oficina / meet Public

Clone
git clone https://amadan.net/oficina/meet

Plain git — no account needed to clone.

Download

Download this file

1import AVFoundation
2import Foundation
3import os
4#if os(macOS)
5 import CoreAudio
6#endif
7
8import LiveKitWebRTC
9
10/// One audio device the person can be on.
11public 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