Files
punktfunk/clients/apple/Sources/PunktfunkKit/Audio/AudioDeviceWatcher.swift
T
enricobuehler bf913c5706
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
fix(apple): switching audio device mid-stream killed the sound for the rest of the session
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.
2026-08-09 11:03:10 +02:00

130 lines
5.5 KiB
Swift

// "The audio output moved under us" — the one signal `SessionAudio` needs to survive a device
// change, and the one piece of it that can be tested without a stream.
//
// Split out of SessionAudio deliberately. 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 wiring that matters most
// (is the observer actually installed? does the identity check let the notification through?) would
// otherwise ship unverified, and a silent failure in it costs the session ALL of its audio. On its
// own this can be pointed at the real hardware from a unit test: see AudioDeviceWatcherTests.
//
// What it does NOT own: anything with session semantics. The iOS route-change steer and the
// media-services-reset re-activation stay in SessionAudio, next to the AVAudioSession they act on.
import AVFoundation
import os
#if os(macOS)
import CoreAudio
#endif
private let log = Logger(subsystem: "io.unom.punktfunk", category: "audio")
final class AudioDeviceWatcher {
/// Why the owner is being told. Only for the log line — every reason leads to the same
/// question, "is playback still on the device it should be on".
enum Reason: String {
/// An engine stopped itself because its IO hardware changed underneath it.
case engineConfiguration = "the audio hardware configuration changed"
/// The system's default output device moved (macOS).
case defaultOutputDevice = "the default output device changed"
}
/// Does this configuration change belong to an engine the session still owns? A retired engine
/// posts one last change as it is torn down, and other AVAudioEngines in the process are not
/// ours to restart.
private let isOurs: (AnyObject?) -> Bool
/// Delivered on the main queue.
private let onChange: (Reason) -> Void
private let lock = NSLock()
private var configObserver: NSObjectProtocol?
#if os(macOS)
private var defaultOutputListener: AudioObjectPropertyListenerBlock?
#endif
init(isOurs: @escaping (AnyObject?) -> Bool, onChange: @escaping (Reason) -> Void) {
self.isOurs = isOurs
self.onChange = onChange
}
deinit { stop() }
/// Idempotent.
func start() {
lock.lock()
let already = configObserver != nil
lock.unlock()
guard !already else { return }
let token = NotificationCenter.default.addObserver(
forName: .AVAudioEngineConfigurationChange, object: nil, queue: nil
) { [weak self] note in
// Posted from whatever thread the IO unit noticed on. The engine is the notification's
// object; it is only ever compared by identity, never resurrected.
let posted = note.object as AnyObject?
DispatchQueue.main.async {
guard let self, self.isOurs(posted) else { return }
self.onChange(.engineConfiguration)
}
}
lock.lock()
configObserver = token
lock.unlock()
#if os(macOS)
// The engine notification is the direct signal, but it is delivered BY an engine — useless
// in the two places it is needed most: after a rebuild that could not start (no engine left
// to notify anyone) and on an engine topology whose notification behaviour is unverified
// (the voice-processing engine, which is the DEFAULT macOS configuration and which no Mac
// here can even initialize). The HAL is told either way.
let block: AudioObjectPropertyListenerBlock = { [weak self] _, _ in
self?.onChange(.defaultOutputDevice) // on the main queue — registered against it below
}
var address = Self.defaultOutputAddress()
let status = AudioObjectAddPropertyListenerBlock(
AudioObjectID(kAudioObjectSystemObject), &address, DispatchQueue.main, block)
guard status == noErr else {
log.warning("""
could not watch the default output device (\(status)) — an output device change \
mid-stream may need a reconnect
""")
return
}
lock.lock()
defaultOutputListener = block
lock.unlock()
#endif
}
/// Idempotent, and safe from any thread. After it returns, no further `onChange` is delivered
/// except one already in flight on the main queue — which the owner's own stopped-flag catches.
func stop() {
lock.lock()
let token = configObserver
configObserver = nil
#if os(macOS)
let listener = defaultOutputListener
defaultOutputListener = nil
#endif
lock.unlock()
if let token { NotificationCenter.default.removeObserver(token) }
#if os(macOS)
guard let listener else { return }
var address = Self.defaultOutputAddress()
AudioObjectRemovePropertyListenerBlock(
AudioObjectID(kAudioObjectSystemObject), &address, DispatchQueue.main, listener)
#endif
}
#if os(macOS)
/// Freshly built per call rather than held in a mutable static: the HAL takes the address
/// `inout` and copies it, so there is nothing to share and a shared one would only be a
/// mutable global.
private static func defaultOutputAddress() -> AudioObjectPropertyAddress {
AudioObjectPropertyAddress(
mSelector: kAudioHardwarePropertyDefaultOutputDevice,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain)
}
#endif
}