docs: condense implementation comments
This commit is contained in:
@@ -131,26 +131,9 @@ final class IOSAudioEngine {
|
||||
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" + ~40–60 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 — mono↔stereo 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).
|
||||
// AVAudioEngine may deliver several codec frames per callback. Pace complete 20 ms frames
|
||||
// through an SPSC ring; never consume partial frames, and recreate the timer when the channel
|
||||
// count changes.
|
||||
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")
|
||||
@@ -314,8 +297,7 @@ final class IOSAudioEngine {
|
||||
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.
|
||||
// The source node must bind to the selected voice-processing output unit.
|
||||
rebuildSourceNode()
|
||||
if micActive { installMicTap() }
|
||||
|
||||
@@ -331,11 +313,7 @@ final class IOSAudioEngine {
|
||||
inFormat=\(inFmt) outputNode=\(outFmt) outputRoute=[\(route)]
|
||||
""")
|
||||
} catch {
|
||||
// iOS occasionally refuses to start the engine immediately after a route change —
|
||||
// the AVAudioSession needs a re-activation nudge before the engine will start. Do
|
||||
// ONE recovery attempt: re-activate the session, re-apply the route config, then
|
||||
// try `engine.start()` again. Recovering here is what fixes the silent-death bug
|
||||
// where unplugging headphones left the engine stopped forever.
|
||||
// Route changes can leave AVAudioSession inactive; retry once after reactivation.
|
||||
logger.error("engine start failed: \(error.localizedDescription) — attempting one-shot recovery")
|
||||
do {
|
||||
try AudioSessionManager.shared.ensureSessionActive()
|
||||
@@ -476,9 +454,7 @@ final class IOSAudioEngine {
|
||||
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.
|
||||
// Re-prime with a larger cushion; consuming a partial frame would lose samples.
|
||||
if state.targetFrames < PumpState.maxTargetFrames { state.targetFrames += 1 }
|
||||
state.primed = false
|
||||
return
|
||||
|
||||
Reference in New Issue
Block a user