Apple mic uplink: the microphone was never in the audio graph, so nothing ever pulled it #94

Merged
enricobuehler merged 2 commits from fix/apple-mic-uplink into main 2026-08-07 14:33:49 +00:00
Owner

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**
enricobuehler added 1 commit 2026-08-07 14:10:00 +00:00
fix(client/apple): a session that can't voice-process still sends its microphone
ci / bun-nix (pull_request) Successful in 25s
ci / docs-site (pull_request) Successful in 1m7s
ci / web (pull_request) Successful in 1m8s
apple / swift (pull_request) Successful in 1m33s
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 1m29s
ci / rust (pull_request) Successful in 5m44s
7e34ca3d5e
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.
enricobuehler added 1 commit 2026-08-07 14:30:36 +00:00
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
d2d5058d0b
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 it 2026-08-07 14:30:56 +00:00
enricobuehler merged commit 7f822d9c41 into main 2026-08-07 14:33:48 +00:00
enricobuehler deleted branch fix/apple-mic-uplink 2026-08-07 14:33:56 +00:00
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: unom/punktfunk#94