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);
bothengine.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:
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;
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);
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.
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.
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 (macOS client, host-independent — seen against both Linux and Windows hosts), reproduced by a second user:
Mechanism
An
AVAudioEnginedoes 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 postsAVAudioEngineConfigurationChange. It stays stopped until the app starts it again.SessionAudiohad 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:isRunning == false;engine.start()on the same object and a fresh engine resume rendering.The fix
A new
AudioDeviceWatcherplus a debounced rebuild inSessionAudiothat puts back the topology the session was started with, on whatever device is there now. Three triggers, because no single one covers the ground: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
.playbacksession 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,
wantsCombinedselects the voice-processing engine — and no Mac in the fleet can initialize VPIO at all (kAUInitializefails-10875on 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-hostdoes not build on macOS (opus/zerocopy/libc/crate::vdisplay::*/crate::encode::*unresolved — pre-existing, matches the existing notes). Soclients/apple/test-loopback.shcannot run on a Mac, and the end-to-endAudioDeviceSwitchTestsadded here always skips today. That is the whole reason the trigger wiring was split into a separately testableAudioDeviceWatcher— otherwise the part where a silent failure costs the session all of its audio would ship unverified.Tests
AudioDeviceWatcherTestspoints the real watcher at the real hardware, no session required:Plant-the-defect: neutralizing
AudioDeviceWatcher.start()fails both positive tests and neither negative one.AudioDeviceSwitchTestsdrives the realSessionAudiothrough 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
.gitea/workflows/apple.ymlonly runsswift build/swift teston macOS, so changes inside#if !os(macOS)are not covered there and were checked locallyStill 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: 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.