Files
punktfunk/clients/apple/Sources/PunktfunkKit/Audio/OpusCodec.swift
T
enricobuehlerandClaude Fable 5 e54258b8ac feat(apple): the mic uplink drops to mono 10 ms packets and stops shoveling its FIFO
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>
2026-07-31 21:57:48 +02:00

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