The capture path carried three latencies that had nothing to do with the network: a 2048-frame tap request (42.7 ms bursts before the first sample could even be sliced), 20 ms Opus framing, and a mono signal duplicated into both stereo channels because the encoder was configured like the downlink. CoreAudio's Opus encoder takes mFramesPerPacket=480 and mChannelsPerFrame=1 just fine — probed empirically: the converter truly emits 10 ms mono CELT packets (TOC config 30), one per 480-frame chunk, and the stereo-shaped decoder upmixes them with the tone intact — so the uplink now asks for 10 ms tap buffers and encodes 48 kbps mono 10 ms packets, with 960 kept only as an init-time fallback. The host decodes any Opus frame ≤120 ms, so nothing changes on the wire's far end. The tap thread also stops burning cycles per callback: the mono fold and resampler scratch buffers are allocated once (regrown only if a larger device quantum ever arrives), and the chunk slicer walks a head index instead of removeFirst — which memmoved the entire backlog for every packet on a render-adjacent thread. iOS additionally asks the session for 5 ms IO quanta at 48 kHz when the mic is on (best-effort; the hardware decides). Verified: swift build (macOS arm64), swift test OpusCodecTests + AudioChannelFoldTests, and an iOS arm64 cross-build; the 480/mono behavior confirmed by TOC inspection on macOS 15. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
163 lines
6.7 KiB
Swift
163 lines
6.7 KiB
Swift
// Opus ⇄ PCM through CoreAudio's built-in codec (kAudioFormatOpus, macOS 10.13+ / iOS
|
|
// 11+) — no bundled libopus. The host's audio plane is raw Opus packets (48 kHz stereo,
|
|
// one frame per packet); the mic uplink is 48 kHz MONO packets (one microphone bus —
|
|
// the host's decoder upmixes, so duplicating it into a second channel only cost bits).
|
|
// AVAudioConverter handles both as single-packet AVAudioCompressedBuffers with explicit
|
|
// packet descriptions.
|
|
//
|
|
// Both classes are single-threaded by contract (one per direction, owned by their
|
|
// drain/capture pipelines).
|
|
|
|
import AVFoundation
|
|
|
|
enum OpusCodecError: Error {
|
|
/// CoreAudio rejected the Opus stream format or had no converter for it.
|
|
case unavailable
|
|
case convertFailed(String)
|
|
}
|
|
|
|
/// 48 kHz stereo float32 interleaved — the decoder's PCM side (the host plane's shape).
|
|
func opusPCMFormat() -> AVAudioFormat? {
|
|
AVAudioFormat(
|
|
commonFormat: .pcmFormatFloat32, sampleRate: 48_000, channels: 2, interleaved: true)
|
|
}
|
|
|
|
/// The compressed side: raw Opus, `framesPerPacket` nominal samples per packet at 48 kHz
|
|
/// (240 = the host's 5 ms audio plane; 480 = the 10 ms packets the encoder emits) and
|
|
/// `channels` (2 = the host plane, 1 = the mic uplink).
|
|
private func opusFormat(framesPerPacket: UInt32, channels: UInt32) -> AVAudioFormat? {
|
|
var desc = AudioStreamBasicDescription(
|
|
mSampleRate: 48_000,
|
|
mFormatID: kAudioFormatOpus,
|
|
mFormatFlags: 0,
|
|
mBytesPerPacket: 0,
|
|
mFramesPerPacket: framesPerPacket,
|
|
mBytesPerFrame: 0,
|
|
mChannelsPerFrame: channels,
|
|
mBitsPerChannel: 0,
|
|
mReserved: 0)
|
|
return AVAudioFormat(streamDescription: &desc)
|
|
}
|
|
|
|
final class OpusDecoder {
|
|
private let converter: AVAudioConverter
|
|
private let inBuf: AVAudioCompressedBuffer
|
|
private let opus: AVAudioFormat
|
|
let pcmFormat: AVAudioFormat
|
|
|
|
/// `framesPerPacket`: the sender's packet duration in samples (host audio = 240).
|
|
init(framesPerPacket: UInt32) throws {
|
|
guard let pcm = opusPCMFormat(),
|
|
let opus = opusFormat(framesPerPacket: framesPerPacket, channels: 2),
|
|
let converter = AVAudioConverter(from: opus, to: pcm)
|
|
else { throw OpusCodecError.unavailable }
|
|
self.converter = converter
|
|
self.opus = opus
|
|
self.pcmFormat = pcm
|
|
inBuf = AVAudioCompressedBuffer(
|
|
format: opus, packetCapacity: 1, maximumPacketSize: 1500)
|
|
}
|
|
|
|
/// Decode one Opus packet into `out` (whose format must be `pcmFormat`); returns the
|
|
/// number of frames written. Empty packets (DTX) decode to 0 frames.
|
|
func decode(_ packet: Data, into out: AVAudioPCMBuffer) throws -> AVAudioFrameCount {
|
|
guard !packet.isEmpty else { return 0 }
|
|
guard packet.count <= Int(inBuf.maximumPacketSize) else {
|
|
throw OpusCodecError.convertFailed("packet larger than maximumPacketSize")
|
|
}
|
|
packet.withUnsafeBytes { raw in
|
|
inBuf.data.copyMemory(from: raw.baseAddress!, byteCount: raw.count)
|
|
}
|
|
inBuf.byteLength = UInt32(packet.count)
|
|
inBuf.packetCount = 1
|
|
inBuf.packetDescriptions![0] = AudioStreamPacketDescription(
|
|
mStartOffset: 0, mVariableFramesInPacket: 0, mDataByteSize: UInt32(packet.count))
|
|
|
|
out.frameLength = 0
|
|
var fed = false
|
|
var convError: NSError?
|
|
let status = converter.convert(to: out, error: &convError) { [inBuf] _, outStatus in
|
|
if fed {
|
|
outStatus.pointee = .noDataNow
|
|
return nil
|
|
}
|
|
fed = true
|
|
outStatus.pointee = .haveData
|
|
return inBuf
|
|
}
|
|
if status == .error {
|
|
throw OpusCodecError.convertFailed(convError?.localizedDescription ?? "decode")
|
|
}
|
|
return out.frameLength
|
|
}
|
|
}
|
|
|
|
final class OpusEncoder {
|
|
/// The encoder's packet duration in samples: 480 = 10 ms, halving the packetization
|
|
/// latency of the old 20 ms framing. CoreAudio honors it — mFramesPerPacket 480/mono
|
|
/// creates a converter that truly emits 10 ms CELT packets (TOC config 30), one per
|
|
/// 480-frame chunk, verified by inspection of the emitted TOC bytes and per-packet
|
|
/// frame accounting. 960 stays as the fallback should an older codec refuse 480.
|
|
/// The host's mic service decodes any Opus frame size up to 120 ms, so either is
|
|
/// wire-compatible.
|
|
let framesPerPacket: AVAudioFrameCount
|
|
|
|
/// 48 kHz MONO float32 interleaved — the uplink carries one microphone bus.
|
|
let pcmFormat: AVAudioFormat
|
|
|
|
private let converter: AVAudioConverter
|
|
private let outBuf: AVAudioCompressedBuffer
|
|
|
|
init() throws {
|
|
guard let pcm = AVAudioFormat(
|
|
commonFormat: .pcmFormatFloat32, sampleRate: 48_000, channels: 1,
|
|
interleaved: true)
|
|
else { throw OpusCodecError.unavailable }
|
|
var made: (converter: AVAudioConverter, fpp: AVAudioFrameCount)?
|
|
for fpp: AVAudioFrameCount in [480, 960] {
|
|
if let opus = opusFormat(framesPerPacket: UInt32(fpp), channels: 1),
|
|
let converter = AVAudioConverter(from: pcm, to: opus) {
|
|
made = (converter, fpp)
|
|
break
|
|
}
|
|
}
|
|
guard let made else { throw OpusCodecError.unavailable }
|
|
// 48 kbps: transparent for mono voice — the old 96 kbps budget was sized for
|
|
// the duplicated-stereo framing this encoder no longer emits.
|
|
made.converter.bitRate = 48_000
|
|
converter = made.converter
|
|
framesPerPacket = made.fpp
|
|
pcmFormat = pcm
|
|
outBuf = AVAudioCompressedBuffer(
|
|
format: made.converter.outputFormat, packetCapacity: 4, maximumPacketSize: 1500)
|
|
}
|
|
|
|
/// Encode exactly `framesPerPacket` frames of `pcmFormat` audio; returns the encoded
|
|
/// packets (normally one).
|
|
func encode(_ pcm: AVAudioPCMBuffer) throws -> [Data] {
|
|
outBuf.byteLength = 0
|
|
outBuf.packetCount = 0
|
|
var fed = false
|
|
var convError: NSError?
|
|
let status = converter.convert(to: outBuf, error: &convError) { _, outStatus in
|
|
if fed {
|
|
outStatus.pointee = .noDataNow
|
|
return nil
|
|
}
|
|
fed = true
|
|
outStatus.pointee = .haveData
|
|
return pcm
|
|
}
|
|
if status == .error {
|
|
throw OpusCodecError.convertFailed(convError?.localizedDescription ?? "encode")
|
|
}
|
|
guard let descs = outBuf.packetDescriptions else { return [] }
|
|
return (0..<Int(outBuf.packetCount)).map { i in
|
|
let d = descs[i]
|
|
return Data(
|
|
bytes: outBuf.data.advanced(by: Int(d.mStartOffset)),
|
|
count: Int(d.mDataByteSize))
|
|
}
|
|
}
|
|
}
|