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
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.
146 lines
6.0 KiB
Swift
146 lines
6.0 KiB
Swift
// CoreAudio HAL device enumeration for the Settings pickers. Devices are persisted by
|
|
// UID (stable across reboots/replugs — AudioDeviceIDs are not); the empty UID means
|
|
// "system default", which additionally tracks default-device changes because we then
|
|
// never pin the engine to a concrete device.
|
|
|
|
#if os(macOS)
|
|
import CoreAudio
|
|
import Foundation
|
|
|
|
public struct AudioDevice: Hashable, Identifiable, Sendable {
|
|
public let uid: String
|
|
public let name: String
|
|
public var id: String { uid }
|
|
}
|
|
|
|
public enum AudioDevices {
|
|
/// Output-capable devices (speakers, headphones, multi-output…).
|
|
public static func outputs() -> [AudioDevice] {
|
|
all().filter { hasStreams($0, scope: kAudioObjectPropertyScopeOutput) }
|
|
.compactMap(describe)
|
|
}
|
|
|
|
/// Input-capable devices (microphones, interfaces…).
|
|
public static func inputs() -> [AudioDevice] {
|
|
all().filter { hasStreams($0, scope: kAudioObjectPropertyScopeInput) }
|
|
.compactMap(describe)
|
|
}
|
|
|
|
/// Resolve a persisted UID to the current AudioDeviceID — nil when unplugged.
|
|
static func deviceID(forUID uid: String) -> AudioDeviceID? {
|
|
all().first { id in
|
|
stringProperty(id, kAudioDevicePropertyDeviceUID) == uid
|
|
}
|
|
}
|
|
|
|
/// Input channel count of the mic the picker would use — the device with this UID, or the
|
|
/// system default input when `uid` is empty. 0 when it can't be resolved. Drives the
|
|
/// "Microphone channel" picker (only shown for multi-channel interfaces).
|
|
public static func inputChannelCount(forUID uid: String) -> Int {
|
|
let id = uid.isEmpty ? defaultInputDevice() : deviceID(forUID: uid)
|
|
guard let id else { return 0 }
|
|
return channelCount(id, scope: kAudioObjectPropertyScopeInput)
|
|
}
|
|
|
|
private static func defaultInputDevice() -> AudioDeviceID? {
|
|
systemDevice(kAudioHardwarePropertyDefaultInputDevice)
|
|
}
|
|
|
|
/// The device the system is currently playing to — what an engine with no pinned speaker UID
|
|
/// follows, and so what `SessionAudio` compares its live output device against when the
|
|
/// default moves (AirPods in or out, a headset unplugged).
|
|
static func defaultOutputDevice() -> AudioDeviceID? {
|
|
systemDevice(kAudioHardwarePropertyDefaultOutputDevice)
|
|
}
|
|
|
|
private static func systemDevice(
|
|
_ selector: AudioObjectPropertySelector
|
|
) -> AudioDeviceID? {
|
|
var address = AudioObjectPropertyAddress(
|
|
mSelector: selector,
|
|
mScope: kAudioObjectPropertyScopeGlobal,
|
|
mElement: kAudioObjectPropertyElementMain)
|
|
var dev = AudioDeviceID(0)
|
|
var size = UInt32(MemoryLayout<AudioDeviceID>.size)
|
|
guard AudioObjectGetPropertyData(
|
|
AudioObjectID(kAudioObjectSystemObject), &address, 0, nil, &size, &dev) == noErr,
|
|
dev != 0
|
|
else { return nil }
|
|
return dev
|
|
}
|
|
|
|
/// Sum of channels across the device's streams in `scope` (its total input/output channels).
|
|
private static func channelCount(
|
|
_ id: AudioDeviceID, scope: AudioObjectPropertyScope
|
|
) -> Int {
|
|
var address = AudioObjectPropertyAddress(
|
|
mSelector: kAudioDevicePropertyStreamConfiguration,
|
|
mScope: scope,
|
|
mElement: kAudioObjectPropertyElementMain)
|
|
var size: UInt32 = 0
|
|
guard AudioObjectGetPropertyDataSize(id, &address, 0, nil, &size) == noErr, size > 0
|
|
else { return 0 }
|
|
let raw = UnsafeMutableRawPointer.allocate(
|
|
byteCount: Int(size), alignment: MemoryLayout<AudioBufferList>.alignment)
|
|
defer { raw.deallocate() }
|
|
guard AudioObjectGetPropertyData(id, &address, 0, nil, &size, raw) == noErr else { return 0 }
|
|
let abl = UnsafeMutableAudioBufferListPointer(
|
|
raw.assumingMemoryBound(to: AudioBufferList.self))
|
|
return abl.reduce(0) { $0 + Int($1.mNumberChannels) }
|
|
}
|
|
|
|
private static func all() -> [AudioDeviceID] {
|
|
var address = AudioObjectPropertyAddress(
|
|
mSelector: kAudioHardwarePropertyDevices,
|
|
mScope: kAudioObjectPropertyScopeGlobal,
|
|
mElement: kAudioObjectPropertyElementMain)
|
|
var size: UInt32 = 0
|
|
guard AudioObjectGetPropertyDataSize(
|
|
AudioObjectID(kAudioObjectSystemObject), &address, 0, nil, &size) == noErr,
|
|
size > 0
|
|
else { return [] }
|
|
var ids = [AudioDeviceID](
|
|
repeating: 0, count: Int(size) / MemoryLayout<AudioDeviceID>.size)
|
|
guard AudioObjectGetPropertyData(
|
|
AudioObjectID(kAudioObjectSystemObject), &address, 0, nil, &size, &ids) == noErr
|
|
else { return [] }
|
|
return ids
|
|
}
|
|
|
|
private static func hasStreams(
|
|
_ id: AudioDeviceID, scope: AudioObjectPropertyScope
|
|
) -> Bool {
|
|
var address = AudioObjectPropertyAddress(
|
|
mSelector: kAudioDevicePropertyStreams,
|
|
mScope: scope,
|
|
mElement: kAudioObjectPropertyElementMain)
|
|
var size: UInt32 = 0
|
|
return AudioObjectGetPropertyDataSize(id, &address, 0, nil, &size) == noErr && size > 0
|
|
}
|
|
|
|
/// UID + human name for a live AudioDeviceID (nil if either property is unreadable).
|
|
static func describe(_ id: AudioDeviceID) -> AudioDevice? {
|
|
guard let uid = stringProperty(id, kAudioDevicePropertyDeviceUID),
|
|
let name = stringProperty(id, kAudioObjectPropertyName)
|
|
else { return nil }
|
|
return AudioDevice(uid: uid, name: name)
|
|
}
|
|
|
|
private static func stringProperty(
|
|
_ id: AudioDeviceID, _ selector: AudioObjectPropertySelector
|
|
) -> String? {
|
|
var address = AudioObjectPropertyAddress(
|
|
mSelector: selector,
|
|
mScope: kAudioObjectPropertyScopeGlobal,
|
|
mElement: kAudioObjectPropertyElementMain)
|
|
var ref: CFString?
|
|
var size = UInt32(MemoryLayout<CFString?>.size)
|
|
let status = withUnsafeMutablePointer(to: &ref) { p in
|
|
AudioObjectGetPropertyData(id, &address, 0, nil, &size, p)
|
|
}
|
|
guard status == noErr, let ref else { return nil }
|
|
return ref as String
|
|
}
|
|
}
|
|
#endif
|