From 7e34ca3d5eb57c062e717c0e2767a7df068362aa Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Fri, 7 Aug 2026 16:09:33 +0200 Subject: [PATCH 1/2] fix(client/apple): a session that can't voice-process still sends its microphone Field report: mic uplink dead on iOS, iPadOS and macOS alike, while Android on the same host works - so the host and the wire are fine. Two defects in the combined (voice-processing) engine, which became the default on all three Apple platforms a week ago and has never run on a device - CI only runs swift test on macOS, and the loopback test counts datagrams without decoding them. - The tap read the input format before the engine was prepared. Enabling voice processing swaps the engine's IO unit for the VPIO one and renegotiates its formats; until prepare() the input node can still report the pre-swap state, 0 Hz / 0 channels included, which installMicTap correctly refuses as 'no usable input device'. Both topologies now prepare first, so the chain is built against what the voice processor actually emits. - A mic chain that failed on the voice-processed engine took the whole uplink down for the session: that arm fell back to playback ONLY. The sibling failure a few lines above - the voice processor refusing to engage at all - already falls back to the split path, which is a working mic without echo cancellation. Both arms do that now. The mic outranks the AEC. Not reproduced locally (no Punktfunk entries in this Mac's log store, and collecting the device's log needs root), so this is a strong inference plus one proven logic defect rather than a confirmed fix. If it persists, Console filtered to subsystem io.unom.punktfunk / category audio names the stage: 'mic capture: N Hz' then 'audio engines joined' then, 10 s in, either 'mic uplink OK - peak ...' or the SILENCE warning. Follow-up worth doing separately: nothing reports whether the uplink actually opened, so the HUD offers a Mute Microphone button over a session sending nothing. Android gates that on a real micRunning signal. --- .../PunktfunkKit/Audio/SessionAudio.swift | 36 +++++++++++++++---- 1 file changed, 29 insertions(+), 7 deletions(-) diff --git a/clients/apple/Sources/PunktfunkKit/Audio/SessionAudio.swift b/clients/apple/Sources/PunktfunkKit/Audio/SessionAudio.swift index d79d12a1..2d123693 100644 --- a/clients/apple/Sources/PunktfunkKit/Audio/SessionAudio.swift +++ b/clients/apple/Sources/PunktfunkKit/Audio/SessionAudio.swift @@ -483,19 +483,33 @@ 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. + // 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 +547,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 { -- 2.54.0 From d2d5058d0ba936d081d1973569614542a92b9550 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Fri, 7 Aug 2026 16:30:29 +0200 Subject: [PATCH 2/2] 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 -- 2.54.0