From 7e34ca3d5eb57c062e717c0e2767a7df068362aa Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Fri, 7 Aug 2026 16:09:33 +0200 Subject: [PATCH] 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 {