// "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 }