Skip to content

mattgalloway/SwiftyLC3

Repository files navigation

SwiftyLC3

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.

Features

  • **Complete⚠️ Important: LC3 does NOT perform automatic resampling of PCM data. This is a critical point to understand:

  • Decoder Output: Always at configuration.sampleRate, always configuration.frameSamples samples per frame

  • Encoder Input: Must be at configuration.sampleRate, exactly configuration.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

Architecture

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

Quick Start

Installation

Add the framework to your Xcode project and import it:

import SwiftyLC3

Basic Usage

// 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)

API Reference

LC3Configuration

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
}

Configuration Parameters Explained

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)

Configuration Properties Explained

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

LC3Encoder

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()
}

Encoder Input Requirements

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 time
  • 2: Stereo interleaved [L, R, L, R...] - pass stride=2 to encode just L channel
  • n: 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

LC3Decoder

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)
}

Decoder Output Guarantees

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

Output Format

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

Enums and Types

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
}

Supported Parameters

  • 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

⚠️ Important: Sample Rate Handling

LC3 does NOT perform automatic resampling of PCM data. This is a critical point to understand:

  • Decoder Output: Always at configuration.sampleRate, always configuration.frameSamples samples per frame
  • Encoder Input: Should be at configuration.sampleRate, exactly configuration.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:

  1. Resample your input to match configuration.sampleRate before encoding
  2. Resample the decoder output to your desired sample rate after decoding
  3. 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

Understanding Sample Rates and Data Flow

Sample Rate Relationships

⚠️ Important: LC3 does NOT resample PCM data. The decoder always outputs PCM at the codec's sample rate specified in configuration.sampleRate, regardless of the pcmSampleRate parameter.

There are two key sample rates to understand:

  1. Codec Sample Rate (configuration.sampleRate): The sample rate the LC3 codec operates at internally
  2. 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)

Critical Sample Rate Facts

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

PCM Data Format and Bit Depth

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 Configuration

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

Complete Workflow Example

// 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] }
}

Multi-Channel Usage

// 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)

Memory and Performance Considerations

// 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

Build Output

The project generates XCFrameworks containing multiple platforms:

iOS-only Project (Current)

  • 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

With macOS Support (Optional)

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)

Adding macOS Support

To add macOS support to the project:

  1. Open the Xcode project
  2. Select the SwiftyLC3 target
  3. In the "General" tab, under "Deployment Info":
    • Click the "+" button next to "Supported Destinations"
    • Add "macOS"
    • Set minimum macOS version (e.g., 10.15)
  4. Update build settings if needed:
    • Ensure the C library builds for macOS
    • Check that Swift code is macOS-compatible
  5. 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.

Technical Details

  • 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

Performance

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

License

This project wraps the Google LC3 codec implementation. Please refer to the original LC3 license terms for usage restrictions.

Contributing

  1. Fork the repository
  2. Create a feature branch
  3. Make your changes
  4. Add tests for new functionality
  5. Submit a pull request

Support

For issues, questions, or contributions, please use the GitHub issue tracker.

About

XCFramework that provides a Swift wrapper for Google's LC3 Audio Codec for iOS and macOS

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published