oficina / meet Public

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

Plain git — no account needed to clone.

Download

Download this file

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