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
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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user