A Swift wrapper around the Google LC3 (Low Complexity Communication Codec) library, providing easy-to-use audio encoding and decoding functionality for iOS and macOS applications.
-
**Complete
⚠️ Important: LC3 does NOT perform automatic resampling of PCM data. This is a critical point to understand: -
Decoder Output: Always at
configuration.sampleRate
, alwaysconfiguration.frameSamples
samples per frame -
Encoder Input: Must be at
configuration.sampleRate
, exactlyconfiguration.frameSamples
samples per frame -
No Automatic Conversion: The API has been simplified - you must handle any resampling externallyImplementation**: Full implementation of the Google LC3 codec
-
Swift-friendly API: Clean, modern Swift interface with proper error handling
-
iOS/macOS Support: Universal framework supporting both iOS devices and simulators
-
Performance Optimized: Includes ARM64 NEON optimizations for mobile devices
-
Memory Safe: Swift wrapper handles memory management automatically
The project consists of:
- LC3 C Library: The complete Google LC3 codec implementation (13 C source files)
- Swift Wrapper: Modern Swift classes providing type-safe audio processing
- Universal Framework: Pre-built framework supporting multiple architectures
Add the framework to your Xcode project and import it:
import SwiftyLC3
// Create configuration
let config = try LC3Configuration(
frameDuration: .ms10, // 10ms frames
sampleRate: .hz48000, // 48kHz
highResolutionMode: false,
pcmFormat: .int16
)
// Create encoder (input PCM must match config.sampleRate)
let encoder = try LC3Encoder(configuration: config)
// Create decoder (output PCM will be at config.sampleRate)
let decoder = try LC3Decoder(configuration: config)
// Choose target bitrate and calculate frame size
let targetBitrate = 128000 // 128 kbps
let frameBytes = config.frameBytes(for: targetBitrate)
// Encode audio data
let pcmSamples: [Int16] = [...] // Your audio samples
let encodedFrame = try encoder.encode(pcmSamples, frameBytes: frameBytes)
// Decode audio data
let decodedSamples = try decoder.decode(encodedFrame, frameBytes: frameBytes)
struct LC3Configuration {
init(frameDuration: LC3FrameDuration,
sampleRate: LC3SampleRate,
highResolutionMode: Bool = false,
pcmFormat: LC3PCMFormat = .int16) throws
var frameSamples: Int { get }
var delaySamples: Int { get }
var minBitrate: Int { get }
var maxBitrate: Int { get }
func frameBytes(for bitrate: Int) -> Int
func bitrate(for frameBytes: Int) -> Int
}
frameDuration
: Controls the size of each audio frame processed by the codec.
.ms2_5
(2.5ms): Ultra-low latency, LC3 Plus only.ms5
(5ms): Very low latency, LC3 Plus only.ms7_5
(7.5ms): Standard LC3, good balance of quality and latency.ms10
(10ms): Standard LC3, better compression efficiency- Shorter frames = lower latency but less compression efficiency
sampleRate
: The internal codec sample rate (not necessarily your PCM rate).
- Determines the codec's frequency resolution and processing bandwidth
- Common values:
.hz48000
for high quality,.hz16000
for voice - Can be different from your input/output PCM sample rate
highResolutionMode
: Enables LC3 Plus features for enhanced quality.
false
: Standard LC3 codec (broader compatibility)true
: LC3 Plus with extended features (96kHz support, improved algorithms)- Use
true
only if you need 96kHz or LC3 Plus specific features
pcmFormat
: Specifies the bit depth and format of PCM data you'll work with.
.int16
: 16-bit signed integers (most common, -32768 to 32767).int24
: 24-bit signed integers (higher dynamic range).int24_3LE
: 24-bit packed in 3 bytes, little-endian.float32
: 32-bit floating point (-1.0 to 1.0)
frameSamples
: Number of PCM samples per frame at the codec's sample rate.
- Example: 48kHz, 10ms frame = 480 samples per frame
- Used to determine how much PCM data to provide per encode() call
delaySamples
: Algorithmic delay introduced by the codec in samples.
- Represents the latency between input and output
- Important for synchronization in real-time applications
- Example: ~2.5ms typical delay = ~120 samples at 48kHz
class LC3Encoder {
init(configuration: LC3Configuration) throws
func encode(pcmData: Data, stride: Int = 1, frameBytes: Int) throws -> Data
func encode<T>(_ samples: [T], stride: Int = 1, frameBytes: Int) throws -> Data
func disableLTPF()
}
Input PCM must match configuration.sampleRate
:
- Sample rate: Must be exactly
configuration.sampleRate
- Sample count: Must be exactly
configuration.frameSamples
per encode() call - Format: Must match
configuration.pcmFormat
(Int16, Float32, etc.) - No automatic resampling: If your source is at a different rate, you must resample it before encoding
stride
: Step between consecutive samples in multi-channel data.
1
: Mono audio or processing one channel at a time2
: Stereo interleaved [L, R, L, R...] - pass stride=2 to encode just L channeln
: For n-channel interleaved audio- Most single-channel use cases should use stride=1
frameBytes
: Target size of the compressed frame in bytes.
- Controls the bitrate: smaller frameBytes = lower bitrate = lower quality
- Calculate using
config.frameBytes(for: targetBitrate)
- Example: 48kHz, 10ms, 128kbps → ~160 bytes per frame
- This is the output size, not related to input PCM size
class LC3Decoder {
init(configuration: LC3Configuration) throws
func decode(frameData: Data?, stride: Int = 1) throws -> (pcmData: Data, packetLossConcealed: Bool)
func decode<T>(frameData: Data?, stride: Int = 1, as type: T.Type) throws -> (samples: [T], packetLossConcealed: Bool)
}
Output PCM is always at configuration.sampleRate
:
- Sample rate: Always exactly
configuration.sampleRate
- Sample count: Always exactly
configuration.frameSamples
per decode() call - Format: Always matches
configuration.pcmFormat
(Int16, Float32, etc.) - No automatic resampling: If you need a different output rate, you must resample the decoded PCM yourself
frameBytes
: Size of the compressed frame being decoded (must match encoder).
- Should be the same value used when encoding this frame
- Tells the decoder how much compressed data to expect
- Input parameter: size of compressed data, not output PCM size
stride
: Step for writing decoded samples in multi-channel scenarios.
1
: Write samples consecutively (most common)2
: Write every other sample (for interleaving with other channels)- Used when manually managing multi-channel audio layout
The decoder outputs PCM data in the format specified by configuration.pcmFormat
:
.int16
: 16 bits per sample (2 bytes per sample).int24
: 24 bits per sample (4 bytes per sample, padded).float32
: 32 bits per sample (4 bytes per sample)- Number of samples =
configuration.frameSamples
enum LC3FrameDuration: Int {
case ms2_5 = 2500 // 2.5ms (LC3 Plus only)
case ms5 = 5000 // 5ms (LC3 Plus only)
case ms7_5 = 7500 // 7.5ms (LC3 standard)
case ms10 = 10000 // 10ms (LC3 standard and Plus)
}
enum LC3SampleRate: Int {
case hz8000 = 8000
case hz16000 = 16000
case hz24000 = 24000
case hz32000 = 32000
case hz48000 = 48000
case hz96000 = 96000 // High-Resolution mode only
}
enum LC3PCMFormat {
case int16
case int24
case int24_3LE
case float32
}
- Sample Rates: 8000, 16000, 24000, 32000, 48000, 96000 Hz
- Frame Durations: 2.5ms, 5ms, 7.5ms, 10ms (availability depends on LC3 vs LC3 Plus)
- PCM Formats: Int16, Int24, Int24_3LE, Float32
- Bit Rates: Dynamic, calculated based on frame size and configuration
- Multi-Channel: Supported via
LC3MultiChannelEncoder
/LC3MultiChannelDecoder
LC3 does NOT perform automatic resampling of PCM data. This is a critical point to understand:
- Decoder Output: Always at
configuration.sampleRate
, alwaysconfiguration.frameSamples
samples per frame - Encoder Input: Should be at
configuration.sampleRate
, exactlyconfiguration.frameSamples
samples per frame - No Automatic Conversion: The
pcmSampleRate
parameter is for C API compatibility but does not change I/O sample rates
If you need different sample rates for your application:
- Resample your input to match
configuration.sampleRate
before encoding - Resample the decoder output to your desired sample rate after decoding
- Use external resampling libraries (e.g.,
libsamplerate
,SpeexDSP
, or custom implementations)
Example with 48kHz application and 24kHz codec:
// Your app works with 48kHz, but you want to use 24kHz LC3 for efficiency
let config = try LC3Configuration(frameDuration: .ms10, sampleRate: .hz24000)
// Input: 480 samples at 48kHz (10ms) → Downsample to 240 samples at 24kHz
let input48k: [Int16] = getAudioFrame() // 480 samples
let input24k = resampleDown(input48k, from: 48000, to: 24000) // 240 samples
// Encode at 24kHz
let encoded = try encoder.encode(input24k, frameBytes: frameBytes)
// Decode at 24kHz
let (decoded24k, _) = try decoder.decode(frameData: encoded) // 240 samples
// Decode output: 240 samples at 24kHz → Upsample to 480 samples at 48kHz
let decoded48k = resampleUp(decoded24k, from: 24000, to: 48000) // 480 samples
configuration.sampleRate
, regardless of the pcmSampleRate
parameter.
There are two key sample rates to understand:
- Codec Sample Rate (
configuration.sampleRate
): The sample rate the LC3 codec operates at internally - PCM Sample Rate (
pcmSampleRate
parameter): Used for C API compatibility but does NOT affect output sample rate
let config = try LC3Configuration(
frameDuration: .ms10,
sampleRate: .hz24000 // Codec operates at 24kHz
)
let decoder = try LC3Decoder(
configuration: config,
pcmSampleRate: .hz48000 // ⚠️ This does NOT change output rate!
)
// The decoder will ALWAYS output 240 samples at 24kHz per 10ms frame
// regardless of the pcmSampleRate parameter
let (pcmData, _) = try decoder.decode(frameData: encodedFrame)
// pcmData contains exactly 240 samples (24kHz × 0.01s = 240 samples)
For Decoders:
- Output sample rate =
configuration.sampleRate
(always) - Output sample count =
configuration.frameSamples
(always) - The
pcmSampleRate
parameter is ignored for output formatting - If you need a different output sample rate, you must resample the decoded PCM yourself
For Encoders:
- Input sample rate should match
configuration.sampleRate
- Input sample count should be exactly
configuration.frameSamples
- The
pcmSampleRate
parameter is used internally by the C API but doesn't change expected input format
Example at 24kHz with 10ms frames:
let config = try LC3Configuration(frameDuration: .ms10, sampleRate: .hz24000)
print("Samples per frame: \(config.frameSamples)") // Always 240 for 24kHz/10ms
let decoder = try LC3Decoder(configuration: config)
let (decodedPCM, _) = try decoder.decode(frameData: someFrame)
// decodedPCM will contain exactly 240 samples
// These 240 samples represent 10ms of audio at 24kHz
// If you need 48kHz output, you must resample these 240 samples to 480 samples
The pcmFormat
in configuration determines bits per sample:
let config = try LC3Configuration(pcmFormat: .int16) // 16 bits per sample
// Input data sizing:
let samplesPerFrame = config.frameSamples // e.g., 480 samples for 48kHz/10ms
let bytesPerSample = 2 // 2 bytes for int16
let inputSize = samplesPerFrame * bytesPerSample // 960 bytes per frame
// Your PCM data must be exactly this size
let pcmSamples = [Int16](repeating: 0, count: samplesPerFrame)
Format Details:
.int16
: 2 bytes/sample, range: -32,768 to 32,767.int24
: 4 bytes/sample (padded), range: -8,388,608 to 8,388,607.float32
: 4 bytes/sample, range: -1.0 to 1.0
Bitrate is controlled indirectly through frame size:
let config = try LC3Configuration(frameDuration: .ms10, sampleRate: .hz48000)
// Check supported bitrate range
print("Min bitrate: \(config.minBitrate) bps")
print("Max bitrate: \(config.maxBitrate) bps")
// Convert bitrate to frame size
let targetBitrate = 128000 // 128 kbps
let frameBytes = config.frameBytes(for: targetBitrate)
// Or convert frame size back to bitrate
let actualBitrate = config.bitrate(for: frameBytes)
Bitrate Formula:
bitrate (bps) = (frameBytes * 8 * 1000) / frameDuration (ms)
Example: 160 bytes, 10ms frame = (160 × 8 × 1000) / 10 = 128,000 bps
// 1. Configure the codec
let config = try LC3Configuration(
frameDuration: .ms10, // 10ms frames for good efficiency
sampleRate: .hz24000, // 24kHz codec sample rate
highResolutionMode: false, // Standard LC3 for compatibility
pcmFormat: .int16 // 16-bit PCM (most common)
)
// 2. Set up encoder/decoder (both use codec sample rate)
let encoder = try LC3Encoder(configuration: config)
let decoder = try LC3Decoder(configuration: config)
// 3. Calculate frame sizing
let targetBitrate = 64000 // 64 kbps target (good for 24kHz)
let frameBytes = config.frameBytes(for: targetBitrate) // ~80 bytes for 24kHz
let samplesPerFrame = config.frameSamples // 240 samples at 24kHz
print("Frame info:")
print("- Sample rate: \(config.sampleRate.rawValue) Hz")
print("- Samples per frame: \(samplesPerFrame)")
print("- Frame bytes: \(frameBytes)")
print("- Actual bitrate: \(config.bitrate(for: frameBytes)) bps")
// 4. Process audio frame by frame
let inputPCM: [Int16] = [...] // Your 24kHz audio data
for frameStart in stride(from: 0, to: inputPCM.count, by: samplesPerFrame) {
let frameEnd = min(frameStart + samplesPerFrame, inputPCM.count)
let frameData = Array(inputPCM[frameStart..<frameEnd])
// Ensure we have exactly the right number of samples
guard frameData.count == samplesPerFrame else {
print("Warning: Frame has \(frameData.count) samples, expected \(samplesPerFrame)")
continue
}
// Encode: 240 samples (24kHz) → ~80 bytes compressed
let encodedFrame = try encoder.encode(frameData, frameBytes: frameBytes)
// Decode: ~80 bytes compressed → 240 samples (24kHz)
let (decodedPCM, wasConcealed) = try decoder.decode(frameData: encodedFrame)
let decodedSamples = decodedPCM.withUnsafeBytes { bytes in
Array(bytes.bindMemory(to: Int16.self))
}
// decodedSamples.count == samplesPerFrame (240)
// These represent exactly 10ms of audio at 24kHz
// Each sample is Int16 (2 bytes, -32768 to 32767)
if wasConcealed {
print("Packet loss concealment was used for this frame")
}
}
// 5. If you need different sample rates, resample before/after LC3
// Example: Convert 48kHz input to 24kHz for LC3, then back to 48kHz
func resample48kTo24k(_ samples48k: [Int16]) -> [Int16] {
// Simple decimation by 2 (in practice, use proper resampling)
return stride(from: 0, to: samples48k.count, by: 2).map { samples48k[$0] }
}
func resample24kTo48k(_ samples24k: [Int16]) -> [Int16] {
// Simple interpolation by 2 (in practice, use proper resampling)
return samples24k.flatMap { sample in [sample, sample] }
}
// Stereo encoding
let stereoEncoder = try LC3MultiChannelEncoder(
configuration: config,
channelCount: 2
)
// Interleaved stereo samples [L, R, L, R, ...]
let interleavedSamples: [Int16] = [...]
let encodedChannels = try stereoEncoder.encode(interleavedSamples, frameBytes: frameBytes)
// Returns array of Data, one per channel
// Manual stereo with stride (alternative approach)
let leftChannel = try encoder.encode(interleavedSamples, stride: 2, frameBytes: frameBytes)
// Encodes every other sample starting at index 0 (left channel)
// Check algorithmic delay for synchronization
let delayMs = Double(config.delaySamples) * 1000.0 / Double(config.sampleRate.rawValue)
print("Codec delay: \(delayMs) ms")
// Estimate memory usage per frame
let inputSize = config.frameSamples * 2 // 2 bytes per Int16 sample
let outputSize = frameBytes // Compressed size
print("Memory per frame: \(inputSize) bytes PCM → \(outputSize) bytes compressed")
// Disable LTPF for synthetic audio (music generation, sine waves)
encoder.disableLTPF() // Improves efficiency for non-speech signals
The project generates XCFrameworks containing multiple platforms:
build/XCFramework/Release/SwiftyLC3.xcframework
- iOS device + simulator (Release)build/XCFramework/Debug/SwiftyLC3.xcframework
- iOS device + simulator (Debug)- Debug symbols (.dSYM) included for Release builds
When macOS support is added to the Xcode project, the build script automatically detects it and creates XCFrameworks containing:
- iOS Device (arm64)
- iOS Simulator (arm64 + x86_64)
- macOS (arm64 + x86_64)
To add macOS support to the project:
- Open the Xcode project
- Select the SwiftyLC3 target
- In the "General" tab, under "Deployment Info":
- Click the "+" button next to "Supported Destinations"
- Add "macOS"
- Set minimum macOS version (e.g., 10.15)
- Update build settings if needed:
- Ensure the C library builds for macOS
- Check that Swift code is macOS-compatible
- Run the build script - it will automatically detect and build for macOS
The build script (build_all.sh
) automatically detects macOS support and builds for all available platforms.
- Language: Swift 5.0+ with C interoperability
- iOS Deployment Target: 12.0+
- Architecture Support: arm64, x86_64 (simulator)
- Optimization: Release builds use
-Os
for size optimization - Memory Management: Automatic cleanup of native resources
The LC3 codec is designed for low-latency, high-quality audio transmission:
- Encoding Latency: ~5ms typical
- Quality: Excellent quality at low bitrates
- CPU Usage: Optimized for mobile processors
- Memory Usage: Minimal dynamic allocation
This project wraps the Google LC3 codec implementation. Please refer to the original LC3 license terms for usage restrictions.
- Fork the repository
- Create a feature branch
- Make your changes
- Add tests for new functionality
- Submit a pull request
For issues, questions, or contributions, please use the GitHub issue tracker.