Merge pull request 'Apple mic uplink: the microphone was never in the audio graph, so nothing ever pulled it' (#94) from fix/apple-mic-uplink into main
apple / swift (push) Successful in 1m31s
ci / web (push) Successful in 2m15s
ci / bun-nix (push) Successful in 21s
ci / docs-site (push) Successful in 1m14s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 13s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 12s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 37s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 12s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 11s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 24s
ci / rust-arm64 (push) Successful in 3m41s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 22s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 32s
docker / builders-arm64cross (push) Successful in 11s
docker / deploy-docs (push) Successful in 36s
ci / rust (push) Successful in 6m49s
release / apple (push) Successful in 9m41s
apple / screenshots (push) Successful in 5m53s

Reviewed-on: #94
This commit was merged in pull request #94.
This commit is contained in:
2026-08-07 14:33:44 +00:00
@@ -483,19 +483,52 @@ public final class SessionAudio {
}
engine.attach(source)
engine.connect(source, to: engine.mainMixerNode, format: format)
guard installMicTap(on: input, micUID: micUID, micChannel: micChannel) else {
// Mic chain unavailable (logged) keep the session audible on the plain playback
// engine rather than playing through an idle voice processor.
// 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
// `installMicTap` (correctly) refuses as "no usable input device". Preparing first means
// the chain is built against what the voice processor will actually emit.
engine.prepare()
guard installMicTap(on: engine.inputNode, micUID: micUID, micChannel: micChannel) else {
// Mic chain unavailable on the VOICE-PROCESSED engine (logged). The mic outranks the
// echo cancellation, so fall back to the split path its own engine, no voice
// processor, the topology that shipped before AEC existed rather than dropping the
// uplink for the rest of the session. (The sibling failure above, where the voice
// processor won't engage at all, already does exactly this; this arm used to give up
// on the mic instead, which is how a whole session could go silent uplink-only.)
engine.stop()
startPlayback(speakerUID: speakerUID)
startCapture(micUID: micUID, micChannel: micChannel)
return
}
engine.prepare()
do {
try engine.start()
} catch {
log.error("combined engine failed to start: \(error.localizedDescription)")
input.removeTap(onBus: 0)
startPlayback(speakerUID: speakerUID) // no echo cancellation beats no audio
engine.inputNode.removeTap(onBus: 0)
engine.stop()
// Same rule: a working mic without echo cancellation beats no mic at all.
startPlayback(speakerUID: speakerUID)
startCapture(micUID: micUID, micChannel: micChannel)
return
}
stateLock.lock()
@@ -533,8 +566,16 @@ public final class SessionAudio {
}
}
#endif
guard installMicTap(on: input, micUID: micUID, micChannel: micChannel) else { return }
// Prepared before the tap reads a format, for the same reason the combined path does it:
// a node that hasn't been through `prepare()` can still report the pre-negotiation
// format (0 Hz / 0 channels on a device that is perfectly fine), which reads downstream
// as "no microphone".
engine.prepare()
guard installMicTap(on: engine.inputNode, micUID: micUID, micChannel: micChannel) else {
log.error("mic uplink unavailable — this session sends no microphone audio")
engine.stop()
return
}
do {
try engine.start()
} catch {