#if os(macOS) import AudioToolbox import AVFoundation import CoreAudio import Foundation import os // Which devices a Mac call's audio runs on, and how the call is put on // them. // // Calls 0.3.0 to 0.3.6 chose devices through LiveKit's engine module // (`inputDevice = …`). Paul's Scarlett 18i8 was not picked up at launch // and would not stay chosen (2026-09-25). Read from the module's source // (webrtc-sdk m150_release, audio_engine_device.mm and // RTCAudioDeviceModule.mm) and measured on a Mac, that route has four // faults, any one of which loses the Scarlett: // // 1. The module lists only "default" until the first peer connection // initialises it, and says nothing when it does (no device-update // callback). The app attached to it at launch, found its remembered // Scarlett missing from a one-entry list, said "Scarlett 18i8 USB // isn't connected; using …" and put the call on the default. // 2. Its list drops any device whose input streams have an unknown // terminal type while it also has outputs, and every virtual or // aggregate device, so an interface can be missing from it for good. // 3. It wires the microphone in with the *old* device's format and only // then moves the engine to the chosen device, so a device at another // sample rate starts silent or not at all — and a start that fails // releases the engine it had, leaving the call with no audio. // 4. `trySetInputDevice` answers YES on failure and NO on success. // // So the module is left on its default and never asked to choose. The // app lists devices from CoreAudio itself (ready at launch), and puts the // engine on the chosen device from the module's own "will enable" hook, // before the module reads any format: `AudioEnginePin`. On a Mac the // engine's input and output are one audio unit, so a microphone and a // speaker that are different devices are joined in a private aggregate // device, as macOS itself does for the two system defaults. // The audio log (AudioLog) is in AudioDiagnostics.swift. /// One CoreAudio device, as the routing rules need it. struct SystemAudioDevice: Equatable, Sendable { var uid: String var name: String var inputChannels: Int var outputChannels: Int var sampleRate: Double = 0 var transport: String = "" var describe: String { "\(name) [\(uid)] in=\(inputChannels) out=\(outputChannels) \(Int(sampleRate)) Hz \(transport)" } } /// What is connected, and which devices macOS has as its defaults. struct AudioInventory: Equatable, Sendable { var devices: [SystemAudioDevice] = [] var defaultInput: String? var defaultOutput: String? var inputs: [SystemAudioDevice] { devices.filter { $0.inputChannels > 0 } } var outputs: [SystemAudioDevice] { devices.filter { $0.outputChannels > 0 } } func list(_ input: Bool) -> [SystemAudioDevice] { input ? inputs : outputs } func systemDefault(_ input: Bool) -> SystemAudioDevice? { let uid = input ? defaultInput : defaultOutput return devices.first { $0.uid == uid } } } /// CoreAudio, read. Property queries only: nothing here opens a device /// or needs microphone permission. enum CoreAudioDevices { /// Private aggregates this app makes (AudioEnginePin), and the one /// AVAudioEngine makes of the two system defaults. Neither is a device /// anyone chose. static let ownAggregatePrefix = "net.amadan.oficina.calls.pair." static func isInternal(uid: String) -> Bool { uid.hasPrefix(ownAggregatePrefix) || uid.hasPrefix("CADefaultDeviceAggregate") } static func inventory() -> AudioInventory { var list: [SystemAudioDevice] = [] for id in allDeviceIDs() { guard let uid = CoreAudioInput.string(id, kAudioDevicePropertyDeviceUID), !isInternal(uid: uid), !isHidden(id) else { continue } let device = SystemAudioDevice( uid: uid, name: CoreAudioInput.string(id, kAudioObjectPropertyName) ?? uid, inputChannels: channels(id, input: true), outputChannels: channels(id, input: false), sampleRate: sampleRate(id), transport: transport(id)) if device.inputChannels > 0 || device.outputChannels > 0 { list.append(device) } } return AudioInventory( devices: list, defaultInput: defaultDevice(input: true).flatMap { CoreAudioInput.string($0, kAudioDevicePropertyDeviceUID) }, defaultOutput: defaultDevice(input: false).flatMap { CoreAudioInput.string($0, kAudioDevicePropertyDeviceUID) }) } static func allDeviceIDs() -> [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.size) guard AudioObjectGetPropertyData(AudioObjectID(kAudioObjectSystemObject), &address, 0, nil, &size, &ids) == noErr else { return [] } return ids } static func defaultDevice(input: Bool) -> AudioDeviceID? { input ? CoreAudioInput.defaultInput() : defaultOutput() } static func defaultOutput() -> AudioDeviceID? { var address = AudioObjectPropertyAddress( mSelector: kAudioHardwarePropertyDefaultOutputDevice, mScope: kAudioObjectPropertyScopeGlobal, mElement: kAudioObjectPropertyElementMain) var device = AudioDeviceID(kAudioObjectUnknown) var size = UInt32(MemoryLayout.size) let status = AudioObjectGetPropertyData( AudioObjectID(kAudioObjectSystemObject), &address, 0, nil, &size, &device) return status == noErr && device != kAudioObjectUnknown ? device : nil } static func channels(_ device: AudioDeviceID, input: Bool) -> Int { var address = AudioObjectPropertyAddress( mSelector: kAudioDevicePropertyStreamConfiguration, mScope: input ? kAudioObjectPropertyScopeInput : kAudioObjectPropertyScopeOutput, mElement: kAudioObjectPropertyElementMain) var size: UInt32 = 0 guard AudioObjectGetPropertyDataSize(device, &address, 0, nil, &size) == noErr, size > 0 else { return 0 } let raw = UnsafeMutableRawPointer.allocate( byteCount: Int(size), alignment: MemoryLayout.alignment) defer { raw.deallocate() } let list = raw.bindMemory(to: AudioBufferList.self, capacity: 1) guard AudioObjectGetPropertyData(device, &address, 0, nil, &size, list) == noErr else { return 0 } return InputChannelMap.count(UnsafeMutableAudioBufferListPointer(list)) } static func uint32(_ device: AudioDeviceID, _ selector: AudioObjectPropertySelector) -> UInt32? { var address = AudioObjectPropertyAddress( mSelector: selector, mScope: kAudioObjectPropertyScopeGlobal, mElement: kAudioObjectPropertyElementMain) guard AudioObjectHasProperty(device, &address) else { return nil } var value: UInt32 = 0 var size = UInt32(MemoryLayout.size) return AudioObjectGetPropertyData(device, &address, 0, nil, &size, &value) == noErr ? value : nil } static func isHidden(_ device: AudioDeviceID) -> Bool { uint32(device, kAudioDevicePropertyIsHidden) == 1 } static func isAlive(_ device: AudioDeviceID) -> Bool { uint32(device, kAudioDevicePropertyDeviceIsAlive) != 0 } static func sampleRate(_ device: AudioDeviceID) -> Double { var address = AudioObjectPropertyAddress( mSelector: kAudioDevicePropertyNominalSampleRate, mScope: kAudioObjectPropertyScopeGlobal, mElement: kAudioObjectPropertyElementMain) var rate: Float64 = 0 var size = UInt32(MemoryLayout.size) return AudioObjectGetPropertyData(device, &address, 0, nil, &size, &rate) == noErr ? rate : 0 } static func transport(_ device: AudioDeviceID) -> String { guard let type = uint32(device, kAudioDevicePropertyTransportType) else { return "" } switch type { case kAudioDeviceTransportTypeBuiltIn: return "built-in" case kAudioDeviceTransportTypeUSB: return "usb" case kAudioDeviceTransportTypeBluetooth, kAudioDeviceTransportTypeBluetoothLE: return "bluetooth" case kAudioDeviceTransportTypeVirtual: return "virtual" case kAudioDeviceTransportTypeAggregate: return "aggregate" case kAudioDeviceTransportTypeThunderbolt: return "thunderbolt" case kAudioDeviceTransportTypeHDMI, kAudioDeviceTransportTypeDisplayPort: return "display" case kAudioDeviceTransportTypeAirPlay: return "airplay" case kAudioDeviceTransportTypeContinuityCaptureWired, kAudioDeviceTransportTypeContinuityCaptureWireless: return "continuity" default: return String(format: "0x%08x", type) } } static func uid(_ device: AudioDeviceID) -> String? { CoreAudioInput.string(device, kAudioDevicePropertyDeviceUID) } /// The device an audio unit is on. static func current(of unit: AudioUnit) -> AudioDeviceID? { CoreAudioInput.device(of: unit) } static func setCurrent(_ device: AudioDeviceID, on unit: AudioUnit) -> OSStatus { var device = device return AudioUnitSetProperty( unit, kAudioOutputUnitProperty_CurrentDevice, kAudioUnitScope_Global, 0, &device, UInt32(MemoryLayout.size)) } } /// A remembered choice matched against what is connected, and what the /// call does about it. Pure: the tests hold every case of it. struct RouteResolution: Equatable, Sendable { /// The picker's selection: "default", or the chosen device's UID. var current: String /// The device the choice asks the engine for; nil follows the system /// default. var explicit: String? /// Said when the choice cannot be honoured. var note: String? /// A preference to store in place of the old one: the device came back /// under a new UID (a USB interface on another port) and was found by /// its name. var remember: AudioPreference? /// Why, for the log. var reason: String } enum AudioRouteRules { static let defaultID = "default" /// - Parameters: /// - failed: devices the engine could not start on, by UID, with why. static func resolve( _ preference: AudioPreference?, _ inventory: AudioInventory, input: Bool, failed: [String: String] = [:] ) -> RouteResolution { guard let preference, !preference.id.isEmpty, preference.id != defaultID else { return RouteResolution( current: defaultID, explicit: nil, note: nil, reason: preference == nil ? "nothing chosen: system default" : "system default chosen") } let list = inventory.list(input) var device = list.first { $0.uid == preference.id } var remember: AudioPreference? var how = "by UID" if device == nil, !preference.name.isEmpty { let named = list.filter { $0.name == preference.name } if named.count == 1 { device = named[0] remember = AudioPreference(id: named[0].uid, name: named[0].name) how = "by name (UID was \(preference.id))" } } let what = preference.name.isEmpty ? "The \(input ? "microphone" : "speaker") you chose" : preference.name guard let device else { return RouteResolution( current: defaultID, explicit: nil, note: note("\(what) isn’t connected", fallback: inventory.systemDefault(input), missing: what, input: input), reason: "remembered \(preference.id) (\(preference.name)) is not in CoreAudio's \(input ? "input" : "output") list") } if let why = failed[device.uid] { return RouteResolution( current: defaultID, explicit: nil, note: note("Calls couldn’t open \(device.name)", fallback: inventory.systemDefault(input), missing: device.name, input: input), remember: remember, reason: "\(device.uid) failed to start: \(why)") } return RouteResolution( current: device.uid, explicit: device.uid, note: nil, remember: remember, reason: "remembered device connected, matched \(how)") } /// "Scarlett 18i8 USB isn’t connected; using MacBook Pro Microphone." /// Never names the missing device as the one in use: when the default /// has the same name (a second unit of the same model), it is called /// the Mac's default instead. static func note(_ head: String, fallback: SystemAudioDevice?, missing: String, input: Bool) -> String { let generic = "the Mac’s default \(input ? "microphone" : "speaker")" guard let fallback, !fallback.name.isEmpty, fallback.name != missing else { return "\(head); using \(generic)." } return "\(head); using \(fallback.name)." } } /// When a chosen microphone's change of rate rebuilds the engine. Pure; /// the tests hold it. enum RateRule { static let minimumGap: Double = 3 /// - Parameters: /// - engineRate: the input rate the running engine was wired at /// (nil: it is not capturing, and the next start reads the rate /// afresh). /// - pair: the engine is on one of our aggregates, whose clock is /// the speaker's, so its input rate says nothing about the /// microphone's: any change of the microphone's rate rebuilds it. static func rebuild( deviceRate: Double, engineRate: Double?, recording: Bool, pair: Bool, lastRebuild: Double, now: Double ) -> Bool { guard recording, let engineRate, deviceRate > 0, now - lastRebuild >= minimumGap else { return false } return pair || abs(engineRate - deviceRate) >= 1 } } /// Where the engine's one audio unit should be: left to AVAudioEngine's /// own default, on one device, or on a pair joined in an aggregate. enum EngineTarget: Equatable, Sendable, CustomStringConvertible { case systemDefault case device(String) case pair(input: String, output: String) /// Planned as for a call that both plays and records, even while the /// engine only plays: the module makes its engine for playout first and /// adds the microphone to that same engine later, and a unit moved to /// another device in between would keep the format it was wired with. /// /// - Parameters: /// - input/output: the explicit choices (nil follows the default). static func plan(input: String?, output: String?, defaultInput: String?, defaultOutput: String?) -> EngineTarget { guard input != nil || output != nil else { return .systemDefault } switch (input ?? defaultInput, output ?? defaultOutput) { case (let i?, let o?): return i == o ? .device(i) : .pair(input: i, output: o) case (let i?, nil): return .device(i) case (nil, let o?): return .device(o) case (nil, nil): return .systemDefault } } var description: String { switch self { case .systemDefault: return "system default" case .device(let uid): return uid case .pair(let i, let o): return "\(i) + \(o)" } } } /// The chosen devices, handed from the main actor to the module's /// thread, and applied to each engine the module makes before it reads a /// format from it. Also where the engine's actual device is read back. final class AudioEnginePin: @unchecked Sendable { private let lock = NSLock() private var input: String? private var output: String? private var aggregate: (key: EngineTarget, id: AudioDeviceID, uid: String, offset: Int, outputOffset: Int)? /// What the last engine was put on, and whether that was an explicit /// choice. Read on the main actor to tell whether a rebuild is due. private var applied: EngineTarget? /// Returns true when the choice changed. func set(input: String?, output: String?) -> Bool { lock.lock() defer { lock.unlock() } guard input != self.input || output != self.output else { return false } self.input = input self.output = output return true } var choices: (input: String?, output: String?) { lock.lock() defer { lock.unlock() } return (input, output) } var lastApplied: EngineTarget? { lock.lock() defer { lock.unlock() } return applied } /// What putting an engine on its devices came to. enum Outcome: Equatable { case applied(EngineTarget) /// The devices could not be set; the engine is on the default. case failed(EngineTarget, String) } /// The target for the choices as they stand, against the defaults as /// they stand. Devices that have gone are left out, so a microphone /// unplugged a moment ago is not asked for. func target() -> EngineTarget { let (input, output) = choices return EngineTarget.plan( input: input.flatMap(Self.alive), output: output.flatMap(Self.alive), defaultInput: CoreAudioDevices.defaultDevice(input: true).flatMap(CoreAudioDevices.uid), defaultOutput: CoreAudioDevices.defaultDevice(input: false).flatMap(CoreAudioDevices.uid)) } /// Called from the module's "will enable" hook, before it reads the /// engine's formats and wires anything. func apply(to engine: AVAudioEngine, recording: Bool) -> Outcome { let target = target() lock.lock() applied = target lock.unlock() guard target != .systemDefault else { return .applied(target) } func fail(_ why: String) -> Outcome { lock.lock() applied = .systemDefault lock.unlock() return .failed(target, why) } guard let device = resolve(target) else { return fail("no device could be made for it") } // Input and output are one unit on a Mac; asking for `inputNode` // makes an input, so it is only asked for when recording. guard let unit = recording ? engine.inputNode.audioUnit : engine.outputNode.audioUnit else { return fail("the engine has no audio unit") } let status = CoreAudioDevices.setCurrent(device, on: unit) return status == noErr ? .applied(target) : fail("setting the device failed, OSStatus \(status)") } /// The input device behind a device an engine is on, and where its /// channels start on it (0: our aggregates put the microphone first). /// Nil for a device that is not one of ours. func inputLayout(of device: AudioDeviceID) -> (uid: String, offset: Int)? { lock.lock() defer { lock.unlock() } guard let aggregate, aggregate.id == device, case .pair(let input, _) = aggregate.key else { return nil } return (input, aggregate.offset) } /// The microphone the engine is actually on, read back from its unit: /// the device itself, the microphone half of one of our aggregates, or /// the system default input under macOS's own aggregate. func actualInput(of unit: AudioUnit) -> String? { guard let device = CoreAudioDevices.current(of: unit), let uid = CoreAudioDevices.uid(device) else { return nil } if let layout = inputLayout(of: device) { return layout.uid } if CoreAudioDevices.isInternal(uid: uid) { return CoreAudioDevices.defaultDevice(input: true).flatMap(CoreAudioDevices.uid) } return uid } private static func alive(_ uid: String) -> String? { guard let id = CoreAudioInput.device(uid: uid), CoreAudioDevices.isAlive(id) else { return nil } return uid } private func resolve(_ target: EngineTarget) -> AudioDeviceID? { switch target { case .systemDefault: return nil case .device(let uid): return CoreAudioInput.device(uid: uid) case .pair(let input, let output): lock.lock() if let aggregate, aggregate.key == target, CoreAudioDevices.isAlive(aggregate.id) { lock.unlock() return aggregate.id } let old = aggregate aggregate = nil lock.unlock() if let old { AudioHardwareDestroyAggregateDevice(old.id) } guard let made = Self.makePair(input: input, output: output) else { return nil } lock.lock() aggregate = (target, made.id, made.uid, made.offset, made.outputOffset) lock.unlock() return made.id } } /// A private aggregate of a microphone and a speaker. The microphone /// goes first: AVAudioEngine refuses an aggregate whose first device /// has no input ("IsFormatSampleRateAndChannelCountValid(outputHWFormat)", /// measured on macOS 26 with the MacBook's own two), and its inputs then /// start at channel 1. The speaker's clock leads, with the microphone /// drift-corrected to it. A microphone with outputs of its own (an /// interface) puts those ahead of the speaker's, so the engine's output /// is mapped past them (`outputOffset`, applied when it starts). private static func makePair(input: String, output: String) -> (id: AudioDeviceID, uid: String, offset: Int, outputOffset: Int)? { let uid = CoreAudioDevices.ownAggregatePrefix + UUID().uuidString let description: [String: Any] = [ kAudioAggregateDeviceNameKey: "Calls", kAudioAggregateDeviceUIDKey: uid, kAudioAggregateDeviceIsPrivateKey: 1, kAudioAggregateDeviceMainSubDeviceKey: output, kAudioAggregateDeviceSubDeviceListKey: [ [kAudioSubDeviceUIDKey: input, kAudioSubDeviceDriftCompensationKey: 1], [kAudioSubDeviceUIDKey: output], ], ] var id = AudioDeviceID(kAudioObjectUnknown) let status = AudioHardwareCreateAggregateDevice(description as CFDictionary, &id) guard status == noErr, id != kAudioObjectUnknown else { AudioLog.error("aggregate of \(input) + \(output) refused, OSStatus \(status)") return nil } let outputOffset = CoreAudioInput.device(uid: input).map { CoreAudioDevices.channels($0, input: false) } ?? 0 AudioLog.info( "aggregate \(uid): in \(input), out \(output) from channel \(outputOffset + 1); in=\(CoreAudioDevices.channels(id, input: true)) out=\(CoreAudioDevices.channels(id, input: false))") return (id, uid, 0, outputOffset) } /// Sends the engine's output to the speaker's channels of one of our /// aggregates when the microphone's own outputs come first. Called as /// the engine starts, once its output format is set. func mapOutput(on unit: AudioUnit) { guard let device = CoreAudioDevices.current(of: unit) else { return } lock.lock() let offset = aggregate.flatMap { $0.id == device ? $0.outputOffset : nil } ?? 0 lock.unlock() guard offset > 0 else { return } var format = AudioStreamBasicDescription() var size = UInt32(MemoryLayout.size) guard AudioUnitGetProperty(unit, kAudioUnitProperty_StreamFormat, kAudioUnitScope_Input, 0, &format, &size) == noErr else { return } let total = CoreAudioDevices.channels(device, input: false) let client = Int(format.mChannelsPerFrame) var map = [Int32](repeating: -1, count: total) for k in 0...size * map.count)) AudioLog.info("output channel map past \(offset) microphone outputs: OSStatus \(status)") } /// The UID of the aggregate this pin has made, if it has one now. var aggregateUID: String? { lock.lock() defer { lock.unlock() } return aggregate?.uid } /// The aggregate goes with the call's audio. func close() { lock.lock() let old = aggregate aggregate = nil lock.unlock() if let old { AudioHardwareDestroyAggregateDevice(old.id) } } deinit { close() } } #endif