import AVFoundation import Foundation import os #if os(macOS) import CoreAudio #endif import LiveKitWebRTC /// One audio device the person can be on. public struct AudioDevice: Identifiable, Hashable, Sendable { public let id: String public let name: String public let selectable: Bool public init(id: String, name: String, selectable: Bool) { self.id = id self.name = name self.selectable = selectable } } /// The audio devices a call is on, and the ability to change them. /// /// On iPhone the microphone is a route on the audio session. On Mac it is /// a CoreAudio device the call's audio engine is put on (AudioDevices.swift /// says why the engine module is no longer asked to choose). Until a call /// has made its audio, the Mac lists are empty. /// /// On Mac the person's choices are remembered — across calls and across /// launches — and the rules are: /// /// - **System default** is an entry of its own, where someone who has /// never chosen starts, and a choice like any other: picked, it is what /// is remembered. It follows macOS — a new default device, or a device /// changing its sample rate, is the module's to follow, and it does (it /// listens for both and rebuilds its engine). /// - A chosen device is kept by its CoreAudio UID, with its name beside it /// for while it is missing, and found by that name when it comes back /// under another UID (a USB interface moved to another port). A change of /// system default does not move it. /// - A chosen device that is not connected — at join, or disconnecting /// mid-call — puts the call on the system default for now, and /// `routeNote` says so ("Scarlett 18i8 isn't connected; using MacBook Pro /// Microphone"). "Connected" is CoreAudio's device list, which is ready at /// launch; the engine module's list, which is not, is never asked. The /// choice is kept, and when the device comes back the call goes back to /// it on its own. /// - A device the engine cannot start on is said as such ("Calls couldn't /// open …"), the call goes back to the default, and choosing it again, or /// the device list changing, tries it again. /// - Nothing here changes the Mac's Sound settings. /// /// Calls 0.3.1 forgot the choices at the end of every call, so a Scarlett /// chosen yesterday was the MacBook's microphone today (Paul, 2026-09-24). @MainActor public final class AudioRoutes: ObservableObject { @Published public private(set) var inputs: [AudioDevice] = [] @Published public private(set) var currentInput: String? @Published public private(set) var outputName = "" /// Speakers, where they can be chosen (Mac). Empty on iPhone, where the /// output follows the route and is only named. @Published public private(set) var outputs: [AudioDevice] = [] @Published public private(set) var currentOutput: String? /// True where inputs can actually be chosen. The UI shows a picker when /// it is set and a plain line of text when it is not, rather than a /// disabled control that looks like something is broken. public let selectable = true #if os(macOS) /// Said while a remembered device is missing, or could not be opened, /// and the call is on the system default instead. Clears itself when /// the device is back. @Published public private(set) var routeNote = "" /// The microphone's input channels, labelled, when it has more than /// one (an audio interface); empty for an ordinary microphone. See /// InputChannel.swift for why this is a choice at all. @Published public private(set) var inputChannels: [String] = [] /// The input the call sends from, 0-based. Remembered per device. @Published public private(set) var inputChannel = 0 /// The microphone and, on an interface, its input: "Scarlett 18i8 USB, /// input 1". Empty before a call has audio. @Published public private(set) var inputSource = "" private var inputDevice: InputDeviceChannels? private var control: AudioEngineControl? let defaults: UserDefaults /// Devices the engine could not start on, by UID, with why. Cleared /// when the person chooses the device again or what is connected /// changes. private(set) var failed: [String: String] = [:] /// The microphone the engine was last seen running on, read back from /// its unit — the truth when it is known. private var engineInput: String? private var lastInventory: AudioInventory? private var lastDecision = "" private var listener: AudioObjectPropertyListenerBlock? /// The chosen microphone's nominal rate, listened to: a Bluetooth /// headset moves to its hands-free rate (16 or 24 kHz) the moment its /// microphone opens, under an engine wired at the old rate. private var rateWatch: (device: AudioObjectID, uid: String, block: AudioObjectPropertyListenerBlock)? private var lastRateRebuild = -Double.infinity #endif #if os(macOS) public init(defaults: UserDefaults = .standard) { self.defaults = defaults refresh() } #else public init() { refresh() } #endif public func refresh() { #if os(iOS) let session = AVAudioSession.sharedInstance() inputs = (session.availableInputs ?? []).map { AudioDevice(id: $0.uid, name: $0.portName, selectable: true) } currentInput = session.currentRoute.inputs.first?.uid outputName = session.currentRoute.outputs.first?.portName ?? "" #else guard let control else { update(\.inputs, []) update(\.outputs, []) update(\.currentInput, nil) update(\.currentOutput, nil) update(\.outputName, "") update(\.routeNote, "") return } let inventory = control.inventory() if inventory != lastInventory { // Something was plugged in or taken out: a device that could not // be opened gets another try. if let last = lastInventory, Set(last.devices.map(\.uid)) != Set(inventory.devices.map(\.uid)) { failed = [:] } lastInventory = inventory AudioLog.info( "CoreAudio devices: \(inventory.devices.map(\.describe).joined(separator: "; ")); default in \(inventory.defaultInput ?? "none"), out \(inventory.defaultOutput ?? "none")") control.logModuleLists() } let input = AudioRouteRules.resolve( AudioPreference.load(input: true, defaults), inventory, input: true, failed: failed) let output = AudioRouteRules.resolve( AudioPreference.load(input: false, defaults), inventory, input: false, failed: failed) input.remember?.save(input: true, defaults) output.remember?.save(input: false, defaults) let decision = "input \(input.current): \(input.reason); output \(output.current): \(output.reason)" if decision != lastDecision { lastDecision = decision AudioLog.info("routes: \(decision)") } if control.pin(input.explicit, output.explicit) { AudioLog.info( "routes: engine asked for input \(input.explicit ?? "default"), output \(output.explicit ?? "default"); rebuilding a running engine") control.rebuild() } update(\.routeNote, [input.note, output.note].compactMap { $0 }.joined(separator: " ")) update(\.inputs, AudioRoutes.list(inventory, input: true)) update(\.outputs, AudioRoutes.list(inventory, input: false)) update(\.currentInput, input.current) update(\.currentOutput, output.current) update(\.outputName, outputs.first { $0.id == currentOutput }?.name ?? "") readChannels(input.explicit ?? inventory.defaultInput, explicit: input.explicit != nil) watchRate(of: input.explicit) #endif } #if os(macOS) /// The panel refreshes while it is open; publishing only what changed /// keeps an open menu from being rebuilt under the pointer. private func update( _ key: ReferenceWritableKeyPath, _ value: Value ) { if self[keyPath: key] != value { self[keyPath: key] = value } } /// "System default (MacBook Pro Microphone)" first, then every device /// CoreAudio has for that side. nonisolated static func list(_ inventory: AudioInventory, input: Bool) -> [AudioDevice] { let head = inventory.systemDefault(input).map { AudioDevice(id: AudioRouteRules.defaultID, name: "System default (\($0.name))", selectable: true) } ?? AudioDevice(id: AudioRouteRules.defaultID, name: "System default", selectable: true) return [head] + inventory.list(input).map { AudioDevice(id: $0.uid, name: $0.name, selectable: true) } } func attach( _ module: RTCAudioDeviceModule, state: ModuleState, pin: AudioEnginePin, inputRate: @escaping () -> Double? ) { attach(AudioEngineControl(module, state: state, pin: pin, inputRate: inputRate)) listenForDevices() } func attach(_ control: AudioEngineControl) { self.control = control failed = [:] engineInput = nil lastInventory = nil lastDecision = "" refresh() } /// CoreAudio's own device list and defaults, listened to directly: the /// moment a remembered device comes back is the moment the call should /// move to it. No poller. private func listenForDevices() { guard listener == nil else { return } let block: AudioObjectPropertyListenerBlock = { [weak self] _, _ in DispatchQueue.main.async { MainActor.assumeIsolated { self?.refresh() } } } var ok = false for selector in [ kAudioHardwarePropertyDevices, kAudioHardwarePropertyDefaultInputDevice, kAudioHardwarePropertyDefaultOutputDevice, ] { var address = AudioObjectPropertyAddress( mSelector: selector, mScope: kAudioObjectPropertyScopeGlobal, mElement: kAudioObjectPropertyElementMain) ok = AudioObjectAddPropertyListenerBlock( AudioObjectID(kAudioObjectSystemObject), &address, DispatchQueue.main, block) == noErr || ok } if ok { listener = block } } /// The engine started on a unit whose microphone is `uid`: what the /// readout and the note name from now on. func engineStarted(input uid: String?) { guard uid != engineInput else { return } engineInput = uid AudioLog.info("engine running with input \(uid ?? "unknown")") let chosen = currentInput != AudioRouteRules.defaultID readChannels(chosen ? currentInput : lastInventory?.defaultInput, explicit: chosen) } /// Listens to the chosen microphone's sample rate (nil stops). With /// the system default the module follows the device itself, as it did /// in 0.3.3, and nothing is added. private func watchRate(of uid: String?) { guard uid != rateWatch?.uid else { return } if let old = rateWatch { var address = AudioRoutes.rateAddress AudioObjectRemovePropertyListenerBlock(old.device, &address, DispatchQueue.main, old.block) rateWatch = nil } guard let uid, let device = CoreAudioInput.device(uid: uid) else { return } let block: AudioObjectPropertyListenerBlock = { [weak self] _, _ in DispatchQueue.main.async { MainActor.assumeIsolated { self?.rateChanged(uid) } } } var address = AudioRoutes.rateAddress if AudioObjectAddPropertyListenerBlock(device, &address, DispatchQueue.main, block) == noErr { rateWatch = (device, uid, block) } } private static var rateAddress: AudioObjectPropertyAddress { AudioObjectPropertyAddress( mSelector: kAudioDevicePropertyNominalSampleRate, mScope: kAudioObjectPropertyScopeGlobal, mElement: kAudioObjectPropertyElementMain) } /// The chosen microphone changed rate. A running engine wired at /// another rate is rebuilt, so the microphone is wired in again at the /// new one (at most once every three seconds). private func rateChanged(_ uid: String) { guard let control, let device = CoreAudioInput.device(uid: uid) else { return } let rate = CoreAudioDevices.sampleRate(device) let now = CaptureDenoiser.now() let rebuild = RateRule.rebuild( deviceRate: rate, engineRate: control.inputRate(), recording: control.isRecording(), pair: control.pinned().isPair, lastRebuild: lastRateRebuild, now: now) AudioLog.info( "\(uid) now runs at \(Int(rate)) Hz; engine input \(control.inputRate().map { "\(Int($0)) Hz" } ?? "not wired"): \(rebuild ? "rebuilding the engine" : "left as it is")") guard rebuild else { return } lastRateRebuild = now control.rebuild() } /// The engine did not start on the devices it was put on. They are /// set aside, the call goes back to the default, and the panel says so. func engineFailed(_ target: EngineTarget, reason: String) { let uids: [String] switch target { case .systemDefault: return case .device(let uid): uids = [uid] case .pair(let input, let output): uids = [input, output] } let (input, output) = control?.choices() ?? (nil, nil) for uid in uids where uid == input || uid == output { failed[uid] = reason } AudioLog.error( "engine failed on \(target.description): \(reason); falling back to the system default") refresh() } /// Chooses the microphone on Mac, mid-call if one is up, and remembers /// it. The engine is rebuilt on the device underneath the running /// track; nothing renegotiates. An empty id is the system default. public func use(input id: String) throws { try choose(id, input: true, what: "microphone") } public func use(output id: String) throws { try choose(id, input: false, what: "speaker") } /// The system default is remembered as a choice of its own (an empty /// UID), so that it is followed rather than held. private func choose(_ id: String, input: Bool, what: String) throws { guard let control else { throw AudioRoutes.gone(what) } if id.isEmpty || id == AudioRouteRules.defaultID { AudioPreference(id: "", name: "").save(input: input, defaults) AudioLog.info("chosen \(what): system default") refresh() return } guard let device = control.inventory().list(input).first(where: { $0.uid == id }) else { AudioLog.error("chosen \(what) \(id) is not connected; refused") throw AudioRoutes.gone(what) } failed[device.uid] = nil AudioPreference(id: device.uid, name: device.name).save(input: input, defaults) AudioLog.info("chosen \(what): \(device.describe)") refresh() } /// The current microphone's channels and the remembered one of them. /// Inputs are a choice only on a microphone chosen by name: on the /// system default the call sends what the module wires, as in 0.3.3 /// (InputChannelMap.channel). private func readChannels(_ uid: String?, explicit: Bool) { let device = control.flatMap { $0.channels(uid) } inputDevice = explicit ? device : nil let channel = device.map { InputChannelMap.channel( saved: InputChannelPreference.load(uid: $0.uid, channels: $0.count, defaults), device: $0.uid, explicit: explicit ? $0.uid : nil, inputs: $0.count) } ?? 0 update(\.inputChannels, explicit ? device.map { d in d.count > 1 ? (0..= 0, channel < max(device.count, 1), channel != inputChannel else { return } InputChannelPreference.save(channel, uid: device.uid, defaults) control?.rebuildInput() refresh() } /// Leaving the call. The choices are remembered, so there is nothing /// to undo; what is left is to look again at what is connected. func reset() { refresh() } /// The microphone actually in use, named with how it is connected and /// the rate it runs at now: "Paul's AirPods Pro (Bluetooth, 24 kHz)". /// The engine's own readback when there is one, else the choice, with /// the system default resolved to the device it is. public func inputDescription() -> String { guard let control else { return "" } let inventory = control.inventory() let uid = engineInput ?? (currentInput == AudioRouteRules.defaultID || currentInput == nil ? inventory.defaultInput : currentInput) guard let device = inventory.devices.first(where: { $0.uid == uid }) else { return "" } return AudioRoutes.describe(device) } /// The microphone a start would open, by name, when it is a Bluetooth /// headset still in its listening profile: opening its microphone /// moves it to the hands-free one (16 or 24 kHz), which takes it a /// second or two. Read from CoreAudio directly, so it is known before /// the call's audio is built. Nil for anything else. nonisolated static func bluetoothSwitching(preference: AudioPreference?, failed: [String: String]) -> String? { let inventory = CoreAudioDevices.inventory() let chosen = AudioRouteRules.resolve(preference, inventory, input: true, failed: failed) return bluetoothSwitching(uid: chosen.explicit ?? inventory.defaultInput, inventory) } nonisolated static func bluetoothSwitching(uid: String?, _ inventory: AudioInventory) -> String? { guard let device = inventory.devices.first(where: { $0.uid == uid }), device.transport == "bluetooth" else { return nil } // At the hands-free rates already: nothing to switch. return device.sampleRate > 0 && device.sampleRate <= 24_000 ? nil : device.name } nonisolated static func describe(_ device: SystemAudioDevice) -> String { var parts: [String] = [] switch device.transport { case "bluetooth": parts.append("Bluetooth") case "usb": parts.append("USB") case "built-in": parts.append("built-in") case "virtual": parts.append("virtual") case "aggregate": parts.append("aggregate") case "thunderbolt": parts.append("Thunderbolt") case "display": parts.append("display") case "airplay": parts.append("AirPlay") default: break } if device.sampleRate > 0 { parts.append(AudioRoutes.kHz(device.sampleRate)) } return parts.isEmpty ? device.name : "\(device.name) (\(parts.joined(separator: ", ")))" } /// "16 kHz", "44.1 kHz". nonisolated static func kHz(_ rate: Double) -> String { let k = rate / 1000 return k == k.rounded() ? "\(Int(k)) kHz" : String(format: "%.1f kHz", k) } /// The kinds of microphone and speaker the call is on, for the quality /// report (spec §15.26): their CoreAudio transports, never their names. /// "unknown" before the call has audio. func deviceKinds() -> (input: String, output: String) { guard let inventory = lastInventory else { return ("unknown", "unknown") } let input = engineInput ?? (currentInput == AudioRouteRules.defaultID || currentInput == nil ? inventory.defaultInput : currentInput) let output = currentOutput == AudioRouteRules.defaultID || currentOutput == nil ? inventory.defaultOutput : currentOutput func kind(_ uid: String?) -> String { guard let uid else { return "none" } return DeviceKind.mac(transport: inventory.devices.first { $0.uid == uid }?.transport) } return (kind(input), kind(output)) } /// For "Copy audio diagnostics": the choices, what they resolve to, /// and everything CoreAudio has. public func diagnostics() -> [String] { guard let control else { return ["routes: no audio attached"] } let inventory = control.inventory() let saved = [true, false].map { input -> String in guard let p = AudioPreference.load(input: input, defaults) else { return "nothing chosen" } return p.id.isEmpty ? "system default (chosen)" : "\(p.name) [\(p.id)]" } var lines = [ "chosen: microphone \(saved[0]); speaker \(saved[1])", "resolved: microphone \(currentInput ?? "none") -> \(inputDescription()); speaker \(currentOutput ?? "none") (\(outputName))", "macOS defaults: input \(inventory.defaultInput ?? "none"), output \(inventory.defaultOutput ?? "none")", "engine input read back: \(engineInput ?? "not yet")", "input sent: \(inputSource.isEmpty ? "-" : inputSource); inputs offered \(inputChannels.count)", ] if !routeNote.isEmpty { lines.append("note: \(routeNote)") } if !failed.isEmpty { lines.append("set aside: " + failed.map { "\($0.key): \($0.value)" }.sorted().joined(separator: "; ")) } let pinned = control.pinned() lines.append("engine target: \(pinned.target), last applied \(pinned.applied)") for device in inventory.devices { lines.append("device: \(device.describe)") } return lines } private static func gone(_ what: String) -> NSError { NSError( domain: "Calls", code: 5, userInfo: [NSLocalizedDescriptionKey: "That \(what) is no longer available."]) } #endif #if os(iOS) /// Chooses the microphone, and decides the Bluetooth question with it. /// /// `.allowBluetoothHFP` is what lets a Bluetooth microphone be used at /// all, and it is also what drags the whole call down to the 8 kHz /// hands-free profile the moment a headset connects — narrowband in /// both directions, for a call that would otherwise be wideband. Left /// on unconditionally, as it was, every pair of AirPods silently cost /// the call its audio quality and nothing said so. /// /// So the category is rebuilt around the choice rather than set once: /// HFP is allowed only while a Bluetooth input is the chosen one, and /// otherwise the route is A2DP for output with the device's own /// microphone, which is both wideband and usually the better /// microphone anyway. Picking the headset is then an informed trade /// someone made, not one made for them. public func use(input id: String) throws { let session = AVAudioSession.sharedInstance() let port = (session.availableInputs ?? []).first { $0.uid == id } guard let port else { throw NSError( domain: "Calls", code: 5, userInfo: [NSLocalizedDescriptionKey: "That microphone is no longer available."]) } let audio = RTCAudioSession.sharedInstance() audio.lockForConfiguration() defer { audio.unlockForConfiguration() } try audio.setCategory( AVAudioSession.Category.playAndRecord, mode: AVAudioSession.Mode.videoChat, options: AudioRoutes.options(bluetoothInput: AudioRoutes.isBluetooth(port))) try session.setPreferredInput(port) refresh() } /// Pure, and deliberately outside the actor: a rule about category /// options is not state, and the test that holds it has no audio /// session and no main actor to run on. /// /// True for the port types that only reach a microphone over the /// hands-free profile. A2DP is output-only, so a headset used as a /// microphone is always one of these. public nonisolated static func isBluetooth(_ port: AVAudioSessionPortDescription) -> Bool { port.portType == .bluetoothHFP } /// The category options for a call, given whether its microphone is a /// Bluetooth one. Pure, so the test can hold the rule without an audio /// session to configure. public nonisolated static func options(bluetoothInput: Bool) -> AVAudioSession.CategoryOptions { bluetoothInput ? [.defaultToSpeaker, .allowBluetoothHFP, .allowBluetoothA2DP] : [.defaultToSpeaker, .allowBluetoothA2DP] } #endif } #if os(macOS) /// A remembered microphone or speaker: its CoreAudio UID, and its name /// for while it is missing (and for finding it again under a new UID). /// An empty UID is the system default, chosen. struct AudioPreference: Equatable, Sendable { var id: String var name: String private static func keys(_ input: Bool) -> (String, String) { input ? ("audioInputUID", "audioInputName") : ("audioOutputUID", "audioOutputName") } static func load(input: Bool, _ defaults: UserDefaults) -> AudioPreference? { let (idKey, nameKey) = keys(input) guard let id = defaults.string(forKey: idKey) else { return nil } return AudioPreference(id: id, name: defaults.string(forKey: nameKey) ?? "") } func save(input: Bool, _ defaults: UserDefaults) { let (idKey, nameKey) = Self.keys(input) defaults.set(id, forKey: idKey) defaults.set(name, forKey: nameKey) } } /// A device as the audio device module lists it. Logged beside /// CoreAudio's list, never chosen from. struct ModuleDevice: Equatable, Sendable { let id: String let name: String let isDefault: Bool } /// The call's audio as the routing rules see it: what is connected, and /// a way to put the engine on a device. The real one is CoreAudio and /// LiveKit's engine module; the tests give it an inventory and record /// what is asked, so the rules are held without opening a microphone. struct AudioEngineControl { var inventory: () -> AudioInventory /// Hands the explicit choices to the engine. True when an engine is /// running on something else and has to be rebuilt to move. var pin: (_ input: String?, _ output: String?) -> Bool var choices: () -> (input: String?, output: String?) /// Makes the module build its engine again, so the pin applies now. var rebuild: () -> Void /// The input channels of the device with this UID (nil: the default). var channels: (_ uid: String?) -> InputDeviceChannels? /// Makes the module wire its microphone in again, so a new channel /// map is applied. A no-op while it is not capturing: the map is read /// whenever the input is next wired in. var rebuildInput: () -> Void /// For the log: what the engine module lists, beside CoreAudio. var logModuleLists: () -> Void /// The input rate the engine last started with (nil: not recording). var inputRate: () -> Double? var isRecording: () -> Bool /// Where the engine would be put now, and where it last was. var pinned: () -> (target: String, applied: String, isPair: Bool) init( inventory: @escaping () -> AudioInventory, pin: @escaping (_ input: String?, _ output: String?) -> Bool = { _, _ in false }, choices: @escaping () -> (input: String?, output: String?) = { (nil, nil) }, rebuild: @escaping () -> Void = {}, channels: @escaping (_ uid: String?) -> InputDeviceChannels? = { _ in nil }, rebuildInput: @escaping () -> Void = {}, logModuleLists: @escaping () -> Void = {}, inputRate: @escaping () -> Double? = { nil }, isRecording: @escaping () -> Bool = { false }, pinned: @escaping () -> (target: String, applied: String, isPair: Bool) = { ("-", "-", false) } ) { self.inventory = inventory self.pin = pin self.choices = choices self.rebuild = rebuild self.channels = channels self.rebuildInput = rebuildInput self.logModuleLists = logModuleLists self.inputRate = inputRate self.isRecording = isRecording self.pinned = pinned } /// The module is only ever asked on the audio queue; the main thread /// reads its state from `state` (ModuleState), never from the module. init( _ module: RTCAudioDeviceModule, state: ModuleState, pin enginePin: AudioEnginePin, inputRate: @escaping () -> Double? ) { inventory = CoreAudioDevices.inventory self.inputRate = inputRate isRecording = { state.read().isRecording } pinned = { let target = enginePin.target() if case .pair = target { return (target.description, enginePin.lastApplied?.description ?? "none yet", true) } return (target.description, enginePin.lastApplied?.description ?? "none yet", false) } choices = { enginePin.choices } // A running engine is rebuilt when what it would be put on now is // not what it was put on. Before an engine exists (the module makes // one for the first connection) there is nothing to move: the first // engine is made on the choice. // A rebuild runs on the audio queue, so `lastApplied` lags the ask by // the time it takes; a second look in between (CoreAudio's device // list changes as the old aggregate goes) must not ask again. let asked = OSAllocatedUnfairLock(initialState: nil) pin = { input, output in _ = enginePin.set(input: input, output: output) let module = state.read() guard !module.isManualRenderingMode, module.isPlaying || module.isRecording, let applied = enginePin.lastApplied else { return false } let target = enginePin.target() guard target != applied else { // Caught up: the next move, even back to a target asked for // before (a device that failed, chosen again), is a new ask. asked.withLock { $0 = nil } return false } return asked.withLock { last in guard last != target else { return false } last = target return true } } // The module has no "rebuild" call. Taking both sides away releases // its engine; giving them back makes a new one, and the pin is // applied to it in the "will enable" hook. // On the audio queue, as every engine start is (AudioWork): a rebuild // onto a Bluetooth microphone waits for the headset to change // profile, and the main thread must not wait with it. rebuild = { [weak module] in guard let module else { return } AudioWork.post { guard !module.isManualRenderingMode else { return } let saved = module.engineAvailability _ = module.setEngineAvailability( RTCAudioEngineAvailability(isInputAvailable: false, isOutputAvailable: false)) let result = module.setEngineAvailability(saved) AudioLog.info("engine rebuilt (result \(result), running \(module.isEngineRunning))") } } channels = { uid in CoreAudioInput.device(forModuleID: uid).flatMap(CoreAudioInput.describe) } // Taking the input away and giving it back rewires the microphone, // asking the observer first — which is where the map is applied. // (Re-selecting the same device does nothing: the module ignores a // state that has not changed.) rebuildInput = { [weak module] in guard let module else { return } AudioWork.post { guard module.isRecording, !module.isManualRenderingMode else { return } _ = module.setEngineAvailability( RTCAudioEngineAvailability(isInputAvailable: false, isOutputAvailable: true)) _ = module.setEngineAvailability( RTCAudioEngineAvailability(isInputAvailable: true, isOutputAvailable: true)) } } // Off the main thread: the module's first list is made on WebRTC's // worker thread, 61–68 ms measured, only to be logged. logModuleLists = { [weak module] in guard let module else { return } AudioWork.post { func line(_ list: [RTCIODevice]) -> String { list.map { "\($0.name) [\($0.deviceId)]" }.joined(separator: "; ") } AudioLog.info( "engine module lists (not used to choose): inputs \(line(module.inputDevices)); outputs \(line(module.outputDevices))" ) } } } } #endif