| 1 | #if os(macOS) |
| 2 | import AudioToolbox |
| 3 | import AVFoundation |
| 4 | import CoreAudio |
| 5 | import Foundation |
| 6 | import os |
| 7 | |
| 8 | // Which devices a Mac call's audio runs on, and how the call is put on |
| 9 | // them. |
| 10 | // |
| 11 | // Calls 0.3.0 to 0.3.6 chose devices through LiveKit's engine module |
| 12 | // (`inputDevice = …`). Paul's Scarlett 18i8 was not picked up at launch |
| 13 | // and would not stay chosen (2026-09-25). Read from the module's source |
| 14 | // (webrtc-sdk m150_release, audio_engine_device.mm and |
| 15 | // RTCAudioDeviceModule.mm) and measured on a Mac, that route has four |
| 16 | // faults, any one of which loses the Scarlett: |
| 17 | // |
| 18 | // 1. The module lists only "default" until the first peer connection |
| 19 | // initialises it, and says nothing when it does (no device-update |
| 20 | // callback). The app attached to it at launch, found its remembered |
| 21 | // Scarlett missing from a one-entry list, said "Scarlett 18i8 USB |
| 22 | // isn't connected; using …" and put the call on the default. |
| 23 | // 2. Its list drops any device whose input streams have an unknown |
| 24 | // terminal type while it also has outputs, and every virtual or |
| 25 | // aggregate device, so an interface can be missing from it for good. |
| 26 | // 3. It wires the microphone in with the *old* device's format and only |
| 27 | // then moves the engine to the chosen device, so a device at another |
| 28 | // sample rate starts silent or not at all — and a start that fails |
| 29 | // releases the engine it had, leaving the call with no audio. |
| 30 | // 4. `trySetInputDevice` answers YES on failure and NO on success. |
| 31 | // |
| 32 | // So the module is left on its default and never asked to choose. The |
| 33 | // app lists devices from CoreAudio itself (ready at launch), and puts the |
| 34 | // engine on the chosen device from the module's own "will enable" hook, |
| 35 | // before the module reads any format: `AudioEnginePin`. On a Mac the |
| 36 | // engine's input and output are one audio unit, so a microphone and a |
| 37 | // speaker that are different devices are joined in a private aggregate |
| 38 | // device, as macOS itself does for the two system defaults. |
| 39 | |
| 40 | // The audio log (AudioLog) is in AudioDiagnostics.swift. |
| 41 | |
| 42 | /// One CoreAudio device, as the routing rules need it. |
| 43 | struct SystemAudioDevice: Equatable, Sendable { |
| 44 | var uid: String |
| 45 | var name: String |
| 46 | var inputChannels: Int |
| 47 | var outputChannels: Int |
| 48 | var sampleRate: Double = 0 |
| 49 | var transport: String = "" |
| 50 | |
| 51 | var describe: String { |
| 52 | "\(name) [\(uid)] in=\(inputChannels) out=\(outputChannels) \(Int(sampleRate)) Hz \(transport)" |
| 53 | } |
| 54 | } |
| 55 | |
| 56 | /// What is connected, and which devices macOS has as its defaults. |
| 57 | struct AudioInventory: Equatable, Sendable { |
| 58 | var devices: [SystemAudioDevice] = [] |
| 59 | var defaultInput: String? |
| 60 | var defaultOutput: String? |
| 61 | |
| 62 | var inputs: [SystemAudioDevice] { devices.filter { $0.inputChannels > 0 } } |
| 63 | var outputs: [SystemAudioDevice] { devices.filter { $0.outputChannels > 0 } } |
| 64 | func list(_ input: Bool) -> [SystemAudioDevice] { input ? inputs : outputs } |
| 65 | func systemDefault(_ input: Bool) -> SystemAudioDevice? { |
| 66 | let uid = input ? defaultInput : defaultOutput |
| 67 | return devices.first { $0.uid == uid } |
| 68 | } |
| 69 | } |
| 70 | |
| 71 | /// CoreAudio, read. Property queries only: nothing here opens a device |
| 72 | /// or needs microphone permission. |
| 73 | enum CoreAudioDevices { |
| 74 | /// Private aggregates this app makes (AudioEnginePin), and the one |
| 75 | /// AVAudioEngine makes of the two system defaults. Neither is a device |
| 76 | /// anyone chose. |
| 77 | static let ownAggregatePrefix = "net.amadan.oficina.calls.pair." |
| 78 | static func isInternal(uid: String) -> Bool { |
| 79 | uid.hasPrefix(ownAggregatePrefix) || uid.hasPrefix("CADefaultDeviceAggregate") |
| 80 | } |
| 81 | |
| 82 | static func inventory() -> AudioInventory { |
| 83 | var list: [SystemAudioDevice] = [] |
| 84 | for id in allDeviceIDs() { |
| 85 | guard let uid = CoreAudioInput.string(id, kAudioDevicePropertyDeviceUID), |
| 86 | !isInternal(uid: uid), !isHidden(id) |
| 87 | else { continue } |
| 88 | let device = SystemAudioDevice( |
| 89 | uid: uid, name: CoreAudioInput.string(id, kAudioObjectPropertyName) ?? uid, |
| 90 | inputChannels: channels(id, input: true), outputChannels: channels(id, input: false), |
| 91 | sampleRate: sampleRate(id), transport: transport(id)) |
| 92 | if device.inputChannels > 0 || device.outputChannels > 0 { list.append(device) } |
| 93 | } |
| 94 | return AudioInventory( |
| 95 | devices: list, |
| 96 | defaultInput: defaultDevice(input: true).flatMap { CoreAudioInput.string($0, kAudioDevicePropertyDeviceUID) }, |
| 97 | defaultOutput: defaultDevice(input: false).flatMap { CoreAudioInput.string($0, kAudioDevicePropertyDeviceUID) }) |
| 98 | } |
| 99 | |
| 100 | static func allDeviceIDs() -> [AudioDeviceID] { |
| 101 | var address = AudioObjectPropertyAddress( |
| 102 | mSelector: kAudioHardwarePropertyDevices, mScope: kAudioObjectPropertyScopeGlobal, |
| 103 | mElement: kAudioObjectPropertyElementMain) |
| 104 | var size: UInt32 = 0 |
| 105 | guard |
| 106 | AudioObjectGetPropertyDataSize(AudioObjectID(kAudioObjectSystemObject), &address, 0, nil, &size) |
| 107 | == noErr, size > 0 |
| 108 | else { return [] } |
| 109 | var ids = [AudioDeviceID](repeating: 0, count: Int(size) / MemoryLayout<AudioDeviceID>.size) |
| 110 | guard |
| 111 | AudioObjectGetPropertyData(AudioObjectID(kAudioObjectSystemObject), &address, 0, nil, &size, &ids) |
| 112 | == noErr |
| 113 | else { return [] } |
| 114 | return ids |
| 115 | } |
| 116 | |
| 117 | static func defaultDevice(input: Bool) -> AudioDeviceID? { |
| 118 | input ? CoreAudioInput.defaultInput() : defaultOutput() |
| 119 | } |
| 120 | |
| 121 | static func defaultOutput() -> AudioDeviceID? { |
| 122 | var address = AudioObjectPropertyAddress( |
| 123 | mSelector: kAudioHardwarePropertyDefaultOutputDevice, |
| 124 | mScope: kAudioObjectPropertyScopeGlobal, mElement: kAudioObjectPropertyElementMain) |
| 125 | var device = AudioDeviceID(kAudioObjectUnknown) |
| 126 | var size = UInt32(MemoryLayout<AudioDeviceID>.size) |
| 127 | let status = AudioObjectGetPropertyData( |
| 128 | AudioObjectID(kAudioObjectSystemObject), &address, 0, nil, &size, &device) |
| 129 | return status == noErr && device != kAudioObjectUnknown ? device : nil |
| 130 | } |
| 131 | |
| 132 | static func channels(_ device: AudioDeviceID, input: Bool) -> Int { |
| 133 | var address = AudioObjectPropertyAddress( |
| 134 | mSelector: kAudioDevicePropertyStreamConfiguration, |
| 135 | mScope: input ? kAudioObjectPropertyScopeInput : kAudioObjectPropertyScopeOutput, |
| 136 | mElement: kAudioObjectPropertyElementMain) |
| 137 | var size: UInt32 = 0 |
| 138 | guard AudioObjectGetPropertyDataSize(device, &address, 0, nil, &size) == noErr, size > 0 |
| 139 | else { return 0 } |
| 140 | let raw = UnsafeMutableRawPointer.allocate( |
| 141 | byteCount: Int(size), alignment: MemoryLayout<AudioBufferList>.alignment) |
| 142 | defer { raw.deallocate() } |
| 143 | let list = raw.bindMemory(to: AudioBufferList.self, capacity: 1) |
| 144 | guard AudioObjectGetPropertyData(device, &address, 0, nil, &size, list) == noErr else { return 0 } |
| 145 | return InputChannelMap.count(UnsafeMutableAudioBufferListPointer(list)) |
| 146 | } |
| 147 | |
| 148 | static func uint32(_ device: AudioDeviceID, _ selector: AudioObjectPropertySelector) -> UInt32? { |
| 149 | var address = AudioObjectPropertyAddress( |
| 150 | mSelector: selector, mScope: kAudioObjectPropertyScopeGlobal, mElement: kAudioObjectPropertyElementMain) |
| 151 | guard AudioObjectHasProperty(device, &address) else { return nil } |
| 152 | var value: UInt32 = 0 |
| 153 | var size = UInt32(MemoryLayout<UInt32>.size) |
| 154 | return AudioObjectGetPropertyData(device, &address, 0, nil, &size, &value) == noErr ? value : nil |
| 155 | } |
| 156 | |
| 157 | static func isHidden(_ device: AudioDeviceID) -> Bool { |
| 158 | uint32(device, kAudioDevicePropertyIsHidden) == 1 |
| 159 | } |
| 160 | |
| 161 | static func isAlive(_ device: AudioDeviceID) -> Bool { |
| 162 | uint32(device, kAudioDevicePropertyDeviceIsAlive) != 0 |
| 163 | } |
| 164 | |
| 165 | static func sampleRate(_ device: AudioDeviceID) -> Double { |
| 166 | var address = AudioObjectPropertyAddress( |
| 167 | mSelector: kAudioDevicePropertyNominalSampleRate, mScope: kAudioObjectPropertyScopeGlobal, |
| 168 | mElement: kAudioObjectPropertyElementMain) |
| 169 | var rate: Float64 = 0 |
| 170 | var size = UInt32(MemoryLayout<Float64>.size) |
| 171 | return AudioObjectGetPropertyData(device, &address, 0, nil, &size, &rate) == noErr ? rate : 0 |
| 172 | } |
| 173 | |
| 174 | static func transport(_ device: AudioDeviceID) -> String { |
| 175 | guard let type = uint32(device, kAudioDevicePropertyTransportType) else { return "" } |
| 176 | switch type { |
| 177 | case kAudioDeviceTransportTypeBuiltIn: return "built-in" |
| 178 | case kAudioDeviceTransportTypeUSB: return "usb" |
| 179 | case kAudioDeviceTransportTypeBluetooth, kAudioDeviceTransportTypeBluetoothLE: return "bluetooth" |
| 180 | case kAudioDeviceTransportTypeVirtual: return "virtual" |
| 181 | case kAudioDeviceTransportTypeAggregate: return "aggregate" |
| 182 | case kAudioDeviceTransportTypeThunderbolt: return "thunderbolt" |
| 183 | case kAudioDeviceTransportTypeHDMI, kAudioDeviceTransportTypeDisplayPort: return "display" |
| 184 | case kAudioDeviceTransportTypeAirPlay: return "airplay" |
| 185 | case kAudioDeviceTransportTypeContinuityCaptureWired, kAudioDeviceTransportTypeContinuityCaptureWireless: |
| 186 | return "continuity" |
| 187 | default: return String(format: "0x%08x", type) |
| 188 | } |
| 189 | } |
| 190 | |
| 191 | static func uid(_ device: AudioDeviceID) -> String? { |
| 192 | CoreAudioInput.string(device, kAudioDevicePropertyDeviceUID) |
| 193 | } |
| 194 | |
| 195 | /// The device an audio unit is on. |
| 196 | static func current(of unit: AudioUnit) -> AudioDeviceID? { CoreAudioInput.device(of: unit) } |
| 197 | |
| 198 | static func setCurrent(_ device: AudioDeviceID, on unit: AudioUnit) -> OSStatus { |
| 199 | var device = device |
| 200 | return AudioUnitSetProperty( |
| 201 | unit, kAudioOutputUnitProperty_CurrentDevice, kAudioUnitScope_Global, 0, &device, |
| 202 | UInt32(MemoryLayout<AudioDeviceID>.size)) |
| 203 | } |
| 204 | } |
| 205 | |
| 206 | /// A remembered choice matched against what is connected, and what the |
| 207 | /// call does about it. Pure: the tests hold every case of it. |
| 208 | struct RouteResolution: Equatable, Sendable { |
| 209 | /// The picker's selection: "default", or the chosen device's UID. |
| 210 | var current: String |
| 211 | /// The device the choice asks the engine for; nil follows the system |
| 212 | /// default. |
| 213 | var explicit: String? |
| 214 | /// Said when the choice cannot be honoured. |
| 215 | var note: String? |
| 216 | /// A preference to store in place of the old one: the device came back |
| 217 | /// under a new UID (a USB interface on another port) and was found by |
| 218 | /// its name. |
| 219 | var remember: AudioPreference? |
| 220 | /// Why, for the log. |
| 221 | var reason: String |
| 222 | } |
| 223 | |
| 224 | enum AudioRouteRules { |
| 225 | static let defaultID = "default" |
| 226 | |
| 227 | /// - Parameters: |
| 228 | /// - failed: devices the engine could not start on, by UID, with why. |
| 229 | static func resolve( |
| 230 | _ preference: AudioPreference?, _ inventory: AudioInventory, input: Bool, |
| 231 | failed: [String: String] = [:] |
| 232 | ) -> RouteResolution { |
| 233 | guard let preference, !preference.id.isEmpty, preference.id != defaultID else { |
| 234 | return RouteResolution( |
| 235 | current: defaultID, explicit: nil, note: nil, |
| 236 | reason: preference == nil ? "nothing chosen: system default" : "system default chosen") |
| 237 | } |
| 238 | let list = inventory.list(input) |
| 239 | var device = list.first { $0.uid == preference.id } |
| 240 | var remember: AudioPreference? |
| 241 | var how = "by UID" |
| 242 | if device == nil, !preference.name.isEmpty { |
| 243 | let named = list.filter { $0.name == preference.name } |
| 244 | if named.count == 1 { |
| 245 | device = named[0] |
| 246 | remember = AudioPreference(id: named[0].uid, name: named[0].name) |
| 247 | how = "by name (UID was \(preference.id))" |
| 248 | } |
| 249 | } |
| 250 | let what = preference.name.isEmpty ? "The \(input ? "microphone" : "speaker") you chose" : preference.name |
| 251 | guard let device else { |
| 252 | return RouteResolution( |
| 253 | current: defaultID, explicit: nil, |
| 254 | note: note("\(what) isn’t connected", fallback: inventory.systemDefault(input), missing: what, input: input), |
| 255 | reason: "remembered \(preference.id) (\(preference.name)) is not in CoreAudio's \(input ? "input" : "output") list") |
| 256 | } |
| 257 | if let why = failed[device.uid] { |
| 258 | return RouteResolution( |
| 259 | current: defaultID, explicit: nil, |
| 260 | note: note("Calls couldn’t open \(device.name)", fallback: inventory.systemDefault(input), missing: device.name, input: input), |
| 261 | remember: remember, reason: "\(device.uid) failed to start: \(why)") |
| 262 | } |
| 263 | return RouteResolution( |
| 264 | current: device.uid, explicit: device.uid, note: nil, remember: remember, |
| 265 | reason: "remembered device connected, matched \(how)") |
| 266 | } |
| 267 | |
| 268 | /// "Scarlett 18i8 USB isn’t connected; using MacBook Pro Microphone." |
| 269 | /// Never names the missing device as the one in use: when the default |
| 270 | /// has the same name (a second unit of the same model), it is called |
| 271 | /// the Mac's default instead. |
| 272 | static func note(_ head: String, fallback: SystemAudioDevice?, missing: String, input: Bool) -> String { |
| 273 | let generic = "the Mac’s default \(input ? "microphone" : "speaker")" |
| 274 | guard let fallback, !fallback.name.isEmpty, fallback.name != missing else { |
| 275 | return "\(head); using \(generic)." |
| 276 | } |
| 277 | return "\(head); using \(fallback.name)." |
| 278 | } |
| 279 | } |
| 280 | |
| 281 | /// When a chosen microphone's change of rate rebuilds the engine. Pure; |
| 282 | /// the tests hold it. |
| 283 | enum RateRule { |
| 284 | static let minimumGap: Double = 3 |
| 285 | |
| 286 | /// - Parameters: |
| 287 | /// - engineRate: the input rate the running engine was wired at |
| 288 | /// (nil: it is not capturing, and the next start reads the rate |
| 289 | /// afresh). |
| 290 | /// - pair: the engine is on one of our aggregates, whose clock is |
| 291 | /// the speaker's, so its input rate says nothing about the |
| 292 | /// microphone's: any change of the microphone's rate rebuilds it. |
| 293 | static func rebuild( |
| 294 | deviceRate: Double, engineRate: Double?, recording: Bool, pair: Bool, lastRebuild: Double, now: Double |
| 295 | ) -> Bool { |
| 296 | guard recording, let engineRate, deviceRate > 0, now - lastRebuild >= minimumGap else { return false } |
| 297 | return pair || abs(engineRate - deviceRate) >= 1 |
| 298 | } |
| 299 | } |
| 300 | |
| 301 | /// Where the engine's one audio unit should be: left to AVAudioEngine's |
| 302 | /// own default, on one device, or on a pair joined in an aggregate. |
| 303 | enum EngineTarget: Equatable, Sendable, CustomStringConvertible { |
| 304 | case systemDefault |
| 305 | case device(String) |
| 306 | case pair(input: String, output: String) |
| 307 | |
| 308 | /// Planned as for a call that both plays and records, even while the |
| 309 | /// engine only plays: the module makes its engine for playout first and |
| 310 | /// adds the microphone to that same engine later, and a unit moved to |
| 311 | /// another device in between would keep the format it was wired with. |
| 312 | /// |
| 313 | /// - Parameters: |
| 314 | /// - input/output: the explicit choices (nil follows the default). |
| 315 | static func plan(input: String?, output: String?, defaultInput: String?, defaultOutput: String?) |
| 316 | -> EngineTarget |
| 317 | { |
| 318 | guard input != nil || output != nil else { return .systemDefault } |
| 319 | switch (input ?? defaultInput, output ?? defaultOutput) { |
| 320 | case (let i?, let o?): return i == o ? .device(i) : .pair(input: i, output: o) |
| 321 | case (let i?, nil): return .device(i) |
| 322 | case (nil, let o?): return .device(o) |
| 323 | case (nil, nil): return .systemDefault |
| 324 | } |
| 325 | } |
| 326 | |
| 327 | var description: String { |
| 328 | switch self { |
| 329 | case .systemDefault: return "system default" |
| 330 | case .device(let uid): return uid |
| 331 | case .pair(let i, let o): return "\(i) + \(o)" |
| 332 | } |
| 333 | } |
| 334 | } |
| 335 | |
| 336 | /// The chosen devices, handed from the main actor to the module's |
| 337 | /// thread, and applied to each engine the module makes before it reads a |
| 338 | /// format from it. Also where the engine's actual device is read back. |
| 339 | final class AudioEnginePin: @unchecked Sendable { |
| 340 | private let lock = NSLock() |
| 341 | private var input: String? |
| 342 | private var output: String? |
| 343 | private var aggregate: (key: EngineTarget, id: AudioDeviceID, uid: String, offset: Int, outputOffset: Int)? |
| 344 | /// What the last engine was put on, and whether that was an explicit |
| 345 | /// choice. Read on the main actor to tell whether a rebuild is due. |
| 346 | private var applied: EngineTarget? |
| 347 | |
| 348 | /// Returns true when the choice changed. |
| 349 | func set(input: String?, output: String?) -> Bool { |
| 350 | lock.lock() |
| 351 | defer { lock.unlock() } |
| 352 | guard input != self.input || output != self.output else { return false } |
| 353 | self.input = input |
| 354 | self.output = output |
| 355 | return true |
| 356 | } |
| 357 | |
| 358 | var choices: (input: String?, output: String?) { |
| 359 | lock.lock() |
| 360 | defer { lock.unlock() } |
| 361 | return (input, output) |
| 362 | } |
| 363 | |
| 364 | var lastApplied: EngineTarget? { |
| 365 | lock.lock() |
| 366 | defer { lock.unlock() } |
| 367 | return applied |
| 368 | } |
| 369 | |
| 370 | /// What putting an engine on its devices came to. |
| 371 | enum Outcome: Equatable { |
| 372 | case applied(EngineTarget) |
| 373 | /// The devices could not be set; the engine is on the default. |
| 374 | case failed(EngineTarget, String) |
| 375 | } |
| 376 | |
| 377 | /// The target for the choices as they stand, against the defaults as |
| 378 | /// they stand. Devices that have gone are left out, so a microphone |
| 379 | /// unplugged a moment ago is not asked for. |
| 380 | func target() -> EngineTarget { |
| 381 | let (input, output) = choices |
| 382 | return EngineTarget.plan( |
| 383 | input: input.flatMap(Self.alive), output: output.flatMap(Self.alive), |
| 384 | defaultInput: CoreAudioDevices.defaultDevice(input: true).flatMap(CoreAudioDevices.uid), |
| 385 | defaultOutput: CoreAudioDevices.defaultDevice(input: false).flatMap(CoreAudioDevices.uid)) |
| 386 | } |
| 387 | |
| 388 | /// Called from the module's "will enable" hook, before it reads the |
| 389 | /// engine's formats and wires anything. |
| 390 | func apply(to engine: AVAudioEngine, recording: Bool) -> Outcome { |
| 391 | let target = target() |
| 392 | lock.lock() |
| 393 | applied = target |
| 394 | lock.unlock() |
| 395 | guard target != .systemDefault else { return .applied(target) } |
| 396 | func fail(_ why: String) -> Outcome { |
| 397 | lock.lock() |
| 398 | applied = .systemDefault |
| 399 | lock.unlock() |
| 400 | return .failed(target, why) |
| 401 | } |
| 402 | guard let device = resolve(target) else { return fail("no device could be made for it") } |
| 403 | // Input and output are one unit on a Mac; asking for `inputNode` |
| 404 | // makes an input, so it is only asked for when recording. |
| 405 | guard let unit = recording ? engine.inputNode.audioUnit : engine.outputNode.audioUnit else { |
| 406 | return fail("the engine has no audio unit") |
| 407 | } |
| 408 | let status = CoreAudioDevices.setCurrent(device, on: unit) |
| 409 | return status == noErr ? .applied(target) : fail("setting the device failed, OSStatus \(status)") |
| 410 | } |
| 411 | |
| 412 | /// The input device behind a device an engine is on, and where its |
| 413 | /// channels start on it (0: our aggregates put the microphone first). |
| 414 | /// Nil for a device that is not one of ours. |
| 415 | func inputLayout(of device: AudioDeviceID) -> (uid: String, offset: Int)? { |
| 416 | lock.lock() |
| 417 | defer { lock.unlock() } |
| 418 | guard let aggregate, aggregate.id == device, case .pair(let input, _) = aggregate.key else { return nil } |
| 419 | return (input, aggregate.offset) |
| 420 | } |
| 421 | |
| 422 | /// The microphone the engine is actually on, read back from its unit: |
| 423 | /// the device itself, the microphone half of one of our aggregates, or |
| 424 | /// the system default input under macOS's own aggregate. |
| 425 | func actualInput(of unit: AudioUnit) -> String? { |
| 426 | guard let device = CoreAudioDevices.current(of: unit), let uid = CoreAudioDevices.uid(device) else { |
| 427 | return nil |
| 428 | } |
| 429 | if let layout = inputLayout(of: device) { return layout.uid } |
| 430 | if CoreAudioDevices.isInternal(uid: uid) { |
| 431 | return CoreAudioDevices.defaultDevice(input: true).flatMap(CoreAudioDevices.uid) |
| 432 | } |
| 433 | return uid |
| 434 | } |
| 435 | |
| 436 | private static func alive(_ uid: String) -> String? { |
| 437 | guard let id = CoreAudioInput.device(uid: uid), CoreAudioDevices.isAlive(id) else { return nil } |
| 438 | return uid |
| 439 | } |
| 440 | |
| 441 | private func resolve(_ target: EngineTarget) -> AudioDeviceID? { |
| 442 | switch target { |
| 443 | case .systemDefault: return nil |
| 444 | case .device(let uid): return CoreAudioInput.device(uid: uid) |
| 445 | case .pair(let input, let output): |
| 446 | lock.lock() |
| 447 | if let aggregate, aggregate.key == target, CoreAudioDevices.isAlive(aggregate.id) { |
| 448 | lock.unlock() |
| 449 | return aggregate.id |
| 450 | } |
| 451 | let old = aggregate |
| 452 | aggregate = nil |
| 453 | lock.unlock() |
| 454 | if let old { AudioHardwareDestroyAggregateDevice(old.id) } |
| 455 | guard let made = Self.makePair(input: input, output: output) else { return nil } |
| 456 | lock.lock() |
| 457 | aggregate = (target, made.id, made.uid, made.offset, made.outputOffset) |
| 458 | lock.unlock() |
| 459 | return made.id |
| 460 | } |
| 461 | } |
| 462 | |
| 463 | /// A private aggregate of a microphone and a speaker. The microphone |
| 464 | /// goes first: AVAudioEngine refuses an aggregate whose first device |
| 465 | /// has no input ("IsFormatSampleRateAndChannelCountValid(outputHWFormat)", |
| 466 | /// measured on macOS 26 with the MacBook's own two), and its inputs then |
| 467 | /// start at channel 1. The speaker's clock leads, with the microphone |
| 468 | /// drift-corrected to it. A microphone with outputs of its own (an |
| 469 | /// interface) puts those ahead of the speaker's, so the engine's output |
| 470 | /// is mapped past them (`outputOffset`, applied when it starts). |
| 471 | private static func makePair(input: String, output: String) |
| 472 | -> (id: AudioDeviceID, uid: String, offset: Int, outputOffset: Int)? |
| 473 | { |
| 474 | let uid = CoreAudioDevices.ownAggregatePrefix + UUID().uuidString |
| 475 | let description: [String: Any] = [ |
| 476 | kAudioAggregateDeviceNameKey: "Calls", |
| 477 | kAudioAggregateDeviceUIDKey: uid, |
| 478 | kAudioAggregateDeviceIsPrivateKey: 1, |
| 479 | kAudioAggregateDeviceMainSubDeviceKey: output, |
| 480 | kAudioAggregateDeviceSubDeviceListKey: [ |
| 481 | [kAudioSubDeviceUIDKey: input, kAudioSubDeviceDriftCompensationKey: 1], |
| 482 | [kAudioSubDeviceUIDKey: output], |
| 483 | ], |
| 484 | ] |
| 485 | var id = AudioDeviceID(kAudioObjectUnknown) |
| 486 | let status = AudioHardwareCreateAggregateDevice(description as CFDictionary, &id) |
| 487 | guard status == noErr, id != kAudioObjectUnknown else { |
| 488 | AudioLog.error("aggregate of \(input) + \(output) refused, OSStatus \(status)") |
| 489 | return nil |
| 490 | } |
| 491 | let outputOffset = CoreAudioInput.device(uid: input).map { CoreAudioDevices.channels($0, input: false) } ?? 0 |
| 492 | AudioLog.info( |
| 493 | "aggregate \(uid): in \(input), out \(output) from channel \(outputOffset + 1); in=\(CoreAudioDevices.channels(id, input: true)) out=\(CoreAudioDevices.channels(id, input: false))") |
| 494 | return (id, uid, 0, outputOffset) |
| 495 | } |
| 496 | |
| 497 | /// Sends the engine's output to the speaker's channels of one of our |
| 498 | /// aggregates when the microphone's own outputs come first. Called as |
| 499 | /// the engine starts, once its output format is set. |
| 500 | func mapOutput(on unit: AudioUnit) { |
| 501 | guard let device = CoreAudioDevices.current(of: unit) else { return } |
| 502 | lock.lock() |
| 503 | let offset = aggregate.flatMap { $0.id == device ? $0.outputOffset : nil } ?? 0 |
| 504 | lock.unlock() |
| 505 | guard offset > 0 else { return } |
| 506 | var format = AudioStreamBasicDescription() |
| 507 | var size = UInt32(MemoryLayout<AudioStreamBasicDescription>.size) |
| 508 | guard AudioUnitGetProperty(unit, kAudioUnitProperty_StreamFormat, kAudioUnitScope_Input, 0, &format, &size) == noErr |
| 509 | else { return } |
| 510 | let total = CoreAudioDevices.channels(device, input: false) |
| 511 | let client = Int(format.mChannelsPerFrame) |
| 512 | var map = [Int32](repeating: -1, count: total) |
| 513 | for k in 0..<client where offset + k < total { map[offset + k] = Int32(k) } |
| 514 | let status = AudioUnitSetProperty( |
| 515 | unit, kAudioOutputUnitProperty_ChannelMap, kAudioUnitScope_Input, 0, &map, |
| 516 | UInt32(MemoryLayout<Int32>.size * map.count)) |
| 517 | AudioLog.info("output channel map past \(offset) microphone outputs: OSStatus \(status)") |
| 518 | } |
| 519 | |
| 520 | /// The UID of the aggregate this pin has made, if it has one now. |
| 521 | var aggregateUID: String? { |
| 522 | lock.lock() |
| 523 | defer { lock.unlock() } |
| 524 | return aggregate?.uid |
| 525 | } |
| 526 | |
| 527 | /// The aggregate goes with the call's audio. |
| 528 | func close() { |
| 529 | lock.lock() |
| 530 | let old = aggregate |
| 531 | aggregate = nil |
| 532 | lock.unlock() |
| 533 | if let old { AudioHardwareDestroyAggregateDevice(old.id) } |
| 534 | } |
| 535 | |
| 536 | deinit { close() } |
| 537 | } |
| 538 | #endif |
| 539 | |