Field report: mic uplink dead on iOS, iPadOS and macOS alike, while Android against the same host works — so the host and the wire are fine. Root-caused from the reporter's device log.
The evidence
16:25:49.093 mic capture: 48000 Hz, 1 ch ← tap installed, format fine
16:25:49.235 audio engines joined — voice processing active ← engine started clean
…13 s of session, no errors, video at 90 fps…
← the 10 s silence verdict NEVER fires
The engine starts cleanly and the tap is installed against a perfectly valid format, so neither the format timing nor the CoreAudio encoder was at fault. But the silence tripwire fires after ten seconds of captured frames, and across a 13-second session it never fired: the tap received nothing at all.
The cause
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 never part of 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; just a session quietly sending 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 — at any audible volume that is a microphone wired straight to the earpiece.
The split (two-engine) path never needed this: a capture-only engine has the input node as its graph, so it is pulled by definition. That is exactly why this broke when the combined topology became the default on all three platforms in f3e122b0 — and why it was never caught, since CI runs swift test on macOS only and the loopback test counts datagrams without decoding one.
Also in this PR (found while tracing, both real)
The tap read the input format before the engine was prepared. Enabling voice processing swaps in the VPIO unit and renegotiates formats; until prepare(), the input node can still report the pre-swap state, 0 Hz / 0 channels included, which installMicTap refuses as "no usable input device". Both topologies prepare first now. (Not what bit here — the log shows a valid 48 kHz format — but a genuine latent hazard.)
A mic chain that failed on the voice-processed engine took the whole uplink down, falling back to playback only. The sibling failure a few lines above 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.
Ruled out along the way
So nobody re-treads it: the setting defaults to on and is read with the correct unset-key pattern; no mute latch can be engaged at start; punktfunk_connection_send_mic is present in both xcframework slices with no capability gate; there is no negotiation or "start mic stream" call the client fails to make; nothing outside SessionAudio touches AVAudioSession, and the category is .playAndRecord whenever the mic is enabled; beginStreaming()'s two call sites are mutually exclusive, so there is no second SessionAudio; and audio?.stop() only runs on disconnect.
Follow-up worth doing separately
Nothing reports whether the uplink actually opened, which is why this reached the field silently: micAvailable is computed from the setting plus TCC status only, so the HUD offers a "Mute Microphone" button over a session sending nothing. Android gates that button on a real micRunning signal. The core also tracks mic counters the C ABI never exports, so the Apple HUD has no uplink telemetry at all.
Verification
swift build (macOS) — green
swift build --triple arm64-apple-ios17.0 — green
swift test — 208 passed, 5 skipped, 0 failures
Built and installed on the reporting iPhone; awaiting on-device confirmation
Field report: **mic uplink dead on iOS, iPadOS and macOS alike**, while Android against the same host works — so the host and the wire are fine. Root-caused from the reporter's device log.
## The evidence
```
16:25:49.093 mic capture: 48000 Hz, 1 ch ← tap installed, format fine
16:25:49.235 audio engines joined — voice processing active ← engine started clean
…13 s of session, no errors, video at 90 fps…
← the 10 s silence verdict NEVER fires
```
The engine starts cleanly and the tap is installed against a perfectly valid format, so neither the format timing nor the CoreAudio encoder was at fault. But the silence tripwire fires after ten seconds of *captured* frames, and across a 13-second session it never fired: **the tap received nothing at all.**
## The cause
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 never part of 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; just a session quietly sending 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 — at any audible volume that is a microphone wired straight to the earpiece.
The split (two-engine) path never needed this: a capture-only engine has the input node *as* its graph, so it is pulled by definition. That is exactly why this broke when the combined topology became the default on all three platforms in `f3e122b0` — and why it was never caught, since CI runs `swift test` on macOS only and the loopback test counts datagrams without decoding one.
## Also in this PR (found while tracing, both real)
- **The tap read the input format before the engine was prepared.** Enabling voice processing swaps in the VPIO unit and renegotiates formats; until `prepare()`, the input node can still report the pre-swap state, 0 Hz / 0 channels included, which `installMicTap` refuses as "no usable input device". Both topologies prepare first now. (Not what bit here — the log shows a valid 48 kHz format — but a genuine latent hazard.)
- **A mic chain that failed on the voice-processed engine took the whole uplink down**, falling back to *playback only*. The sibling failure a few lines above 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.
## Ruled out along the way
So nobody re-treads it: the setting defaults to on and is read with the correct unset-key pattern; no mute latch can be engaged at start; `punktfunk_connection_send_mic` is present in both xcframework slices with no capability gate; there is no negotiation or "start mic stream" call the client fails to make; nothing outside `SessionAudio` touches `AVAudioSession`, and the category is `.playAndRecord` whenever the mic is enabled; `beginStreaming()`'s two call sites are mutually exclusive, so there is no second `SessionAudio`; and `audio?.stop()` only runs on disconnect.
## Follow-up worth doing separately
Nothing reports whether the uplink actually opened, which is why this reached the field silently: `micAvailable` is computed from the setting plus TCC status only, so the HUD offers a "Mute Microphone" button over a session sending nothing. Android gates that button on a real `micRunning` signal. The core also tracks mic counters the C ABI never exports, so the Apple HUD has no uplink telemetry at all.
## Verification
- `swift build` (macOS) — green
- `swift build --triple arm64-apple-ios17.0` — green
- `swift test` — 208 passed, 5 skipped, 0 failures
- Built and installed on the reporting iPhone; **awaiting on-device confirmation**
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.
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.
enricobuehler
changed title from Apple mic uplink: prepare before reading the input format, and never trade the mic for echo cancellation to Apple mic uplink: the microphone was never in the audio graph, so nothing ever pulled it2026-08-07 14:30:56 +00:00
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Field report: mic uplink dead on iOS, iPadOS and macOS alike, while Android against the same host works — so the host and the wire are fine. Root-caused from the reporter's device log.
The evidence
The engine starts cleanly and the tap is installed against a perfectly valid format, so neither the format timing nor the CoreAudio encoder was at fault. But the silence tripwire fires after ten seconds of captured frames, and across a 13-second session it never fired: the tap received nothing at all.
The cause
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 never part of 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; just a session quietly sending 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 = 0because the mic must reach the graph and never the speaker — at any audible volume that is a microphone wired straight to the earpiece.The split (two-engine) path never needed this: a capture-only engine has the input node as its graph, so it is pulled by definition. That is exactly why this broke when the combined topology became the default on all three platforms in
f3e122b0— and why it was never caught, since CI runsswift teston macOS only and the loopback test counts datagrams without decoding one.Also in this PR (found while tracing, both real)
prepare(), the input node can still report the pre-swap state, 0 Hz / 0 channels included, whichinstallMicTaprefuses as "no usable input device". Both topologies prepare first now. (Not what bit here — the log shows a valid 48 kHz format — but a genuine latent hazard.)Ruled out along the way
So nobody re-treads it: the setting defaults to on and is read with the correct unset-key pattern; no mute latch can be engaged at start;
punktfunk_connection_send_micis present in both xcframework slices with no capability gate; there is no negotiation or "start mic stream" call the client fails to make; nothing outsideSessionAudiotouchesAVAudioSession, and the category is.playAndRecordwhenever the mic is enabled;beginStreaming()'s two call sites are mutually exclusive, so there is no secondSessionAudio; andaudio?.stop()only runs on disconnect.Follow-up worth doing separately
Nothing reports whether the uplink actually opened, which is why this reached the field silently:
micAvailableis computed from the setting plus TCC status only, so the HUD offers a "Mute Microphone" button over a session sending nothing. Android gates that button on a realmicRunningsignal. The core also tracks mic counters the C ABI never exports, so the Apple HUD has no uplink telemetry at all.Verification
swift build(macOS) — greenswift build --triple arm64-apple-ios17.0— greenswift test— 208 passed, 5 skipped, 0 failuresApple mic uplink: prepare before reading the input format, and never trade the mic for echo cancellationto Apple mic uplink: the microphone was never in the audio graph, so nothing ever pulled it