| 1 | import AVFoundation |
| 2 | import Foundation |
| 3 | import os |
| 4 | #if os(macOS) |
| 5 | import CoreAudio |
| 6 | #endif |
| 7 | |
| 8 | import LiveKitWebRTC |
| 9 | |
| 10 | /// One audio device the person can be on. |
| 11 | public struct AudioDevice: Identifiable, Hashable, Sendable { |
| 12 | public let id: String |
| 13 | public let name: String |
| 14 | public let selectable: Bool |
| 15 | public init(id: String, name: String, selectable: Bool) { |
| 16 | self.id = id |
| 17 | self.name = name |
| 18 | self.selectable = selectable |
| 19 | } |
| 20 | } |
| 21 | |
| 22 | /// The audio devices a call is on, and the ability to change them. |
| 23 | /// |
| 24 | /// On iPhone the microphone is a route on the audio session. On Mac it is |
| 25 | /// a CoreAudio device the call's audio engine is put on (AudioDevices.swift |
| 26 | /// says why the engine module is no longer asked to choose). Until a call |
| 27 | /// has made its audio, the Mac lists are empty. |
| 28 | /// |
| 29 | /// On Mac the person's choices are remembered — across calls and across |
| 30 | /// launches — and the rules are: |
| 31 | /// |
| 32 | /// - **System default** is an entry of its own, where someone who has |
| 33 | /// never chosen starts, and a choice like any other: picked, it is what |
| 34 | /// is remembered. It follows macOS — a new default device, or a device |
| 35 | /// changing its sample rate, is the module's to follow, and it does (it |
| 36 | /// listens for both and rebuilds its engine). |
| 37 | /// - A chosen device is kept by its CoreAudio UID, with its name beside it |
| 38 | /// for while it is missing, and found by that name when it comes back |
| 39 | /// under another UID (a USB interface moved to another port). A change of |
| 40 | /// system default does not move it. |
| 41 | /// - A chosen device that is not connected — at join, or disconnecting |
| 42 | /// mid-call — puts the call on the system default for now, and |
| 43 | /// `routeNote` says so ("Scarlett 18i8 isn't connected; using MacBook Pro |
| 44 | /// Microphone"). "Connected" is CoreAudio's device list, which is ready at |
| 45 | /// launch; the engine module's list, which is not, is never asked. The |
| 46 | /// choice is kept, and when the device comes back the call goes back to |
| 47 | /// it on its own. |
| 48 | /// - A device the engine cannot start on is said as such ("Calls couldn't |
| 49 | /// open …"), the call goes back to the default, and choosing it again, or |
| 50 | /// the device list changing, tries it again. |
| 51 | /// - Nothing here changes the Mac's Sound settings. |
| 52 | /// |
| 53 | /// Calls 0.3.1 forgot the choices at the end of every call, so a Scarlett |
| 54 | /// chosen yesterday was the MacBook's microphone today (Paul, 2026-09-24). |
| 55 | @MainActor public final class AudioRoutes: ObservableObject { |
| 56 | @Published public private(set) var inputs: [AudioDevice] = [] |
| 57 | @Published public private(set) var currentInput: String? |
| 58 | @Published public private(set) var outputName = "" |
| 59 | /// Speakers, where they can be chosen (Mac). Empty on iPhone, where the |
| 60 | /// output follows the route and is only named. |
| 61 | @Published public private(set) var outputs: [AudioDevice] = [] |
| 62 | @Published public private(set) var currentOutput: String? |
| 63 | /// True where inputs can actually be chosen. The UI shows a picker when |
| 64 | /// it is set and a plain line of text when it is not, rather than a |
| 65 | /// disabled control that looks like something is broken. |
| 66 | public let selectable = true |
| 67 | |
| 68 | #if os(macOS) |
| 69 | /// Said while a remembered device is missing, or could not be opened, |
| 70 | /// and the call is on the system default instead. Clears itself when |
| 71 | /// the device is back. |
| 72 | @Published public private(set) var routeNote = "" |
| 73 | /// The microphone's input channels, labelled, when it has more than |
| 74 | /// one (an audio interface); empty for an ordinary microphone. See |
| 75 | /// InputChannel.swift for why this is a choice at all. |
| 76 | @Published public private(set) var inputChannels: [String] = [] |
| 77 | /// The input the call sends from, 0-based. Remembered per device. |
| 78 | @Published public private(set) var inputChannel = 0 |
| 79 | /// The microphone and, on an interface, its input: "Scarlett 18i8 USB, |
| 80 | /// input 1". Empty before a call has audio. |
| 81 | @Published public private(set) var inputSource = "" |
| 82 | private var inputDevice: InputDeviceChannels? |
| 83 | private var control: AudioEngineControl? |
| 84 | let defaults: UserDefaults |
| 85 | /// Devices the engine could not start on, by UID, with why. Cleared |
| 86 | /// when the person chooses the device again or what is connected |
| 87 | /// changes. |
| 88 | private(set) var failed: [String: String] = [:] |
| 89 | /// The microphone the engine was last seen running on, read back from |
| 90 | /// its unit — the truth when it is known. |
| 91 | private var engineInput: String? |
| 92 | private var lastInventory: AudioInventory? |
| 93 | private var lastDecision = "" |
| 94 | private var listener: AudioObjectPropertyListenerBlock? |
| 95 | /// The chosen microphone's nominal rate, listened to: a Bluetooth |
| 96 | /// headset moves to its hands-free rate (16 or 24 kHz) the moment its |
| 97 | /// microphone opens, under an engine wired at the old rate. |
| 98 | private var rateWatch: (device: AudioObjectID, uid: String, block: AudioObjectPropertyListenerBlock)? |
| 99 | private var lastRateRebuild = -Double.infinity |
| 100 | #endif |
| 101 | |
| 102 | #if os(macOS) |
| 103 | public init(defaults: UserDefaults = .standard) { |
| 104 | self.defaults = defaults |
| 105 | refresh() |
| 106 | } |
| 107 | #else |
| 108 | public init() { |
| 109 | refresh() |
| 110 | } |
| 111 | #endif |
| 112 | |
| 113 | public func refresh() { |
| 114 | #if os(iOS) |
| 115 | let session = AVAudioSession.sharedInstance() |
| 116 | inputs = (session.availableInputs ?? []).map { |
| 117 | AudioDevice(id: $0.uid, name: $0.portName, selectable: true) |
| 118 | } |
| 119 | currentInput = session.currentRoute.inputs.first?.uid |
| 120 | outputName = session.currentRoute.outputs.first?.portName ?? "" |
| 121 | #else |
| 122 | guard let control else { |
| 123 | update(\.inputs, []) |
| 124 | update(\.outputs, []) |
| 125 | update(\.currentInput, nil) |
| 126 | update(\.currentOutput, nil) |
| 127 | update(\.outputName, "") |
| 128 | update(\.routeNote, "") |
| 129 | return |
| 130 | } |
| 131 | let inventory = control.inventory() |
| 132 | if inventory != lastInventory { |
| 133 | // Something was plugged in or taken out: a device that could not |
| 134 | // be opened gets another try. |
| 135 | if let last = lastInventory, Set(last.devices.map(\.uid)) != Set(inventory.devices.map(\.uid)) { |
| 136 | failed = [:] |
| 137 | } |
| 138 | lastInventory = inventory |
| 139 | AudioLog.info( |
| 140 | "CoreAudio devices: \(inventory.devices.map(\.describe).joined(separator: "; ")); default in \(inventory.defaultInput ?? "none"), out \(inventory.defaultOutput ?? "none")") |
| 141 | control.logModuleLists() |
| 142 | } |
| 143 | let input = AudioRouteRules.resolve( |
| 144 | AudioPreference.load(input: true, defaults), inventory, input: true, failed: failed) |
| 145 | let output = AudioRouteRules.resolve( |
| 146 | AudioPreference.load(input: false, defaults), inventory, input: false, failed: failed) |
| 147 | input.remember?.save(input: true, defaults) |
| 148 | output.remember?.save(input: false, defaults) |
| 149 | let decision = "input \(input.current): \(input.reason); output \(output.current): \(output.reason)" |
| 150 | if decision != lastDecision { |
| 151 | lastDecision = decision |
| 152 | AudioLog.info("routes: \(decision)") |
| 153 | } |
| 154 | if control.pin(input.explicit, output.explicit) { |
| 155 | AudioLog.info( |
| 156 | "routes: engine asked for input \(input.explicit ?? "default"), output \(output.explicit ?? "default"); rebuilding a running engine") |
| 157 | control.rebuild() |
| 158 | } |
| 159 | update(\.routeNote, [input.note, output.note].compactMap { $0 }.joined(separator: " ")) |
| 160 | update(\.inputs, AudioRoutes.list(inventory, input: true)) |
| 161 | update(\.outputs, AudioRoutes.list(inventory, input: false)) |
| 162 | update(\.currentInput, input.current) |
| 163 | update(\.currentOutput, output.current) |
| 164 | update(\.outputName, outputs.first { $0.id == currentOutput }?.name ?? "") |
| 165 | readChannels(input.explicit ?? inventory.defaultInput, explicit: input.explicit != nil) |
| 166 | watchRate(of: input.explicit) |
| 167 | #endif |
| 168 | } |
| 169 | |
| 170 | #if os(macOS) |
| 171 | /// The panel refreshes while it is open; publishing only what changed |
| 172 | /// keeps an open menu from being rebuilt under the pointer. |
| 173 | private func update<Value: Equatable>( |
| 174 | _ key: ReferenceWritableKeyPath<AudioRoutes, Value>, _ value: Value |
| 175 | ) { |
| 176 | if self[keyPath: key] != value { self[keyPath: key] = value } |
| 177 | } |
| 178 | |
| 179 | /// "System default (MacBook Pro Microphone)" first, then every device |
| 180 | /// CoreAudio has for that side. |
| 181 | nonisolated static func list(_ inventory: AudioInventory, input: Bool) -> [AudioDevice] { |
| 182 | let head = inventory.systemDefault(input).map { |
| 183 | AudioDevice(id: AudioRouteRules.defaultID, name: "System default (\($0.name))", selectable: true) |
| 184 | } ?? AudioDevice(id: AudioRouteRules.defaultID, name: "System default", selectable: true) |
| 185 | return [head] + inventory.list(input).map { AudioDevice(id: $0.uid, name: $0.name, selectable: true) } |
| 186 | } |
| 187 | |
| 188 | func attach( |
| 189 | _ module: RTCAudioDeviceModule, state: ModuleState, pin: AudioEnginePin, inputRate: @escaping () -> Double? |
| 190 | ) { |
| 191 | attach(AudioEngineControl(module, state: state, pin: pin, inputRate: inputRate)) |
| 192 | listenForDevices() |
| 193 | } |
| 194 | |
| 195 | func attach(_ control: AudioEngineControl) { |
| 196 | self.control = control |
| 197 | failed = [:] |
| 198 | engineInput = nil |
| 199 | lastInventory = nil |
| 200 | lastDecision = "" |
| 201 | refresh() |
| 202 | } |
| 203 | |
| 204 | /// CoreAudio's own device list and defaults, listened to directly: the |
| 205 | /// moment a remembered device comes back is the moment the call should |
| 206 | /// move to it. No poller. |
| 207 | private func listenForDevices() { |
| 208 | guard listener == nil else { return } |
| 209 | let block: AudioObjectPropertyListenerBlock = { [weak self] _, _ in |
| 210 | DispatchQueue.main.async { MainActor.assumeIsolated { self?.refresh() } } |
| 211 | } |
| 212 | var ok = false |
| 213 | for selector in [ |
| 214 | kAudioHardwarePropertyDevices, kAudioHardwarePropertyDefaultInputDevice, |
| 215 | kAudioHardwarePropertyDefaultOutputDevice, |
| 216 | ] { |
| 217 | var address = AudioObjectPropertyAddress( |
| 218 | mSelector: selector, mScope: kAudioObjectPropertyScopeGlobal, mElement: kAudioObjectPropertyElementMain) |
| 219 | ok = |
| 220 | AudioObjectAddPropertyListenerBlock( |
| 221 | AudioObjectID(kAudioObjectSystemObject), &address, DispatchQueue.main, block) == noErr || ok |
| 222 | } |
| 223 | if ok { listener = block } |
| 224 | } |
| 225 | |
| 226 | /// The engine started on a unit whose microphone is `uid`: what the |
| 227 | /// readout and the note name from now on. |
| 228 | func engineStarted(input uid: String?) { |
| 229 | guard uid != engineInput else { return } |
| 230 | engineInput = uid |
| 231 | AudioLog.info("engine running with input \(uid ?? "unknown")") |
| 232 | let chosen = currentInput != AudioRouteRules.defaultID |
| 233 | readChannels(chosen ? currentInput : lastInventory?.defaultInput, explicit: chosen) |
| 234 | } |
| 235 | |
| 236 | /// Listens to the chosen microphone's sample rate (nil stops). With |
| 237 | /// the system default the module follows the device itself, as it did |
| 238 | /// in 0.3.3, and nothing is added. |
| 239 | private func watchRate(of uid: String?) { |
| 240 | guard uid != rateWatch?.uid else { return } |
| 241 | if let old = rateWatch { |
| 242 | var address = AudioRoutes.rateAddress |
| 243 | AudioObjectRemovePropertyListenerBlock(old.device, &address, DispatchQueue.main, old.block) |
| 244 | rateWatch = nil |
| 245 | } |
| 246 | guard let uid, let device = CoreAudioInput.device(uid: uid) else { return } |
| 247 | let block: AudioObjectPropertyListenerBlock = { [weak self] _, _ in |
| 248 | DispatchQueue.main.async { MainActor.assumeIsolated { self?.rateChanged(uid) } } |
| 249 | } |
| 250 | var address = AudioRoutes.rateAddress |
| 251 | if AudioObjectAddPropertyListenerBlock(device, &address, DispatchQueue.main, block) == noErr { |
| 252 | rateWatch = (device, uid, block) |
| 253 | } |
| 254 | } |
| 255 | |
| 256 | private static var rateAddress: AudioObjectPropertyAddress { |
| 257 | AudioObjectPropertyAddress( |
| 258 | mSelector: kAudioDevicePropertyNominalSampleRate, mScope: kAudioObjectPropertyScopeGlobal, |
| 259 | mElement: kAudioObjectPropertyElementMain) |
| 260 | } |
| 261 | |
| 262 | /// The chosen microphone changed rate. A running engine wired at |
| 263 | /// another rate is rebuilt, so the microphone is wired in again at the |
| 264 | /// new one (at most once every three seconds). |
| 265 | private func rateChanged(_ uid: String) { |
| 266 | guard let control, let device = CoreAudioInput.device(uid: uid) else { return } |
| 267 | let rate = CoreAudioDevices.sampleRate(device) |
| 268 | let now = CaptureDenoiser.now() |
| 269 | let rebuild = RateRule.rebuild( |
| 270 | deviceRate: rate, engineRate: control.inputRate(), recording: control.isRecording(), |
| 271 | pair: control.pinned().isPair, lastRebuild: lastRateRebuild, now: now) |
| 272 | AudioLog.info( |
| 273 | "\(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")") |
| 274 | guard rebuild else { return } |
| 275 | lastRateRebuild = now |
| 276 | control.rebuild() |
| 277 | } |
| 278 | |
| 279 | /// The engine did not start on the devices it was put on. They are |
| 280 | /// set aside, the call goes back to the default, and the panel says so. |
| 281 | func engineFailed(_ target: EngineTarget, reason: String) { |
| 282 | let uids: [String] |
| 283 | switch target { |
| 284 | case .systemDefault: return |
| 285 | case .device(let uid): uids = [uid] |
| 286 | case .pair(let input, let output): uids = [input, output] |
| 287 | } |
| 288 | let (input, output) = control?.choices() ?? (nil, nil) |
| 289 | for uid in uids where uid == input || uid == output { failed[uid] = reason } |
| 290 | AudioLog.error( |
| 291 | "engine failed on \(target.description): \(reason); falling back to the system default") |
| 292 | refresh() |
| 293 | } |
| 294 | |
| 295 | /// Chooses the microphone on Mac, mid-call if one is up, and remembers |
| 296 | /// it. The engine is rebuilt on the device underneath the running |
| 297 | /// track; nothing renegotiates. An empty id is the system default. |
| 298 | public func use(input id: String) throws { |
| 299 | try choose(id, input: true, what: "microphone") |
| 300 | } |
| 301 | |
| 302 | public func use(output id: String) throws { |
| 303 | try choose(id, input: false, what: "speaker") |
| 304 | } |
| 305 | |
| 306 | /// The system default is remembered as a choice of its own (an empty |
| 307 | /// UID), so that it is followed rather than held. |
| 308 | private func choose(_ id: String, input: Bool, what: String) throws { |
| 309 | guard let control else { throw AudioRoutes.gone(what) } |
| 310 | if id.isEmpty || id == AudioRouteRules.defaultID { |
| 311 | AudioPreference(id: "", name: "").save(input: input, defaults) |
| 312 | AudioLog.info("chosen \(what): system default") |
| 313 | refresh() |
| 314 | return |
| 315 | } |
| 316 | guard let device = control.inventory().list(input).first(where: { $0.uid == id }) else { |
| 317 | AudioLog.error("chosen \(what) \(id) is not connected; refused") |
| 318 | throw AudioRoutes.gone(what) |
| 319 | } |
| 320 | failed[device.uid] = nil |
| 321 | AudioPreference(id: device.uid, name: device.name).save(input: input, defaults) |
| 322 | AudioLog.info("chosen \(what): \(device.describe)") |
| 323 | refresh() |
| 324 | } |
| 325 | |
| 326 | /// The current microphone's channels and the remembered one of them. |
| 327 | /// Inputs are a choice only on a microphone chosen by name: on the |
| 328 | /// system default the call sends what the module wires, as in 0.3.3 |
| 329 | /// (InputChannelMap.channel). |
| 330 | private func readChannels(_ uid: String?, explicit: Bool) { |
| 331 | let device = control.flatMap { $0.channels(uid) } |
| 332 | inputDevice = explicit ? device : nil |
| 333 | let channel = |
| 334 | device.map { |
| 335 | InputChannelMap.channel( |
| 336 | saved: InputChannelPreference.load(uid: $0.uid, channels: $0.count, defaults), device: $0.uid, |
| 337 | explicit: explicit ? $0.uid : nil, inputs: $0.count) |
| 338 | } ?? 0 |
| 339 | update(\.inputChannels, explicit ? device.map { d in d.count > 1 ? (0..<d.count).map(d.label) : [] } ?? [] : []) |
| 340 | update(\.inputChannel, channel) |
| 341 | update(\.inputSource, device?.describe(channel: channel) ?? "") |
| 342 | } |
| 343 | |
| 344 | /// Chooses which input of a multi-channel microphone the call sends, |
| 345 | /// remembers it for that device, and rebuilds the module's input so it |
| 346 | /// takes effect now — a moment's gap in both directions, as a device |
| 347 | /// switch has. Outside a call it is only remembered. |
| 348 | public func use(inputChannel channel: Int) { |
| 349 | guard let device = inputDevice, channel >= 0, channel < max(device.count, 1), |
| 350 | channel != inputChannel |
| 351 | else { return } |
| 352 | InputChannelPreference.save(channel, uid: device.uid, defaults) |
| 353 | control?.rebuildInput() |
| 354 | refresh() |
| 355 | } |
| 356 | |
| 357 | /// Leaving the call. The choices are remembered, so there is nothing |
| 358 | /// to undo; what is left is to look again at what is connected. |
| 359 | func reset() { |
| 360 | refresh() |
| 361 | } |
| 362 | |
| 363 | /// The microphone actually in use, named with how it is connected and |
| 364 | /// the rate it runs at now: "Paul's AirPods Pro (Bluetooth, 24 kHz)". |
| 365 | /// The engine's own readback when there is one, else the choice, with |
| 366 | /// the system default resolved to the device it is. |
| 367 | public func inputDescription() -> String { |
| 368 | guard let control else { return "" } |
| 369 | let inventory = control.inventory() |
| 370 | let uid = |
| 371 | engineInput ?? (currentInput == AudioRouteRules.defaultID || currentInput == nil ? inventory.defaultInput : currentInput) |
| 372 | guard let device = inventory.devices.first(where: { $0.uid == uid }) else { return "" } |
| 373 | return AudioRoutes.describe(device) |
| 374 | } |
| 375 | |
| 376 | /// The microphone a start would open, by name, when it is a Bluetooth |
| 377 | /// headset still in its listening profile: opening its microphone |
| 378 | /// moves it to the hands-free one (16 or 24 kHz), which takes it a |
| 379 | /// second or two. Read from CoreAudio directly, so it is known before |
| 380 | /// the call's audio is built. Nil for anything else. |
| 381 | nonisolated static func bluetoothSwitching(preference: AudioPreference?, failed: [String: String]) -> String? { |
| 382 | let inventory = CoreAudioDevices.inventory() |
| 383 | let chosen = AudioRouteRules.resolve(preference, inventory, input: true, failed: failed) |
| 384 | return bluetoothSwitching(uid: chosen.explicit ?? inventory.defaultInput, inventory) |
| 385 | } |
| 386 | |
| 387 | nonisolated static func bluetoothSwitching(uid: String?, _ inventory: AudioInventory) -> String? { |
| 388 | guard let device = inventory.devices.first(where: { $0.uid == uid }), device.transport == "bluetooth" |
| 389 | else { return nil } |
| 390 | // At the hands-free rates already: nothing to switch. |
| 391 | return device.sampleRate > 0 && device.sampleRate <= 24_000 ? nil : device.name |
| 392 | } |
| 393 | |
| 394 | nonisolated static func describe(_ device: SystemAudioDevice) -> String { |
| 395 | var parts: [String] = [] |
| 396 | switch device.transport { |
| 397 | case "bluetooth": parts.append("Bluetooth") |
| 398 | case "usb": parts.append("USB") |
| 399 | case "built-in": parts.append("built-in") |
| 400 | case "virtual": parts.append("virtual") |
| 401 | case "aggregate": parts.append("aggregate") |
| 402 | case "thunderbolt": parts.append("Thunderbolt") |
| 403 | case "display": parts.append("display") |
| 404 | case "airplay": parts.append("AirPlay") |
| 405 | default: break |
| 406 | } |
| 407 | if device.sampleRate > 0 { parts.append(AudioRoutes.kHz(device.sampleRate)) } |
| 408 | return parts.isEmpty ? device.name : "\(device.name) (\(parts.joined(separator: ", ")))" |
| 409 | } |
| 410 | |
| 411 | /// "16 kHz", "44.1 kHz". |
| 412 | nonisolated static func kHz(_ rate: Double) -> String { |
| 413 | let k = rate / 1000 |
| 414 | return k == k.rounded() ? "\(Int(k)) kHz" : String(format: "%.1f kHz", k) |
| 415 | } |
| 416 | |
| 417 | /// The kinds of microphone and speaker the call is on, for the quality |
| 418 | /// report (spec §15.26): their CoreAudio transports, never their names. |
| 419 | /// "unknown" before the call has audio. |
| 420 | func deviceKinds() -> (input: String, output: String) { |
| 421 | guard let inventory = lastInventory else { return ("unknown", "unknown") } |
| 422 | let input = |
| 423 | engineInput ?? (currentInput == AudioRouteRules.defaultID || currentInput == nil ? inventory.defaultInput : currentInput) |
| 424 | let output = currentOutput == AudioRouteRules.defaultID || currentOutput == nil ? inventory.defaultOutput : currentOutput |
| 425 | func kind(_ uid: String?) -> String { |
| 426 | guard let uid else { return "none" } |
| 427 | return DeviceKind.mac(transport: inventory.devices.first { $0.uid == uid }?.transport) |
| 428 | } |
| 429 | return (kind(input), kind(output)) |
| 430 | } |
| 431 | |
| 432 | /// For "Copy audio diagnostics": the choices, what they resolve to, |
| 433 | /// and everything CoreAudio has. |
| 434 | public func diagnostics() -> [String] { |
| 435 | guard let control else { return ["routes: no audio attached"] } |
| 436 | let inventory = control.inventory() |
| 437 | let saved = [true, false].map { input -> String in |
| 438 | guard let p = AudioPreference.load(input: input, defaults) else { return "nothing chosen" } |
| 439 | return p.id.isEmpty ? "system default (chosen)" : "\(p.name) [\(p.id)]" |
| 440 | } |
| 441 | var lines = [ |
| 442 | "chosen: microphone \(saved[0]); speaker \(saved[1])", |
| 443 | "resolved: microphone \(currentInput ?? "none") -> \(inputDescription()); speaker \(currentOutput ?? "none") (\(outputName))", |
| 444 | "macOS defaults: input \(inventory.defaultInput ?? "none"), output \(inventory.defaultOutput ?? "none")", |
| 445 | "engine input read back: \(engineInput ?? "not yet")", |
| 446 | "input sent: \(inputSource.isEmpty ? "-" : inputSource); inputs offered \(inputChannels.count)", |
| 447 | ] |
| 448 | if !routeNote.isEmpty { lines.append("note: \(routeNote)") } |
| 449 | if !failed.isEmpty { |
| 450 | lines.append("set aside: " + failed.map { "\($0.key): \($0.value)" }.sorted().joined(separator: "; ")) |
| 451 | } |
| 452 | let pinned = control.pinned() |
| 453 | lines.append("engine target: \(pinned.target), last applied \(pinned.applied)") |
| 454 | for device in inventory.devices { lines.append("device: \(device.describe)") } |
| 455 | return lines |
| 456 | } |
| 457 | |
| 458 | private static func gone(_ what: String) -> NSError { |
| 459 | NSError( |
| 460 | domain: "Calls", code: 5, |
| 461 | userInfo: [NSLocalizedDescriptionKey: "That \(what) is no longer available."]) |
| 462 | } |
| 463 | #endif |
| 464 | |
| 465 | #if os(iOS) |
| 466 | /// Chooses the microphone, and decides the Bluetooth question with it. |
| 467 | /// |
| 468 | /// `.allowBluetoothHFP` is what lets a Bluetooth microphone be used at |
| 469 | /// all, and it is also what drags the whole call down to the 8 kHz |
| 470 | /// hands-free profile the moment a headset connects — narrowband in |
| 471 | /// both directions, for a call that would otherwise be wideband. Left |
| 472 | /// on unconditionally, as it was, every pair of AirPods silently cost |
| 473 | /// the call its audio quality and nothing said so. |
| 474 | /// |
| 475 | /// So the category is rebuilt around the choice rather than set once: |
| 476 | /// HFP is allowed only while a Bluetooth input is the chosen one, and |
| 477 | /// otherwise the route is A2DP for output with the device's own |
| 478 | /// microphone, which is both wideband and usually the better |
| 479 | /// microphone anyway. Picking the headset is then an informed trade |
| 480 | /// someone made, not one made for them. |
| 481 | public func use(input id: String) throws { |
| 482 | let session = AVAudioSession.sharedInstance() |
| 483 | let port = (session.availableInputs ?? []).first { $0.uid == id } |
| 484 | guard let port else { |
| 485 | throw NSError( |
| 486 | domain: "Calls", code: 5, |
| 487 | userInfo: [NSLocalizedDescriptionKey: "That microphone is no longer available."]) |
| 488 | } |
| 489 | let audio = RTCAudioSession.sharedInstance() |
| 490 | audio.lockForConfiguration() |
| 491 | defer { audio.unlockForConfiguration() } |
| 492 | try audio.setCategory( |
| 493 | AVAudioSession.Category.playAndRecord, |
| 494 | mode: AVAudioSession.Mode.videoChat, |
| 495 | options: AudioRoutes.options(bluetoothInput: AudioRoutes.isBluetooth(port))) |
| 496 | try session.setPreferredInput(port) |
| 497 | refresh() |
| 498 | } |
| 499 | |
| 500 | /// Pure, and deliberately outside the actor: a rule about category |
| 501 | /// options is not state, and the test that holds it has no audio |
| 502 | /// session and no main actor to run on. |
| 503 | /// |
| 504 | /// True for the port types that only reach a microphone over the |
| 505 | /// hands-free profile. A2DP is output-only, so a headset used as a |
| 506 | /// microphone is always one of these. |
| 507 | public nonisolated static func isBluetooth(_ port: AVAudioSessionPortDescription) -> Bool { |
| 508 | port.portType == .bluetoothHFP |
| 509 | } |
| 510 | |
| 511 | /// The category options for a call, given whether its microphone is a |
| 512 | /// Bluetooth one. Pure, so the test can hold the rule without an audio |
| 513 | /// session to configure. |
| 514 | public nonisolated static func options(bluetoothInput: Bool) -> AVAudioSession.CategoryOptions { |
| 515 | bluetoothInput |
| 516 | ? [.defaultToSpeaker, .allowBluetoothHFP, .allowBluetoothA2DP] |
| 517 | : [.defaultToSpeaker, .allowBluetoothA2DP] |
| 518 | } |
| 519 | #endif |
| 520 | } |
| 521 | |
| 522 | #if os(macOS) |
| 523 | /// A remembered microphone or speaker: its CoreAudio UID, and its name |
| 524 | /// for while it is missing (and for finding it again under a new UID). |
| 525 | /// An empty UID is the system default, chosen. |
| 526 | struct AudioPreference: Equatable, Sendable { |
| 527 | var id: String |
| 528 | var name: String |
| 529 | |
| 530 | private static func keys(_ input: Bool) -> (String, String) { |
| 531 | input ? ("audioInputUID", "audioInputName") : ("audioOutputUID", "audioOutputName") |
| 532 | } |
| 533 | static func load(input: Bool, _ defaults: UserDefaults) -> AudioPreference? { |
| 534 | let (idKey, nameKey) = keys(input) |
| 535 | guard let id = defaults.string(forKey: idKey) else { return nil } |
| 536 | return AudioPreference(id: id, name: defaults.string(forKey: nameKey) ?? "") |
| 537 | } |
| 538 | func save(input: Bool, _ defaults: UserDefaults) { |
| 539 | let (idKey, nameKey) = Self.keys(input) |
| 540 | defaults.set(id, forKey: idKey) |
| 541 | defaults.set(name, forKey: nameKey) |
| 542 | } |
| 543 | } |
| 544 | |
| 545 | /// A device as the audio device module lists it. Logged beside |
| 546 | /// CoreAudio's list, never chosen from. |
| 547 | struct ModuleDevice: Equatable, Sendable { |
| 548 | let id: String |
| 549 | let name: String |
| 550 | let isDefault: Bool |
| 551 | } |
| 552 | |
| 553 | /// The call's audio as the routing rules see it: what is connected, and |
| 554 | /// a way to put the engine on a device. The real one is CoreAudio and |
| 555 | /// LiveKit's engine module; the tests give it an inventory and record |
| 556 | /// what is asked, so the rules are held without opening a microphone. |
| 557 | struct AudioEngineControl { |
| 558 | var inventory: () -> AudioInventory |
| 559 | /// Hands the explicit choices to the engine. True when an engine is |
| 560 | /// running on something else and has to be rebuilt to move. |
| 561 | var pin: (_ input: String?, _ output: String?) -> Bool |
| 562 | var choices: () -> (input: String?, output: String?) |
| 563 | /// Makes the module build its engine again, so the pin applies now. |
| 564 | var rebuild: () -> Void |
| 565 | /// The input channels of the device with this UID (nil: the default). |
| 566 | var channels: (_ uid: String?) -> InputDeviceChannels? |
| 567 | /// Makes the module wire its microphone in again, so a new channel |
| 568 | /// map is applied. A no-op while it is not capturing: the map is read |
| 569 | /// whenever the input is next wired in. |
| 570 | var rebuildInput: () -> Void |
| 571 | /// For the log: what the engine module lists, beside CoreAudio. |
| 572 | var logModuleLists: () -> Void |
| 573 | /// The input rate the engine last started with (nil: not recording). |
| 574 | var inputRate: () -> Double? |
| 575 | var isRecording: () -> Bool |
| 576 | /// Where the engine would be put now, and where it last was. |
| 577 | var pinned: () -> (target: String, applied: String, isPair: Bool) |
| 578 | |
| 579 | init( |
| 580 | inventory: @escaping () -> AudioInventory, |
| 581 | pin: @escaping (_ input: String?, _ output: String?) -> Bool = { _, _ in false }, |
| 582 | choices: @escaping () -> (input: String?, output: String?) = { (nil, nil) }, |
| 583 | rebuild: @escaping () -> Void = {}, |
| 584 | channels: @escaping (_ uid: String?) -> InputDeviceChannels? = { _ in nil }, |
| 585 | rebuildInput: @escaping () -> Void = {}, |
| 586 | logModuleLists: @escaping () -> Void = {}, |
| 587 | inputRate: @escaping () -> Double? = { nil }, |
| 588 | isRecording: @escaping () -> Bool = { false }, |
| 589 | pinned: @escaping () -> (target: String, applied: String, isPair: Bool) = { ("-", "-", false) } |
| 590 | ) { |
| 591 | self.inventory = inventory |
| 592 | self.pin = pin |
| 593 | self.choices = choices |
| 594 | self.rebuild = rebuild |
| 595 | self.channels = channels |
| 596 | self.rebuildInput = rebuildInput |
| 597 | self.logModuleLists = logModuleLists |
| 598 | self.inputRate = inputRate |
| 599 | self.isRecording = isRecording |
| 600 | self.pinned = pinned |
| 601 | } |
| 602 | |
| 603 | /// The module is only ever asked on the audio queue; the main thread |
| 604 | /// reads its state from `state` (ModuleState), never from the module. |
| 605 | init( |
| 606 | _ module: RTCAudioDeviceModule, state: ModuleState, pin enginePin: AudioEnginePin, |
| 607 | inputRate: @escaping () -> Double? |
| 608 | ) { |
| 609 | inventory = CoreAudioDevices.inventory |
| 610 | self.inputRate = inputRate |
| 611 | isRecording = { state.read().isRecording } |
| 612 | pinned = { |
| 613 | let target = enginePin.target() |
| 614 | if case .pair = target { |
| 615 | return (target.description, enginePin.lastApplied?.description ?? "none yet", true) |
| 616 | } |
| 617 | return (target.description, enginePin.lastApplied?.description ?? "none yet", false) |
| 618 | } |
| 619 | choices = { enginePin.choices } |
| 620 | // A running engine is rebuilt when what it would be put on now is |
| 621 | // not what it was put on. Before an engine exists (the module makes |
| 622 | // one for the first connection) there is nothing to move: the first |
| 623 | // engine is made on the choice. |
| 624 | // A rebuild runs on the audio queue, so `lastApplied` lags the ask by |
| 625 | // the time it takes; a second look in between (CoreAudio's device |
| 626 | // list changes as the old aggregate goes) must not ask again. |
| 627 | let asked = OSAllocatedUnfairLock<EngineTarget?>(initialState: nil) |
| 628 | pin = { input, output in |
| 629 | _ = enginePin.set(input: input, output: output) |
| 630 | let module = state.read() |
| 631 | guard !module.isManualRenderingMode, module.isPlaying || module.isRecording, |
| 632 | let applied = enginePin.lastApplied |
| 633 | else { return false } |
| 634 | let target = enginePin.target() |
| 635 | guard target != applied else { |
| 636 | // Caught up: the next move, even back to a target asked for |
| 637 | // before (a device that failed, chosen again), is a new ask. |
| 638 | asked.withLock { $0 = nil } |
| 639 | return false |
| 640 | } |
| 641 | return asked.withLock { last in |
| 642 | guard last != target else { return false } |
| 643 | last = target |
| 644 | return true |
| 645 | } |
| 646 | } |
| 647 | // The module has no "rebuild" call. Taking both sides away releases |
| 648 | // its engine; giving them back makes a new one, and the pin is |
| 649 | // applied to it in the "will enable" hook. |
| 650 | // On the audio queue, as every engine start is (AudioWork): a rebuild |
| 651 | // onto a Bluetooth microphone waits for the headset to change |
| 652 | // profile, and the main thread must not wait with it. |
| 653 | rebuild = { [weak module] in |
| 654 | guard let module else { return } |
| 655 | AudioWork.post { |
| 656 | guard !module.isManualRenderingMode else { return } |
| 657 | let saved = module.engineAvailability |
| 658 | _ = module.setEngineAvailability( |
| 659 | RTCAudioEngineAvailability(isInputAvailable: false, isOutputAvailable: false)) |
| 660 | let result = module.setEngineAvailability(saved) |
| 661 | AudioLog.info("engine rebuilt (result \(result), running \(module.isEngineRunning))") |
| 662 | } |
| 663 | } |
| 664 | channels = { uid in CoreAudioInput.device(forModuleID: uid).flatMap(CoreAudioInput.describe) } |
| 665 | // Taking the input away and giving it back rewires the microphone, |
| 666 | // asking the observer first — which is where the map is applied. |
| 667 | // (Re-selecting the same device does nothing: the module ignores a |
| 668 | // state that has not changed.) |
| 669 | rebuildInput = { [weak module] in |
| 670 | guard let module else { return } |
| 671 | AudioWork.post { |
| 672 | guard module.isRecording, !module.isManualRenderingMode else { return } |
| 673 | _ = module.setEngineAvailability( |
| 674 | RTCAudioEngineAvailability(isInputAvailable: false, isOutputAvailable: true)) |
| 675 | _ = module.setEngineAvailability( |
| 676 | RTCAudioEngineAvailability(isInputAvailable: true, isOutputAvailable: true)) |
| 677 | } |
| 678 | } |
| 679 | // Off the main thread: the module's first list is made on WebRTC's |
| 680 | // worker thread, 61–68 ms measured, only to be logged. |
| 681 | logModuleLists = { [weak module] in |
| 682 | guard let module else { return } |
| 683 | AudioWork.post { |
| 684 | func line(_ list: [RTCIODevice]) -> String { |
| 685 | list.map { "\($0.name) [\($0.deviceId)]" }.joined(separator: "; ") |
| 686 | } |
| 687 | AudioLog.info( |
| 688 | "engine module lists (not used to choose): inputs \(line(module.inputDevices)); outputs \(line(module.outputDevices))" |
| 689 | ) |
| 690 | } |
| 691 | } |
| 692 | } |
| 693 | } |
| 694 | #endif |
| 695 | |