Switching audio device mid-stream killed the sound for the rest of the session — AVAudioEngine stops itself, and nothing ever restarted it #141

Merged
enricobuehler merged 1 commits from worktree-audio-device-switch-silence into main 2026-08-09 09:10:53 +00:00
Owner

Field report (macOS client, host-independent — seen against both Linux and Windows hosts), reproduced by a second user:

running the mac client, when i switched audio devices, the audio didnt play anymore. as in: i started the stream with airpod pros in my ear, i removed them just a little bit - no sound on speaker, and when i put them back in - no sound in the airpods. always have to restart the stream to get that working again

Mechanism

An AVAudioEngine does not follow the audio hardware. When the output device changes under a running engine, its IO unit sees the new hardware, the engine stops itself, and it posts AVAudioEngineConfigurationChange. It stays stopped until the app starts it again.

SessionAudio had no handling of that notification anywhere. No error, no failed call, no log line — just a session rendering silence from that instant on. And putting the AirPods back in is a second stop, not a recovery, which is exactly why that half of the report reads so strangely.

Measured, not inferred — a harness on the client's own playback topology (AVAudioSourceNode → main mixer, 48 kHz stereo), moving the default output device programmatically:

  • baseline ~94 render callbacks/s → 0 the instant the device changes, isRunning == false;
  • fires again on the way back (two notifications, two stops);
  • both engine.start() on the same object and a fresh engine resume rendering.

The fix

A new AudioDeviceWatcher plus a debounced rebuild in SessionAudio that puts back the topology the session was started with, on whatever device is there now. Three triggers, because no single one covers the ground:

  1. the engine notification, every platform — the direct signal, but it can only be posted by an engine, so it cannot report a rebuild that failed to start;
  2. a CoreAudio HAL default-output-device listener (macOS) — independent of any engine and of the engine's topology (see the caveat below, this one is load-bearing);
  3. route-change + mediaServicesWereReset (iOS/tvOS), where the session rather than the device is what moves.

They collapse into one debounced rebuild (one switch produces a burst of triggers), with a floor between rebuilds so a device that renegotiates in a loop cannot spin the session, and a short retry ladder for a device caught mid-transition — a rebuild that fails leaves no engine to post the next notification, so that path must not simply give up.

The ring is deliberately carried across: the drain thread keeps decoding right through the switch, and the ring's overflow policy has already dropped whatever went stale while the engine was down.

A rebuild only happens when it concerns us. A healthy engine that followed the change on its own is left alone, and somebody changing the system default while this session is pinned to a named speaker is none of our business — rebuilding for that would cost an audible gap for nothing.

iOS/tvOS had the same latent defect. The route observer was iOS-and-mic-only (it existed for the earpiece steer), so a mic-off .playback session or an Apple TV had nothing watching at all. It is now installed for every session on both.

Two things a reviewer should know

The default macOS topology has one unverified link. With mic + echo cancellation on (both default) and no pinned UIDs, wantsCombined selects the voice-processing engine — and no Mac in the fleet can initialize VPIO at all (kAUInitialize fails -10875 on every device combination; this Mac Studio has no built-in mic). So whether a VPIO engine posts the notification the plain one demonstrably does is unknown. That is precisely why trigger (2) exists — the HAL does not care what topology the engine uses. Worth confirming on a MacBook if anyone gets the chance.

punktfunk-host does not build on macOS (opus/zerocopy/libc/crate::vdisplay::*/crate::encode::* unresolved — pre-existing, matches the existing notes). So clients/apple/test-loopback.sh cannot run on a Mac, and the end-to-end AudioDeviceSwitchTests added here always skips today. That is the whole reason the trigger wiring was split into a separately testable AudioDeviceWatcher — otherwise the part where a silent failure costs the session all of its audio would ship unverified.

Tests

AudioDeviceWatcherTests points the real watcher at the real hardware, no session required:

  • a genuine default-output-device move must reach the owner;
  • our engine's configuration change must get through;
  • a foreign engine's must not (a retired engine posts one last change as it is torn down);
  • a stopped watcher must go quiet.

Plant-the-defect: neutralizing AudioDeviceWatcher.start() fails both positive tests and neither negative one.

AudioDeviceSwitchTests drives the real SessionAudio through the out-and-back switch against the loopback host, and skips wherever that fixture cannot run. The open host's frame budget is raised so it outlives the switch.

Gates

  • 267 Swift tests green on macOS (+4 new, 6 skipped), zero warnings
  • iOS and tvOS typecheck green — .gitea/workflows/apple.yml only runs swift build/swift test on macOS, so changes inside #if !os(macOS) are not covered there and were checked locally
  • No Rust touched

Still owed

On-glass. Nobody has yet pulled AirPods out of an ear against a real stream on this build. The engine-stop and the recovery were each measured on hardware, but not the whole loop end to end.

Field report (macOS client, **host-independent** — seen against both Linux and Windows hosts), reproduced by a second user: > running the mac client, when i switched audio devices, the audio didnt play anymore. as in: i started the stream with airpod pros in my ear, i removed them just a little bit - no sound on speaker, and when i put them back in - no sound in the airpods. always have to restart the stream to get that working again ## Mechanism **An `AVAudioEngine` does not follow the audio hardware.** When the output device changes under a running engine, its IO unit sees the new hardware, **the engine stops itself**, and it posts `AVAudioEngineConfigurationChange`. It stays stopped until the app starts it again. `SessionAudio` had **no handling of that notification anywhere**. No error, no failed call, no log line — just a session rendering silence from that instant on. And putting the AirPods back in is a **second stop, not a recovery**, which is exactly why that half of the report reads so strangely. Measured, not inferred — a harness on the client's own playback topology (`AVAudioSourceNode` → main mixer, 48 kHz stereo), moving the default output device programmatically: - baseline ~94 render callbacks/s → **0** the instant the device changes, `isRunning == false`; - fires again on the way back (two notifications, two stops); - **both** `engine.start()` on the same object and a fresh engine resume rendering. ## The fix A new `AudioDeviceWatcher` plus a debounced rebuild in `SessionAudio` that puts back the topology the session was started with, on whatever device is there now. **Three triggers**, because no single one covers the ground: 1. **the engine notification**, every platform — the direct signal, but it can only be posted *by* an engine, so it cannot report a rebuild that failed to start; 2. **a CoreAudio HAL default-output-device listener (macOS)** — independent of any engine *and* of the engine's topology (see the caveat below, this one is load-bearing); 3. **route-change + `mediaServicesWereReset` (iOS/tvOS)**, where the session rather than the device is what moves. They collapse into one debounced rebuild (one switch produces a burst of triggers), with a floor between rebuilds so a device that renegotiates in a loop cannot spin the session, and a short retry ladder for a device caught mid-transition — a rebuild that fails leaves no engine to post the next notification, so that path must not simply give up. The **ring is deliberately carried across**: the drain thread keeps decoding right through the switch, and the ring's overflow policy has already dropped whatever went stale while the engine was down. A rebuild only happens when it concerns us. A healthy engine that followed the change on its own is left alone, and somebody changing the system default while this session is pinned to a named speaker is none of our business — rebuilding for that would cost an audible gap for nothing. **iOS/tvOS had the same latent defect.** The route observer was iOS-and-mic-only (it existed for the earpiece steer), so a mic-off `.playback` session or an Apple TV had nothing watching at all. It is now installed for every session on both. ## Two things a reviewer should know **The default macOS topology has one unverified link.** With mic + echo cancellation on (both default) and no pinned UIDs, `wantsCombined` selects the **voice-processing engine** — and **no Mac in the fleet can initialize VPIO at all** (`kAUInitialize` fails `-10875` on every device combination; this Mac Studio has no built-in mic). So whether a VPIO engine posts the notification the plain one demonstrably does is **unknown**. That is precisely why trigger (2) exists — the HAL does not care what topology the engine uses. Worth confirming on a MacBook if anyone gets the chance. **`punktfunk-host` does not build on macOS** (`opus`/`zerocopy`/`libc`/`crate::vdisplay::*`/`crate::encode::*` unresolved — pre-existing, matches the existing notes). So `clients/apple/test-loopback.sh` cannot run on a Mac, and the end-to-end `AudioDeviceSwitchTests` added here **always skips today**. That is the whole reason the trigger wiring was split into a separately testable `AudioDeviceWatcher` — otherwise the part where a silent failure costs the session *all* of its audio would ship unverified. ## Tests `AudioDeviceWatcherTests` points the real watcher at the real hardware, no session required: - a genuine default-output-device move must reach the owner; - our engine's configuration change must get through; - a foreign engine's must not (a retired engine posts one last change as it is torn down); - a stopped watcher must go quiet. **Plant-the-defect:** neutralizing `AudioDeviceWatcher.start()` fails both positive tests and neither negative one. `AudioDeviceSwitchTests` drives the real `SessionAudio` through the out-and-back switch against the loopback host, and skips wherever that fixture cannot run. The open host's frame budget is raised so it outlives the switch. ## Gates - 267 Swift tests green on macOS (+4 new, 6 skipped), zero warnings - iOS **and** tvOS typecheck green — `.gitea/workflows/apple.yml` only runs `swift build`/`swift test` on macOS, so changes inside `#if !os(macOS)` are not covered there and were checked locally - No Rust touched ## Still owed ⏳ **On-glass.** Nobody has yet pulled AirPods out of an ear against a real stream on this build. The engine-stop and the recovery were each measured on hardware, but not the whole loop end to end.
enricobuehler added 1 commit 2026-08-09 09:10:30 +00:00
fix(apple): switching audio device mid-stream killed the sound for the rest of the session
ci / bun-nix (pull_request) Successful in 36s
ci / web (pull_request) Successful in 1m22s
apple / swift (pull_request) Successful in 1m39s
apple / screenshots (pull_request) Skipped
ci / docs-site (pull_request) Successful in 1m40s
ci / rust-arm64 (pull_request) Successful in 2m53s
ci / rust (pull_request) Failing after 9m43s
bf913c5706
Field report, macOS client, host-independent: start a stream with AirPods in, take them
out — nothing on the speakers; put them back in — nothing in the AirPods either. Only
restarting the whole stream brought audio back.

An AVAudioEngine does not follow the audio hardware. When the output device changes under
a running engine, its IO unit sees the new hardware, THE ENGINE STOPS ITSELF, and it posts
AVAudioEngineConfigurationChange. It stays stopped until somebody starts it again, and
nothing here ever did — no error, no log line, just a session rendering silence from that
moment on. Putting the AirPods back in is a second stop, not a recovery, which is exactly
why that half of the report looked so strange.

Measured on the client's own playback topology (source node -> main mixer, 48 kHz stereo)
by moving the default output device programmatically: render callbacks go from ~94/s to
zero the instant the device changes, and both restarting the same engine and building a
fresh one resume them.

The fix watches the hardware and rebuilds the topology the session was started with, on
whatever device is there now. Three triggers, because no single one covers the ground:

  - the engine's own configuration-change notification, every platform — the direct
    signal, but it can only be posted BY an engine, so it cannot report a rebuild that
    failed to start;
  - a CoreAudio HAL default-output-device listener on macOS — independent of any engine
    and of the engine's topology. This is what makes the recovery work for the
    voice-processing engine, which is the DEFAULT macOS configuration (mic and echo
    cancellation both default on) and whose notification behaviour could not be verified:
    no Mac in the fleet can initialize VPIO at all;
  - route-change and media-services-reset on iOS/tvOS, where the session rather than the
    device is what moves. The route observer is now installed for mic-off (.playback)
    sessions and on tvOS too — it used to be iOS-and-mic-only, for the earpiece steer,
    but every platform has engines a route change can stop.

They collapse into one debounced rebuild (one switch produces a burst), with a floor
between rebuilds so a device that renegotiates in a loop cannot spin the session, and a
short retry ladder for a device caught mid-transition — a rebuild that fails leaves no
engine to post the next notification, so that path must not simply give up. The ring is
deliberately carried across: the drain thread keeps decoding through the switch, and the
ring's overflow policy has already dropped whatever went stale while the engine was down.

A rebuild is only ever done when it concerns us. A healthy engine that followed the change
on its own is left alone, and somebody changing the system default while this session is
pinned to a named speaker is none of our business — rebuilding for that would cost an
audible gap for nothing.

The trigger wiring is split into AudioDeviceWatcher for one reason: an end-to-end test of
the recovery needs a live session, which needs a host, and punktfunk-host does not build
on macOS — so the part where a silent failure costs the session ALL of its audio would
otherwise ship unverified. On its own the watcher is pointed at the real hardware from a
unit test: a real default-output-device move must reach the owner, our engine's
notification must get through, a foreign engine's must not. Neutralizing the wiring fails
both positive tests and neither negative one.

AudioDeviceSwitchTests drives the real SessionAudio through the out-and-back switch
against the loopback host; it skips wherever that fixture cannot run (which is every Mac,
today) and the open host's frame budget is raised so it outlives the switch.
enricobuehler merged commit 2c190b27b4 into main 2026-08-09 09:10:53 +00:00
enricobuehler deleted branch worktree-audio-device-switch-silence 2026-08-09 09:10: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#141