docs: condense implementation comments
Some checks failed
Build Linux Binaries / linux/amd64 (push) Has been cancelled
Build Linux Binaries / linux/arm64 (push) Has been cancelled

This commit is contained in:
2026-07-23 13:37:05 +02:00
parent 575e2907d0
commit 4f71b784fe
22 changed files with 102 additions and 507 deletions

View File

@@ -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" + ~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).
// 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