fix(client/apple): the mic was never in the graph, so nothing ever pulled it
ci / web (pull_request) Successful in 1m1s
ci / docs-site (pull_request) Successful in 1m15s
ci / bun-nix (pull_request) Successful in 20s
apple / swift (pull_request) Successful in 1m30s
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 1m45s
ci / rust (pull_request) Successful in 5m43s

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.
This commit is contained in:
2026-08-07 16:30:29 +02:00
parent 7e34ca3d5e
commit d2d5058d0b
@@ -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