Files
voice-cat/clients/apple/iOS/VoiceCatiOS/IOSVoiceProcessingEngine.swift
Talon 19c2fb6ec9 fix(ios): pace mic feed with a prebuffer cushion to stop flutter/crackle
The iOS mic was unusable — a consistent ~40-60ms flutter + volume fade
('slow fan') on every preset. The core sends each captured frame
synchronously (no send pacer), so packet cadence == capture cadence, and
the receiver's playout keeps near-zero buffering by design (its jitter
estimate keys off the regular sender timestamp, so it's blind to arrival
jitter). That's smooth only for a steady sender (desktop miniaudio =
steady 20ms); the iOS AVAudioEngine tap delivers ~2 frames per ~40ms
callback -> bursty -> receiver underruns -> PLC fade.

Fix (iOS-only): the mic tap writes converted 48kHz int16 to an SPSC ring;
a 20ms feed pump drains it and calls feedPcm at a steady cadence. The pump
primes a small prebuffer cushion (3 frames ~60ms, self-healing up to
~120ms on underrun) before releasing, so the tap's bursts can't drain it
to empty. Never reads a partial frame (read consumes what it returns ->
partials were the crackle), and rebuilds with the current channel count
each rebuild() (a frozen count fed mono-as-stereo = octave-up on a
Stereo->Voice Chat switch).

Trade-off: ~60-120ms added mic-send latency, unavoidable when de-bursting
for a near-zero-buffer receiver. PROGRESS.md notes the proper follow-up:
make the jitter buffer measure real RFC-3550 arrival jitter so the
receiver absorbs bursts itself.

Verified: xcodebuild Debug BUILD SUCCEEDED (iOS Simulator, arm64).
2026-06-23 15:40:54 +02:00

455 lines
23 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import AVFoundation
import Darwin
import os
import VoiceCatCore
private let logger = Logger(subsystem: "cat.voice.VoiceCatiOS", category: "IOSAudioEngine")
/// In-process single-producer/single-consumer int16 PCM ring for the playback path.
///
/// producer = the core's mixer-timer thread (the `vc_set_mixed_output_sink` callback)
/// consumer = the `AVAudioSourceNode` render thread
///
/// Heap-backed (not shared memory like `BroadcastAudioRing`), but the same discipline: aligned
/// 64-bit monotonic indices with `OSMemoryBarrier` for acquire/release ordering. Both the C
/// callback and the render block are real-time they only do index math + a memcpy here, never
/// lock or allocate.
final class PCMRing {
private let data: UnsafeMutablePointer<Int16>
private let capacity: Int
private var writeIdx: UInt64 = 0
private var readIdx: UInt64 = 0
init(capacitySamples: Int) {
capacity = capacitySamples
data = UnsafeMutablePointer<Int16>.allocate(capacity: capacitySamples)
data.initialize(repeating: 0, count: capacitySamples)
}
deinit { data.deallocate() }
/// Producer: append `count` interleaved int16 samples. Drops the chunk if it doesn't fit
/// (better to skip than tear). Single producer only (the core mixer-timer thread).
func write(_ src: UnsafePointer<Int16>, count: Int) {
guard count > 0, count <= capacity else { return }
let w = writeIdx
OSMemoryBarrier()
let r = readIdx
if capacity - Int(w &- r) < count { return } // full: drop
var idx = Int(w % UInt64(capacity))
var off = 0
var rem = count
while rem > 0 {
let chunk = min(rem, capacity - idx)
(data + idx).update(from: src + off, count: chunk)
idx = (idx + chunk) % capacity
off += chunk
rem -= chunk
}
OSMemoryBarrier()
writeIdx = w &+ UInt64(count)
}
/// Consumer: read up to `count` interleaved int16 samples into `dst`; returns the number
/// read (the rest is the caller's to silence-fill). Single consumer only (render thread).
func read(into dst: UnsafeMutablePointer<Int16>, count: Int) -> Int {
let r = readIdx
OSMemoryBarrier()
let w = writeIdx
let available = Int(w &- r)
if available <= 0 { return 0 }
let n = min(available, count)
var idx = Int(r % UInt64(capacity))
var off = 0
var rem = n
while rem > 0 {
let chunk = min(rem, capacity - idx)
(dst + off).update(from: data + idx, count: chunk)
idx = (idx + chunk) % capacity
off += chunk
rem -= chunk
}
OSMemoryBarrier()
readIdx = r &+ UInt64(n)
return n
}
/// Consumer-side snapshot of how many interleaved int16 samples are currently buffered. Lets a
/// paced consumer check for a full frame *before* calling `read`, so it never reads (and thus
/// discards) a partial frame. Single consumer only (same thread that calls `read`).
var availableSamples: Int {
let r = readIdx
OSMemoryBarrier()
let w = writeIdx
return Int(w &- r)
}
/// Discard everything buffered call before (re)starting so stale pre-roll isn't played.
func reset() { OSMemoryBarrier(); readIdx = writeIdx }
/// Diagnostics: monotonic total samples written / read since the ring was created. The
/// indices are already cumulative, so these are free. Only read them when both threads are
/// quiesced (e.g. at teardown after the engine + mixer sink are stopped) they are not
/// synchronized for live cross-thread reads. Lets us tell "core never delivered PCM" apart
/// from "PCM arrived but produced no sound" (the AVAudioEngine output graph).
var debugTotalWritten: UInt64 { writeIdx }
var debugTotalRead: UInt64 { readIdx }
}
/// The single iOS audio engine (docs/voice.md §8 "iOS audio engine").
///
/// **One path, always external.** On iOS the core never opens a miniaudio device: a MIC stream is
/// always started with `external_feed=1`, `vc_set_external_playback(1)` is set once at connect, and
/// this engine drives *both* directions through one `AVAudioEngine`:
/// - **core speaker:** the core's mixed-output sink fills `ring`; an `AVAudioSourceNode` pulls
/// from it and renders through the engine output. This runs the whole time we're connected,
/// so remote audio plays even before the user joins voice (no "can't hear anyone").
/// - **mic core:** when the mic is active a tap on the input node converts to 48 kHz int16 and
/// writes to a pacing ring; a 20 ms timer releases steady 960-sample frames to
/// `client.feedPcm(micStreamId)`. The core sends each captured frame synchronously, so this
/// steady cadence is what keeps packets from bursting and fluttering the receiver's playout.
///
/// Echo cancellation / noise suppression / AGC come from Apple's Voice-Processing I/O unit (VPIO),
/// which `inputNode.setVoiceProcessingEnabled(true)` enables. VPIO forces mono, so it is engaged
/// only when the active preset wants it (`IOSAudioRouter.currentConfigUsesVoiceProcessing`) the
/// Stereo Mic / A2DP configs run the same engine with VPIO off.
///
/// Every preset / route / interruption change funnels through `reconfigure()`: a single
/// deterministic stop AVAudioSession reconfigure rebuild graph start. There is no second
/// (miniaudio) audio path to hand off to, so a switch cannot leave one direction dropped.
@MainActor
final class IOSAudioEngine {
static let shared = IOSAudioEngine()
/// True while connected (between `startListening` and `stop`) the playback graph should run.
private(set) var isConnected = false
/// True while a local mic stream is active the input tap should be installed.
private(set) var micActive = false
private let engine = AVAudioEngine()
private var sourceNode: AVAudioSourceNode?
private weak var client: VoiceCatClient?
private var micStreamId: UInt32 = 0
private var captureChannels: UInt32 = 1
// Mic feed pacing. The core sends each captured frame SYNCHRONOUSLY as it arrives
// (on_capture_frame encode sendto, client.cpp) there is no send pacer in the core. On
// desktop miniaudio capture fires one 960-sample frame every 20 ms, so packets leave at a
// steady 20 ms. On iOS the AVAudioEngine input tap fires at the hardware IO-buffer period
// (often ~40 ms under VPIO), delivering ~2 frames at once: feeding those straight to the core
// bursts 2 packets out then goes quiet for ~40 ms, and the receiver's ~40 ms jitter buffer
// underruns on every gap PLC fade ("talking through a slow fan" + ~4060 ms flutter).
//
// Fix: pace the feed to a steady 20 ms. The tap converts to int16 and writes to a lock-free
// SPSC ring (producer, audio clock); a 20 ms timer releases ONE 960-sample frame per tick to
// feedPcm (consumer). The producer's average rate is locked to 48 kHz = exactly one frame per
// 20 ms, so it matches the consumer; the ring just absorbs the tap's 2-at-a-time bursts.
//
// Two correctness rules learned the hard way (these caused the earlier crackle + octave):
// 1. NEVER read a partial frame `read` consumes whatever it returns, so reading <960 would
// silently discard those samples (crackle). The timer checks `availableSamples` first and
// only reads when a full frame is present; an underrun just skips the tick (nothing lost).
// 2. NEVER freeze the channel count in the timer monostereo preset switches change it. The
// timer is torn down and recreated inside `rebuild()`, so it always captures the current
// `captureChannels`; the ring is reset while the timer is stopped (no cross-thread race).
private let micRing = PCMRing(capacitySamples: 48000 * 2) // ~1 s stereo ample elastic slack
private var micTimer: DispatchSourceTimer?
private let micQueue = DispatchQueue(label: "cat.voice.mic.feedPump")
private let micDrainScratch: UnsafeMutablePointer<Int16>
private static let micFrameSamplesPerChannel = 960 // 20 ms @ 48 kHz core's frame size
/// Feed-pump state, touched only on `micQueue` (the pump's serial queue). A reference type so
/// the timer closure mutates it without capturing `self` (which is @MainActor). `targetFrames`
/// is the prebuffer depth: the pump fills this many frames before it starts releasing, so the
/// tap's bursty delivery (~2 frames at once) can't drain it to empty between bursts. It persists
/// across rebuilds and self-heals upward (capped) on an underrun, so it tunes to whatever IO
/// buffer size the active route/VPIO actually uses without a hard-coded guess.
private final class PumpState {
var primed = false
var targetFrames = 3 // ~60 ms initial cushion; grows on underrun up to maxTargetFrames
static let maxTargetFrames = 6 // ~120 ms cap bounds added latency
}
private let pumpState = PumpState()
// 48 kHz stereo Float32 (deinterleaved) the format the source node renders. The core
// delivers 48 kHz stereo int16 via the mixed-output sink; mainMixerNode adapts to the route.
private let outFormat = AVAudioFormat(
commonFormat: .pcmFormatFloat32, sampleRate: 48000, channels: 2, interleaved: false)!
// Playback ring (mixed remote audio): ~0.5 s of 48 kHz stereo int16. Filled by the core's
// mixer-timer thread, drained by the source-node render thread.
private let ring = PCMRing(capacitySamples: 48000 * 2 / 2)
// Render-thread scratch for deinterleaving pre-allocated so the render block never allocates.
private let renderScratchFrames = 8192
private let renderScratch: UnsafeMutablePointer<Int16>
private init() {
renderScratch = UnsafeMutablePointer<Int16>.allocate(capacity: renderScratchFrames * 2)
renderScratch.initialize(repeating: 0, count: renderScratchFrames * 2)
micDrainScratch = UnsafeMutablePointer<Int16>.allocate(capacity: 960 * 2)
micDrainScratch.initialize(repeating: 0, count: 960 * 2)
}
// MARK: - Lifecycle
/// Begin playback-only (listening) operation. Called once at connect, after
/// `client.setExternalPlayback(true)` and `AudioSessionManager.ensureSessionActive()`. Attaches
/// the source node, wires the core's mixed-output sink into the ring, and starts the engine so
/// remote audio plays immediately.
func startListening(client: VoiceCatClient) {
self.client = client
guard !isConnected else { return }
isConnected = true
ring.reset()
// Wire the core's mixed-output sink into the ring (C function pointer, no captures). Stays
// registered for the whole connection; the ring is drained by the source-node render block.
let ringPtr = Unmanaged.passUnretained(self.ring).toOpaque()
client.setMixedOutputSink({ user, pcm, spc, ch, _ in
guard let user, let pcm else { return }
let ring = Unmanaged<PCMRing>.fromOpaque(user).takeUnretainedValue()
ring.write(pcm, count: spc * Int(ch))
}, user: ringPtr)
rebuild()
}
/// Tear down the engine and unhook the core sink. Called on disconnect.
func stop() {
guard isConnected else { return }
micActive = false
stopMicTimer()
isConnected = false
client?.setMixedOutputSink(nil, user: nil)
engine.inputNode.removeTap(onBus: 0)
if engine.isRunning { engine.stop() }
logger.info("audio engine stopped — ring written=\(self.ring.debugTotalWritten) read=\(self.ring.debugTotalRead) samples")
try? engine.inputNode.setVoiceProcessingEnabled(false)
if let src = sourceNode {
engine.detach(src)
sourceNode = nil
}
ring.reset()
micRing.reset()
client = nil
}
// MARK: - Mic transitions
/// Engage the mic: install the input tap and (if the preset wants it) VPIO. Called when the
/// user joins voice, after the MIC stream (external_feed) is started.
func startMic(streamId: UInt32, channels: UInt32) {
micStreamId = streamId
captureChannels = channels
micActive = true
rebuild()
}
/// Disengage the mic: remove the tap and VPIO, keep playback running for remaining remote audio.
func stopMic() {
guard micActive else { return }
micActive = false
rebuild()
}
/// Update the capture channel count (monostereo) for the active mic and rebuild.
func setCaptureChannels(_ channels: UInt32) {
captureChannels = channels
if let client, micStreamId != 0 {
client.setCaptureChannels(streamId: micStreamId, channels: channels)
}
if micActive { rebuild() }
}
/// Re-apply the engine graph against the current AVAudioSession config (preset / route change).
/// Safe to call when only listening it just rebuilds the playback graph against the new route.
func reconfigure() {
guard isConnected else { return }
rebuild()
}
// MARK: - Graph (re)build
/// The single place that (re)builds and starts the engine graph. Deterministic: stop set
/// VPIO (re)install the mic tap start. The caller is responsible for having applied the
/// AVAudioSession config (category/mode/route) first (`IOSAudioRouter.applyConfiguration`).
private func rebuild() {
guard isConnected else { return }
// Stop the feed pump before touching the tap / ring so the timer (on micQueue) can't race
// the ring reset in installMicTap. It is restarted at the end with the current channel count.
stopMicTimer()
if engine.isRunning { engine.stop() }
engine.inputNode.removeTap(onBus: 0)
let useVPIO = micActive && IOSAudioRouter.shared.currentConfigUsesVoiceProcessing
do {
try engine.inputNode.setVoiceProcessingEnabled(useVPIO)
} catch {
logger.error("setVoiceProcessingEnabled(\(useVPIO)) failed: \(error.localizedDescription)")
}
if useVPIO {
// AGC is the one VPIO sub-stage iOS exposes; AEC+NS are bundled into the master switch.
engine.inputNode.isVoiceProcessingAGCEnabled = IOSAudioRouter.shared.agcEnabled
}
// (Re)build the playback source node AFTER the VPIO state is set, so it connects against the
// correct (voice-processed or plain) output unit mirrors the proven original ordering.
rebuildSourceNode()
if micActive { installMicTap() }
engine.prepare()
do {
try engine.start()
let inFmt = engine.inputNode.outputFormat(forBus: 0)
let outFmt = engine.outputNode.outputFormat(forBus: 0)
let route = AVAudioSession.sharedInstance().currentRoute.outputs
.map { "\($0.portName)[\($0.portType.rawValue)]" }.joined(separator: ", ")
logger.info("""
engine started — mic=\(self.micActive) vpio=\(useVPIO) captureCh=\(self.captureChannels) \
inFormat=\(inFmt) outputNode=\(outFmt) outputRoute=[\(route)]
""")
} catch {
logger.error("engine start failed: \(error.localizedDescription)")
}
// Start the feed pump last, with the current channel count, so it never carries a stale
// (frozen) channel count across a monostereo switch.
if micActive { startMicTimer() }
}
/// Detach any previous source node and attach a fresh one pulling mixed PCM from the ring.
/// Rebuilt on every graph rebuild so it always connects against the current output unit (the
/// VPIO state can change the output between rebuilds). Its format is route-independent
/// `mainMixerNode` adapts 48 kHz stereo to whatever the output route is.
private func rebuildSourceNode() {
if let old = sourceNode {
engine.detach(old)
sourceNode = nil
}
let ring = self.ring
let scratch = self.renderScratch
let scratchFrames = self.renderScratchFrames
let src = AVAudioSourceNode(format: outFormat) { _, _, frameCount, ablPtr in
let frames = Int(frameCount)
let abl = UnsafeMutableAudioBufferListPointer(ablPtr)
let n = min(frames, scratchFrames)
let got = ring.read(into: scratch, count: n * 2) / 2 // interleaved stereo frames
let scale: Float = 1.0 / 32768.0
for ch in 0..<abl.count {
guard let base = abl[ch].mData?.assumingMemoryBound(to: Float.self) else { continue }
for i in 0..<frames {
base[i] = i < got ? Float(scratch[i * 2 + min(ch, 1)]) * scale : 0
}
}
return noErr
}
sourceNode = src
engine.attach(src)
engine.connect(src, to: engine.mainMixerNode, format: outFormat)
}
/// Install the mic tap: convert the input node's native format to 48 kHz int16 (mono or
/// stereo per `captureChannels`) and write it to the pacing ring. The 20 ms feed pump
/// (`startMicTimer`) releases steady 960-sample frames to `feedPcm` see the mic-feed comment
/// above for why the tap must NOT call feedPcm directly (it bursts packets receiver flutter).
/// Rebuilds the converter each time because the input format depends on the VPIO state and the
/// active route.
private func installMicTap() {
guard client != nil else { return }
// Fresh ring on every (re)install a rebuild must not feed stale pre-roll into the new tap.
// Safe here: the feed pump was stopped at the top of rebuild(), so no consumer is running.
micRing.reset()
let ring = micRing // captured by the closure as a `let` no self capture (see mic-feed comment)
let inFormat = engine.inputNode.outputFormat(forBus: 0)
guard inFormat.sampleRate > 0 else {
logger.error("input format unavailable (\(inFormat)) — mic will not transmit")
return
}
let targetCh = max(1, min(2, captureChannels))
guard let target = AVAudioFormat(commonFormat: .pcmFormatInt16, sampleRate: 48000,
channels: AVAudioChannelCount(targetCh), interleaved: true),
let converter = AVAudioConverter(from: inFormat, to: target) else {
logger.error("mic converter unavailable (in=\(inFormat), ch=\(targetCh)) — mic will not transmit")
return
}
let chInt = Int(targetCh)
engine.inputNode.installTap(onBus: 0, bufferSize: 960, format: inFormat) { buffer, _ in
// Convert this tap buffer to 48 kHz int16. Output capacity scaled for any upsample.
let ratio = target.sampleRate / buffer.format.sampleRate
let outCap = AVAudioFrameCount(Double(buffer.frameLength) * ratio + 16)
guard let outBuf = AVAudioPCMBuffer(pcmFormat: target, frameCapacity: outCap) else { return }
var fed = false
let status = converter.convert(to: outBuf, error: nil) { _, outStatus in
if fed { outStatus.pointee = .noDataNow; return nil }
fed = true
outStatus.pointee = .haveData
return buffer
}
guard status != .error, outBuf.frameLength > 0,
let chData = outBuf.int16ChannelData else { return }
// int16 interleaved channelData[0] is the interleaved buffer. Write the converter's
// variable-length output to the pacing ring; the 20 ms feed pump releases steady
// 960-sample frames to feedPcm so packets leave the core at a steady 20 ms cadence.
ring.write(chData[0], count: Int(outBuf.frameLength) * chInt)
}
}
// MARK: - Mic feed pump (paces feedPcm at a steady 20 ms cadence)
/// Start the 20 ms feed pump. After priming a small cushion (`pumpState.targetFrames`), it
/// releases ONE 960-sample frame per tick from `micRing` to `feedPcm`, so the core (which sends
/// synchronously per captured frame) emits packets at a steady 20 ms the cadence its receivers
/// expect. The cushion is essential: the receiver's playout deliberately keeps near-zero
/// buffering (low latency), so it tolerates a steady stream but not bursts; the iOS tap delivers
/// ~2 frames at once, and without the cushion the pump runs at ~0 depth and underruns on every
/// tap/timer phase beat (crackle). Recreated on every `rebuild()` so `ch` always reflects the
/// current `captureChannels` (monostereo switches). Captures only locals + the reference-type
/// ring/client/state (no `self`, which is @MainActor).
private func startMicTimer() {
stopMicTimer()
guard let client else { return }
let ring = micRing
let scratch = micDrainScratch
let state = pumpState
let sid = micStreamId
let ch = max(1, min(2, Int(captureChannels)))
let frameSamples = Self.micFrameSamplesPerChannel
let full = frameSamples * ch
let chU32 = UInt32(ch)
// The ring was just reset in installMicTap, so the cushion must be refilled before sending.
state.primed = false
let feed: () -> Void = {
_ = ring.read(into: scratch, count: full) // caller guarantees a full frame is present
_ = client.feedPcm(streamId: sid, pcm: scratch,
samplesPerChannel: frameSamples, channels: chU32)
}
let t = DispatchSource.makeTimerSource(queue: micQueue)
t.schedule(deadline: .now(), repeating: .milliseconds(20), leeway: .milliseconds(2))
t.setEventHandler {
let frames = ring.availableSamples / full // whole frames currently buffered
if !state.primed {
if frames < state.targetFrames { return } // still filling the cushion (into silence)
state.primed = true
} else if frames == 0 {
// Underrun: the cushion drained. Grow it (capped) so it won't recur, then re-prime.
// Never read a partial frame `read` consumes what it returns, so that would
// discard samples (the old crackle bug); skipping loses nothing, the samples wait.
if state.targetFrames < PumpState.maxTargetFrames { state.targetFrames += 1 }
state.primed = false
return
}
feed() // one steady frame per tick (frames >= 1 here)
// Catch-up: if the backlog grew past the cushion (pump descheduled, or producer ran
// ahead via a burst), release one extra frame to drain it and keep latency bounded.
if frames - 1 > state.targetFrames + 1 { feed() }
}
t.resume()
micTimer = t
}
private func stopMicTimer() {
micTimer?.cancel()
micTimer = nil
}
}