From d2d5058d0ba936d081d1973569614542a92b9550 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Fri, 7 Aug 2026 16:30:29 +0200 Subject: [PATCH] fix(client/apple): the mic was never in the graph, so nothing ever pulled it ROOT CAUSE, from the reporter's device log: 16:25:49.093 mic capture: 48000 Hz, 1 ch <- tap installed, format fine 16:25:49.235 audio engines joined - voice processing active ... 13 s of session, no errors, and the 10 s silence verdict NEVER fires The engine started clean and the tap was installed against a valid format - so neither the format timing nor the encoder was the fault. The tripwire fires after ten seconds of CAPTURED frames and never fired across a 13-second session: the tap received nothing at all. Because the capture side must be pulled, and only the render graph pulls anything. On the combined engine the input node carried a tap and no connection, so it was not in the graph and nobody drove it: the IO unit came up (the recording indicator lit for a beat, then went out as the input went idle) and not one buffer ever reached the tap. No error, no failed start - a session that quietly sent no microphone. The input now runs through a silent sink into the main mixer, which is what Apple's own voice-processing sample does. outputVolume = 0 because the mic must reach the graph and never the speaker. The split path never needed this - a capture-only engine has the input node AS its graph - so this broke exactly when the combined topology became the default. Verified: swift build (macOS), swift build --triple arm64-apple-ios17.0, swift test 208 passed. Awaiting the reporter's on-device confirmation. --- .../PunktfunkKit/Audio/SessionAudio.swift | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/clients/apple/Sources/PunktfunkKit/Audio/SessionAudio.swift b/clients/apple/Sources/PunktfunkKit/Audio/SessionAudio.swift index 2d123693..60fd6ef4 100644 --- a/clients/apple/Sources/PunktfunkKit/Audio/SessionAudio.swift +++ b/clients/apple/Sources/PunktfunkKit/Audio/SessionAudio.swift @@ -483,6 +483,25 @@ public final class SessionAudio { } engine.attach(source) engine.connect(source, to: engine.mainMixerNode, format: format) + + // The capture side must be PULLED, and only the render graph pulls anything. An input + // node carrying nothing but a tap is not part of that graph, so on the combined engine + // nobody drove it: the IO unit came up (the recording indicator lit for a beat, then went + // out as the input went idle) and NOT ONE BUFFER ever reached the tap — no error, no + // failed start, just a session that quietly sent no microphone at all. Routing the input + // through a silent sink puts it in the graph, which is what Apple's own voice-processing + // sample does. The split path never needed it: a capture-only engine has the input node + // AS its graph, so it is pulled by definition — which is why this only broke when the + // combined topology became the default. + // + // `outputVolume = 0` on the sink: the mic has to reach the graph, never the speaker. At + // any audible volume this is a microphone wired straight to the earpiece. + let micSink = AVAudioMixerNode() + engine.attach(micSink) + micSink.outputVolume = 0 + engine.connect(engine.inputNode, to: micSink, format: nil) + engine.connect(micSink, to: engine.mainMixerNode, format: nil) + // BEFORE the tap reads a format. Enabling voice processing swaps the engine's IO unit // for the VPIO one and renegotiates its formats, and until the engine is prepared the // input node can still report the pre-swap state — 0 Hz / 0 channels included, which