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 XCTest
3 @testable import CallsKit
4
5 /// The Mac's remembered routing rules, held against a stand-in for
6 /// CoreAudio and the engine so no microphone is opened. The module
7 /// itself follows the system default and sample-rate changes; these hold
8 /// what AudioRoutes adds on top of it.
9 @MainActor final class CallAudioSelectionTests: XCTestCase {
10 /// CoreAudio and the engine, as far as the rules can see them.
11 private final class Machine {
12 var devices: [SystemAudioDevice] = [
13 SystemAudioDevice(uid: "BuiltInMicrophoneDevice", name: "MacBook Pro Microphone", inputChannels: 1, outputChannels: 0),
14 SystemAudioDevice(uid: "BuiltInSpeakerDevice", name: "MacBook Pro Speakers", inputChannels: 0, outputChannels: 2),
15 SystemAudioDevice(uid: "usb", name: "USB Mic", inputChannels: 1, outputChannels: 0),
16 SystemAudioDevice(uid: "headset", name: "Headset", inputChannels: 0, outputChannels: 2),
17 ]
18 var defaultInput = "BuiltInMicrophoneDevice"
19 var defaultOutput = "BuiltInSpeakerDevice"
20 /// What the engine was last asked for; the engine is "running" when set.
21 var pinned: (input: String?, output: String?) = (nil, nil)
22 var running = false
23 var applied: EngineTarget = .systemDefault
24 var rebuilds = 0
25 /// What the engine module lists: only "default" until a connection
26 /// has initialised it, as LiveKit's does. Never used to choose.
27 var moduleList = ["default"]
28
29 static let scarlett = SystemAudioDevice(
30 uid: "AppleUSBAudioEngine:Focusrite:Scarlett 18i8 USB:0001:1,2", name: "Scarlett 18i8 USB",
31 inputChannels: 18, outputChannels: 8, sampleRate: 44_100, transport: "usb")
32
33 var inventory: AudioInventory {
34 AudioInventory(devices: devices, defaultInput: defaultInput, defaultOutput: defaultOutput)
35 }
36 func target() -> EngineTarget {
37 EngineTarget.plan(
38 input: pinned.input, output: pinned.output, defaultInput: defaultInput, defaultOutput: defaultOutput)
39 }
40
41 func control() -> AudioEngineControl {
42 AudioEngineControl(
43 inventory: { self.inventory },
44 pin: { input, output in
45 self.pinned = (input, output)
46 return self.running && self.target() != self.applied
47 },
48 choices: { self.pinned },
49 rebuild: {
50 self.rebuilds += 1
51 self.applied = self.target()
52 })
53 }
54
55 /// The module making its first engine, for the first connection.
56 func start() {
57 running = true
58 applied = target()
59 }
60 }
61
62 private var store: UserDefaults!
63 private var storeName = ""
64
65 override func setUp() {
66 super.setUp()
67 storeName = "calls-audio-tests-\(UUID().uuidString)"
68 store = UserDefaults(suiteName: storeName)
69 }
70 override func tearDown() {
71 store.removePersistentDomain(forName: storeName)
72 super.tearDown()
73 }
74
75 private func routes(_ machine: Machine) -> AudioRoutes {
76 let routes = AudioRoutes(defaults: store)
77 routes.attach(machine.control())
78 return routes
79 }
80
81 func testFirstCallStartsOnTheSystemDefault() {
82 let machine = Machine()
83 let routes = routes(machine)
84 XCTAssertEqual(routes.currentInput, "default")
85 XCTAssertEqual(routes.currentOutput, "default")
86 XCTAssertEqual(routes.inputs.first?.name, "System default (MacBook Pro Microphone)")
87 XCTAssertEqual(routes.outputName, "System default (MacBook Pro Speakers)")
88 XCTAssertEqual(routes.inputs.map(\.id), ["default", "BuiltInMicrophoneDevice", "usb"])
89 XCTAssertEqual(routes.outputs.map(\.id), ["default", "BuiltInSpeakerDevice", "headset"])
90 XCTAssertTrue(machine.pinned == (nil, nil), "nothing chosen: the engine follows the default")
91 machine.start()
92 XCTAssertEqual(machine.applied, .systemDefault)
93 }
94
95 /// Paul, 2026-09-25 (0.3.6): "Still not picking up the Scarlett when I
96 /// load the app for the first time." The engine module lists only
97 /// "default" until the first connection initialises it; 0.3.6 matched
98 /// the remembered UID against that one-entry list at launch and fell
99 /// back. The lists and the choice now come from CoreAudio, which has
100 /// the Scarlett at launch.
101 func testRememberedMicrophoneResolvesAtLaunchBeforeTheModuleHasEnumerated() {
102 let machine = Machine()
103 machine.devices.append(Machine.scarlett)
104 machine.defaultOutput = Machine.scarlett.uid
105 XCTAssertEqual(machine.moduleList, ["default"], "the module has not enumerated yet")
106 AudioPreference(id: Machine.scarlett.uid, name: "Scarlett 18i8 USB").save(input: true, store)
107 let routes = routes(machine)
108 XCTAssertEqual(routes.currentInput, Machine.scarlett.uid)
109 XCTAssertEqual(routes.routeNote, "", "no claim that a connected Scarlett isn't connected")
110 XCTAssertEqual(machine.pinned.input, Machine.scarlett.uid)
111 machine.start()
112 XCTAssertEqual(machine.applied, .device(Machine.scarlett.uid), "the first engine is made on it")
113 XCTAssertEqual(machine.rebuilds, 0, "no rebuild: the engine was made on the choice")
114 }
115
116 /// The device appearing only after launch — plugged in, or CoreAudio
117 /// late to publish it — is picked up on the next device-list update,
118 /// and a running engine is moved to it.
119 func testRememberedMicrophoneAppearingLaterIsResolvedOnTheNextUpdate() {
120 let machine = Machine()
121 AudioPreference(id: Machine.scarlett.uid, name: "Scarlett 18i8 USB").save(input: true, store)
122 let routes = routes(machine)
123 machine.start()
124 XCTAssertEqual(routes.currentInput, "default")
125 XCTAssertEqual(routes.routeNote, "Scarlett 18i8 USB isn’t connected; using MacBook Pro Microphone.")
126 routes.refresh()
127 XCTAssertEqual(machine.rebuilds, 0, "nothing to move to yet")
128 machine.devices.append(Machine.scarlett)
129 routes.refresh() // CoreAudio's device-list listener
130 XCTAssertEqual(routes.currentInput, Machine.scarlett.uid)
131 XCTAssertEqual(routes.routeNote, "")
132 XCTAssertEqual(machine.rebuilds, 1, "the running engine is rebuilt on the Scarlett")
133 XCTAssertEqual(machine.applied, .pair(input: Machine.scarlett.uid, output: "BuiltInSpeakerDevice"))
134 routes.refresh()
135 XCTAssertEqual(machine.rebuilds, 1, "and once, not on every look")
136 }
137
138 /// A USB interface moved to another port comes back under a new UID.
139 /// It is found by name, and the new UID is remembered.
140 func testADeviceBackUnderANewUIDIsFoundByName() {
141 let machine = Machine()
142 var moved = Machine.scarlett
143 moved.uid = "AppleUSBAudioEngine:Focusrite:Scarlett 18i8 USB:0002:1,2"
144 machine.devices.append(moved)
145 AudioPreference(id: Machine.scarlett.uid, name: "Scarlett 18i8 USB").save(input: true, store)
146 let routes = routes(machine)
147 XCTAssertEqual(routes.currentInput, moved.uid)
148 XCTAssertEqual(routes.routeNote, "")
149 XCTAssertEqual(AudioPreference.load(input: true, store)?.id, moved.uid)
150 }
151
152 /// Paul's 0.3.6 panel: "Scarlett 18i8 USB isn't connected; using
153 /// Scarlett 18i8 USB." The remembered speaker was the Scarlett, the
154 /// module's one-entry list lacked it, and the default it fell back to
155 /// was the Scarlett. Resolved against CoreAudio the Scarlett is simply
156 /// found; and a note can never name the missing device as the one in
157 /// use.
158 func testTheNoteIsNeverSelfContradictory() {
159 let machine = Machine()
160 machine.devices.append(Machine.scarlett)
161 machine.defaultOutput = Machine.scarlett.uid
162 AudioPreference(id: Machine.scarlett.uid, name: "Scarlett 18i8 USB").save(input: false, store)
163 AudioPreference(id: Machine.scarlett.uid, name: "Scarlett 18i8 USB").save(input: true, store)
164 let routes = routes(machine)
165 XCTAssertEqual(routes.routeNote, "")
166 XCTAssertEqual(routes.currentOutput, Machine.scarlett.uid)
167
168 // A second unit of the same model is the default while the chosen
169 // one, a different unit with its own UID, is away: two with the same
170 // name are ambiguous, so it is not taken for the chosen one, and the
171 // note does not say "using Scarlett 18i8 USB".
172 let fallback = SystemAudioDevice(uid: "other", name: "Scarlett 18i8 USB", inputChannels: 18, outputChannels: 8)
173 let missing = AudioPreference(id: "gone", name: "Scarlett 18i8 USB")
174 let two = AudioInventory(
175 devices: [fallback, SystemAudioDevice(uid: "third", name: "Scarlett 18i8 USB", inputChannels: 18, outputChannels: 8)],
176 defaultInput: "other", defaultOutput: "other")
177 let resolved = AudioRouteRules.resolve(missing, two, input: true)
178 XCTAssertEqual(resolved.current, "default")
179 XCTAssertEqual(resolved.note, "Scarlett 18i8 USB isn’t connected; using the Mac’s default microphone.")
180
181 // Every note, over a spread of cases: the missing name never
182 // follows "using".
183 for (pref, inventory) in [
184 (missing, two),
185 (AudioPreference(id: "x", name: "MacBook Pro Microphone"), machine.inventory),
186 (AudioPreference(id: "x", name: ""), machine.inventory),
187 (AudioPreference(id: "x", name: "Mic"), AudioInventory()),
188 ] {
189 for input in [true, false] {
190 guard let note = AudioRouteRules.resolve(pref, inventory, input: input).note else { continue }
191 let using = note.components(separatedBy: "; using ").last ?? ""
192 XCTAssertFalse(!pref.name.isEmpty && using.hasPrefix(pref.name), note)
193 }
194 }
195 }
196
197 func testChoicesAreRememberedAcrossCallsAndLaunches() throws {
198 let machine = Machine()
199 let routes = routes(machine)
200 try routes.use(input: "usb")
201 try routes.use(output: "headset")
202 routes.reset() // leaving the call
203 XCTAssertEqual(routes.currentInput, "usb")
204 XCTAssertEqual(routes.currentOutput, "headset")
205
206 let next = Machine()
207 let launch = self.routes(next)
208 XCTAssertEqual(launch.currentInput, "usb")
209 XCTAssertEqual(launch.currentOutput, "headset")
210 XCTAssertTrue(next.pinned == ("usb", "headset"), "applied at join, before anyone asks")
211 next.start()
212 XCTAssertEqual(next.applied, .pair(input: "usb", output: "headset"))
213 XCTAssertEqual(store.string(forKey: "audioInputUID"), "usb")
214 XCTAssertEqual(store.string(forKey: "audioInputName"), "USB Mic")
215 }
216
217 func testSystemDefaultIsRememberedWhenPicked() throws {
218 let machine = Machine()
219 let routes = routes(machine)
220 try routes.use(input: "usb")
221 try routes.use(input: "")
222 XCTAssertEqual(routes.currentInput, "default")
223 XCTAssertEqual(store.string(forKey: "audioInputUID"), "", "the default is a choice of its own")
224 try routes.use(input: "usb")
225 try routes.use(input: "default")
226 XCTAssertEqual(store.string(forKey: "audioInputUID"), "", "the picker's own id for it too")
227 let next = Machine()
228 let launch = self.routes(next)
229 XCTAssertEqual(launch.currentInput, "default")
230 XCTAssertTrue(next.pinned == (nil, nil))
231 }
232
233 func testDisconnectMidCallFallsBackAndComesBackOnItsOwn() throws {
234 let machine = Machine()
235 let routes = routes(machine)
236 machine.start()
237 try routes.use(input: "usb")
238 try routes.use(output: "headset")
239 XCTAssertEqual(machine.rebuilds, 2)
240 machine.devices.removeAll { $0.uid == "usb" }
241 routes.refresh()
242 XCTAssertEqual(routes.currentInput, "default")
243 XCTAssertEqual(routes.currentOutput, "headset")
244 XCTAssertEqual(machine.applied, .pair(input: "BuiltInMicrophoneDevice", output: "headset"))
245 XCTAssertEqual(routes.routeNote, "USB Mic isn’t connected; using MacBook Pro Microphone.")
246 let count = machine.rebuilds
247 routes.refresh()
248 XCTAssertEqual(machine.rebuilds, count, "the engine is moved once, not on every look")
249 machine.devices.append(SystemAudioDevice(uid: "usb", name: "USB Mic", inputChannels: 1, outputChannels: 0))
250 routes.refresh()
251 XCTAssertEqual(routes.currentInput, "usb")
252 XCTAssertEqual(machine.applied, .pair(input: "usb", output: "headset"))
253 XCTAssertEqual(routes.routeNote, "")
254 }
255
256 func testRefusedSwitchKeepsPreviousSelection() throws {
257 let machine = Machine()
258 let routes = routes(machine)
259 try routes.use(input: "usb")
260 XCTAssertThrowsError(try routes.use(input: "missing")) { error in
261 XCTAssertEqual((error as NSError).code, 5)
262 }
263 XCTAssertEqual(routes.currentInput, "usb")
264 XCTAssertThrowsError(try routes.use(output: "missing"))
265 XCTAssertThrowsError(try routes.use(output: "usb"), "a microphone is not a speaker")
266 XCTAssertEqual(routes.currentOutput, "default")
267 XCTAssertEqual(store.string(forKey: "audioInputUID"), "usb", "nor the memory")
268 }
269
270 func testSystemDefaultFollowsChangesButExplicitChoiceDoesNot() throws {
271 let machine = Machine()
272 let routes = routes(machine)
273 machine.start()
274 machine.defaultOutput = "headset"
275 routes.refresh()
276 XCTAssertEqual(routes.currentOutput, "default")
277 XCTAssertEqual(routes.outputName, "System default (Headset)")
278 XCTAssertEqual(machine.rebuilds, 0, "the module follows the default itself")
279 try routes.use(output: "BuiltInSpeakerDevice")
280 let count = machine.rebuilds
281 machine.defaultOutput = "BuiltInSpeakerDevice"
282 routes.refresh()
283 machine.defaultOutput = "headset"
284 routes.refresh()
285 XCTAssertEqual(routes.currentOutput, "BuiltInSpeakerDevice")
286 XCTAssertEqual(machine.rebuilds, count)
287 }
288
289 /// A device the engine cannot start on is said so, and set aside
290 /// until it is chosen again or what is connected changes: never a
291 /// loop of rebuilds.
292 func testAnEngineFailureFallsBackSaysSoAndRetriesOnChange() throws {
293 let machine = Machine()
294 machine.devices.append(Machine.scarlett)
295 let routes = routes(machine)
296 machine.start()
297 try routes.use(input: Machine.scarlett.uid)
298 XCTAssertEqual(machine.rebuilds, 1)
299 routes.engineFailed(machine.applied, reason: "the audio engine would not start on it")
300 XCTAssertEqual(routes.currentInput, "default")
301 XCTAssertEqual(routes.routeNote, "Calls couldn’t open Scarlett 18i8 USB; using MacBook Pro Microphone.")
302 XCTAssertEqual(machine.applied, .systemDefault)
303 XCTAssertEqual(machine.rebuilds, 2)
304 routes.refresh()
305 routes.refresh()
306 XCTAssertEqual(machine.rebuilds, 2, "no retry loop")
307 XCTAssertEqual(AudioPreference.load(input: true, store)?.id, Machine.scarlett.uid, "the choice is kept")
308
309 machine.devices.append(SystemAudioDevice(uid: "new", name: "AirPods", inputChannels: 1, outputChannels: 2))
310 routes.refresh()
311 XCTAssertEqual(routes.currentInput, Machine.scarlett.uid, "something changed: tried again")
312 XCTAssertEqual(routes.routeNote, "")
313
314 routes.engineFailed(machine.applied, reason: "again")
315 XCTAssertEqual(routes.currentInput, "default")
316 try routes.use(input: Machine.scarlett.uid)
317 XCTAssertEqual(routes.currentInput, Machine.scarlett.uid, "choosing it again tries again")
318 }
319
320 func testNoModuleMeansNothingToChoose() {
321 let routes = AudioRoutes(defaults: store)
322 XCTAssertTrue(routes.inputs.isEmpty)
323 XCTAssertNil(routes.currentInput)
324 XCTAssertThrowsError(try routes.use(input: "usb"))
325 routes.reset()
326 XCTAssertTrue(routes.outputs.isEmpty)
327 }
328 }
329
330 /// Where the engine's one audio unit goes (on a Mac, input and output
331 /// are a single unit).
332 final class EngineTargetTests: XCTestCase {
333 func testPlans() {
334 func plan(_ i: String?, _ o: String?, di: String? = "mic", do d: String? = "spk") -> EngineTarget {
335 EngineTarget.plan(input: i, output: o, defaultInput: di, defaultOutput: d)
336 }
337 XCTAssertEqual(plan(nil, nil), .systemDefault, "nothing chosen: macOS's own default aggregate")
338 // Paul's Mac: the Scarlett chosen as microphone, and the default
339 // speaker is the Scarlett — one device, no aggregate.
340 XCTAssertEqual(plan("scarlett", nil, do: "scarlett"), .device("scarlett"))
341 XCTAssertEqual(plan("scarlett", "scarlett"), .device("scarlett"))
342 XCTAssertEqual(plan("usb", nil), .pair(input: "usb", output: "spk"))
343 XCTAssertEqual(plan(nil, "headset"), .pair(input: "mic", output: "headset"))
344 XCTAssertEqual(plan("usb", nil, do: nil), .device("usb"), "no speaker at all")
345 XCTAssertEqual(plan(nil, "headset", di: nil), .device("headset"), "no microphone at all")
346 }
347
348 /// A Bluetooth headset (or anything else with its own microphone and
349 /// speaker) used for both is one device: never an aggregate, chosen
350 /// on either side or both.
351 func testOneDeviceForBothIsNeverAPair() {
352 func plan(_ i: String?, _ o: String?, di: String?, do d: String?) -> EngineTarget {
353 EngineTarget.plan(input: i, output: o, defaultInput: di, defaultOutput: d)
354 }
355 XCTAssertEqual(plan(nil, nil, di: "airpods", do: "airpods"), .systemDefault, "the default is macOS's, untouched")
356 XCTAssertEqual(plan("airpods", nil, di: "airpods", do: "airpods"), .device("airpods"))
357 XCTAssertEqual(plan(nil, "airpods", di: "airpods", do: "airpods"), .device("airpods"))
358 XCTAssertEqual(plan("airpods", "airpods", di: "mic", do: "spk"), .device("airpods"))
359 }
360
361 /// When a chosen microphone's change of rate rebuilds the engine: a
362 /// headset going to its hands-free rate under an engine wired at 48
363 /// kHz does; a change that matches what the engine has, an engine not
364 /// capturing, or a second change within three seconds does not.
365 func testARateChangeRebuildsOnlyWhenTheEngineIsWiredAtAnother() {
366 func rule(_ device: Double, _ engine: Double?, recording: Bool = true, pair: Bool = false, last: Double = -100)
367 -> Bool
368 {
369 RateRule.rebuild(deviceRate: device, engineRate: engine, recording: recording, pair: pair, lastRebuild: last, now: 10)
370 }
371 XCTAssertTrue(rule(24_000, 48_000), "HFP engaged under a 48 kHz engine")
372 XCTAssertFalse(rule(24_000, 24_000), "already wired at the new rate")
373 XCTAssertFalse(rule(24_000, nil), "not capturing: the next start reads the rate afresh")
374 XCTAssertFalse(rule(24_000, 48_000, recording: false))
375 XCTAssertFalse(rule(24_000, 48_000, last: 8), "at most once every three seconds")
376 XCTAssertTrue(rule(24_000, 48_000, last: 7))
377 XCTAssertTrue(rule(24_000, 48_000, pair: true))
378 XCTAssertTrue(rule(48_000, 48_000, pair: true), "an aggregate's rate is the speaker's, so any change rebuilds it")
379 XCTAssertFalse(rule(0, 48_000), "a device that has gone says 0")
380 }
381
382 func testTheAggregateOffsetMapsTheMicrophonesOwnInputs() {
383 // A speaker with two inputs of its own ahead of the microphone.
384 XCTAssertEqual(InputChannelMap.map(channel: 0, clientChannels: 1, mappedBefore: false, offset: 2), [2])
385 XCTAssertEqual(InputChannelMap.map(channel: 1, clientChannels: 2, mappedBefore: false, offset: 2), [3, 3])
386 XCTAssertNil(InputChannelMap.map(channel: 0, clientChannels: 1, mappedBefore: false, offset: 0))
387 }
388 }
389
390 /// The real CoreAudio on this machine: the inventory the app lists at
391 /// launch, with no engine and no permission needed.
392 /// Which microphone the test says it is switching: a Bluetooth headset
393 /// not yet at its hands-free rate, and nothing else.
394 final class BluetoothSwitchTests: XCTestCase {
395 private let inventory = AudioInventory(
396 devices: [
397 SystemAudioDevice(
398 uid: "airpods", name: "Paul’s AirPods Pro", inputChannels: 1, outputChannels: 2, sampleRate: 48_000,
399 transport: "bluetooth"),
400 SystemAudioDevice(
401 uid: "airpods-hfp", name: "Headset", inputChannels: 1, outputChannels: 1, sampleRate: 24_000,
402 transport: "bluetooth"),
403 SystemAudioDevice(
404 uid: "BuiltInMicrophoneDevice", name: "MacBook Air Microphone", inputChannels: 1, outputChannels: 0,
405 sampleRate: 48_000, transport: "built-in"),
406 ], defaultInput: "airpods", defaultOutput: "airpods")
407
408 func testABluetoothHeadsetInItsListeningProfileIsSwitching() {
409 XCTAssertEqual(AudioRoutes.bluetoothSwitching(uid: "airpods", inventory), "Paul’s AirPods Pro")
410 }
411
412 func testOneAlreadyAtItsHandsFreeRateIsNot() {
413 XCTAssertNil(AudioRoutes.bluetoothSwitching(uid: "airpods-hfp", inventory))
414 }
415
416 func testABuiltInMicrophoneOrNothingIsNot() {
417 XCTAssertNil(AudioRoutes.bluetoothSwitching(uid: "BuiltInMicrophoneDevice", inventory))
418 XCTAssertNil(AudioRoutes.bluetoothSwitching(uid: nil, inventory))
419 }
420 }
421
422 final class CoreAudioInventoryTests: XCTestCase {
423 func testTheInventoryIsReadyWithoutAnEngine() {
424 let inventory = CoreAudioDevices.inventory()
425 for device in inventory.devices {
426 print("CoreAudio: \(device.describe)")
427 XCTAssertFalse(device.name.isEmpty)
428 XCTAssertFalse(CoreAudioDevices.isInternal(uid: device.uid), "no aggregate of ours or macOS's is listed")
429 }
430 if let input = inventory.defaultInput {
431 XCTAssertTrue(inventory.inputs.contains { $0.uid == input } || inventory.devices.isEmpty)
432 }
433 }
434 }
435#endif
436
437#if os(macOS)
438 import LiveKitWebRTC
439
440 /// The pin on a real engine module, playing (which needs no microphone
441 /// permission): a chosen speaker puts the engine on an aggregate of the
442 /// default microphone and that speaker before the module wires it, the
443 /// engine runs there, and going back to the default rebuilds it.
444 final class EnginePinIntegrationTests: XCTestCase {
445 @MainActor func testAChosenSpeakerRunsTheEngineOnItAndBack() async throws {
446 let inventory = CoreAudioDevices.inventory()
447 guard let speaker = inventory.defaultOutput, let microphone = inventory.defaultInput else {
448 throw XCTSkip("this machine has no default input and output")
449 }
450 let suite = "calls-engine-pin-\(UUID().uuidString)"
451 let store = UserDefaults(suiteName: suite)!
452 defer { store.removePersistentDomain(forName: suite) }
453 let routes = AudioRoutes(defaults: store)
454 let audio = CallAudio(routes: routes, audioDevice: nil)
455 let module = audio.factory.audioDeviceModule
456 try routes.use(output: speaker)
457 // The first connection initialises the module, as in a call.
458 let pc = audio.factory.peerConnection(
459 with: RTCConfiguration(),
460 constraints: RTCMediaConstraints(mandatoryConstraints: nil, optionalConstraints: nil), delegate: nil)
461 defer { pc?.close() }
462 XCTAssertEqual(module.initPlayout(), 0)
463 XCTAssertEqual(module.startPlayout(), 0)
464 defer { _ = module.stopPlayout() }
465 XCTAssertTrue(module.isEngineRunning, "the engine runs on the pinned devices")
466 let expected: EngineTarget = microphone == speaker ? .device(speaker) : .pair(input: microphone, output: speaker)
467 XCTAssertEqual(audio.enginePin.lastApplied, expected)
468
469 try routes.use(output: "")
470 // The rebuild runs on the audio queue (0.3.9), never the main thread.
471 await AudioWork.drain()
472 XCTAssertEqual(audio.enginePin.lastApplied, .systemDefault, "rebuilt on the default")
473 XCTAssertTrue(module.isEngineRunning)
474 }
475
476 /// A device that is both microphone and speaker (as AirPods are),
477 /// chosen for both: the engine runs on the device itself, and no
478 /// aggregate is made. Uses whatever such device this Mac has (a
479 /// virtual one here, "Microsoft Teams Audio"); playing only, which
480 /// needs no microphone permission.
481 @MainActor func testOneDeviceForBothRunsOnItWithNoAggregate() throws {
482 let inventory = CoreAudioDevices.inventory()
483 guard let both = inventory.devices.first(where: { $0.inputChannels > 0 && $0.outputChannels > 0 }) else {
484 throw XCTSkip("this machine has no device with both a microphone and a speaker")
485 }
486 print("same-device route on \(both.describe)")
487 let suite = "calls-engine-same-\(UUID().uuidString)"
488 let store = UserDefaults(suiteName: suite)!
489 defer { store.removePersistentDomain(forName: suite) }
490 let routes = AudioRoutes(defaults: store)
491 let audio = CallAudio(routes: routes, audioDevice: nil)
492 let module = audio.factory.audioDeviceModule
493 try routes.use(input: both.uid)
494 try routes.use(output: both.uid)
495 let pc = audio.factory.peerConnection(
496 with: RTCConfiguration(),
497 constraints: RTCMediaConstraints(mandatoryConstraints: nil, optionalConstraints: nil), delegate: nil)
498 defer { pc?.close() }
499 XCTAssertEqual(module.initPlayout(), 0)
500 XCTAssertEqual(module.startPlayout(), 0)
501 defer { _ = module.stopPlayout() }
502 XCTAssertTrue(module.isEngineRunning, "the engine runs on the one device")
503 XCTAssertEqual(audio.enginePin.lastApplied, .device(both.uid))
504 XCTAssertNil(audio.enginePin.aggregateUID, "no aggregate for one device")
505 XCTAssertFalse(
506 CoreAudioDevices.allDeviceIDs().compactMap(CoreAudioDevices.uid).contains {
507 $0.hasPrefix(CoreAudioDevices.ownAggregatePrefix)
508 }, "and none left anywhere on the system")
509 }
510 }
511#endif
512