#if os(macOS) import XCTest @testable import CallsKit /// The Mac's remembered routing rules, held against a stand-in for /// CoreAudio and the engine so no microphone is opened. The module /// itself follows the system default and sample-rate changes; these hold /// what AudioRoutes adds on top of it. @MainActor final class CallAudioSelectionTests: XCTestCase { /// CoreAudio and the engine, as far as the rules can see them. private final class Machine { var devices: [SystemAudioDevice] = [ SystemAudioDevice(uid: "BuiltInMicrophoneDevice", name: "MacBook Pro Microphone", inputChannels: 1, outputChannels: 0), SystemAudioDevice(uid: "BuiltInSpeakerDevice", name: "MacBook Pro Speakers", inputChannels: 0, outputChannels: 2), SystemAudioDevice(uid: "usb", name: "USB Mic", inputChannels: 1, outputChannels: 0), SystemAudioDevice(uid: "headset", name: "Headset", inputChannels: 0, outputChannels: 2), ] var defaultInput = "BuiltInMicrophoneDevice" var defaultOutput = "BuiltInSpeakerDevice" /// What the engine was last asked for; the engine is "running" when set. var pinned: (input: String?, output: String?) = (nil, nil) var running = false var applied: EngineTarget = .systemDefault var rebuilds = 0 /// What the engine module lists: only "default" until a connection /// has initialised it, as LiveKit's does. Never used to choose. var moduleList = ["default"] static let scarlett = SystemAudioDevice( uid: "AppleUSBAudioEngine:Focusrite:Scarlett 18i8 USB:0001:1,2", name: "Scarlett 18i8 USB", inputChannels: 18, outputChannels: 8, sampleRate: 44_100, transport: "usb") var inventory: AudioInventory { AudioInventory(devices: devices, defaultInput: defaultInput, defaultOutput: defaultOutput) } func target() -> EngineTarget { EngineTarget.plan( input: pinned.input, output: pinned.output, defaultInput: defaultInput, defaultOutput: defaultOutput) } func control() -> AudioEngineControl { AudioEngineControl( inventory: { self.inventory }, pin: { input, output in self.pinned = (input, output) return self.running && self.target() != self.applied }, choices: { self.pinned }, rebuild: { self.rebuilds += 1 self.applied = self.target() }) } /// The module making its first engine, for the first connection. func start() { running = true applied = target() } } private var store: UserDefaults! private var storeName = "" override func setUp() { super.setUp() storeName = "calls-audio-tests-\(UUID().uuidString)" store = UserDefaults(suiteName: storeName) } override func tearDown() { store.removePersistentDomain(forName: storeName) super.tearDown() } private func routes(_ machine: Machine) -> AudioRoutes { let routes = AudioRoutes(defaults: store) routes.attach(machine.control()) return routes } func testFirstCallStartsOnTheSystemDefault() { let machine = Machine() let routes = routes(machine) XCTAssertEqual(routes.currentInput, "default") XCTAssertEqual(routes.currentOutput, "default") XCTAssertEqual(routes.inputs.first?.name, "System default (MacBook Pro Microphone)") XCTAssertEqual(routes.outputName, "System default (MacBook Pro Speakers)") XCTAssertEqual(routes.inputs.map(\.id), ["default", "BuiltInMicrophoneDevice", "usb"]) XCTAssertEqual(routes.outputs.map(\.id), ["default", "BuiltInSpeakerDevice", "headset"]) XCTAssertTrue(machine.pinned == (nil, nil), "nothing chosen: the engine follows the default") machine.start() XCTAssertEqual(machine.applied, .systemDefault) } /// Paul, 2026-09-25 (0.3.6): "Still not picking up the Scarlett when I /// load the app for the first time." The engine module lists only /// "default" until the first connection initialises it; 0.3.6 matched /// the remembered UID against that one-entry list at launch and fell /// back. The lists and the choice now come from CoreAudio, which has /// the Scarlett at launch. func testRememberedMicrophoneResolvesAtLaunchBeforeTheModuleHasEnumerated() { let machine = Machine() machine.devices.append(Machine.scarlett) machine.defaultOutput = Machine.scarlett.uid XCTAssertEqual(machine.moduleList, ["default"], "the module has not enumerated yet") AudioPreference(id: Machine.scarlett.uid, name: "Scarlett 18i8 USB").save(input: true, store) let routes = routes(machine) XCTAssertEqual(routes.currentInput, Machine.scarlett.uid) XCTAssertEqual(routes.routeNote, "", "no claim that a connected Scarlett isn't connected") XCTAssertEqual(machine.pinned.input, Machine.scarlett.uid) machine.start() XCTAssertEqual(machine.applied, .device(Machine.scarlett.uid), "the first engine is made on it") XCTAssertEqual(machine.rebuilds, 0, "no rebuild: the engine was made on the choice") } /// The device appearing only after launch — plugged in, or CoreAudio /// late to publish it — is picked up on the next device-list update, /// and a running engine is moved to it. func testRememberedMicrophoneAppearingLaterIsResolvedOnTheNextUpdate() { let machine = Machine() AudioPreference(id: Machine.scarlett.uid, name: "Scarlett 18i8 USB").save(input: true, store) let routes = routes(machine) machine.start() XCTAssertEqual(routes.currentInput, "default") XCTAssertEqual(routes.routeNote, "Scarlett 18i8 USB isn’t connected; using MacBook Pro Microphone.") routes.refresh() XCTAssertEqual(machine.rebuilds, 0, "nothing to move to yet") machine.devices.append(Machine.scarlett) routes.refresh() // CoreAudio's device-list listener XCTAssertEqual(routes.currentInput, Machine.scarlett.uid) XCTAssertEqual(routes.routeNote, "") XCTAssertEqual(machine.rebuilds, 1, "the running engine is rebuilt on the Scarlett") XCTAssertEqual(machine.applied, .pair(input: Machine.scarlett.uid, output: "BuiltInSpeakerDevice")) routes.refresh() XCTAssertEqual(machine.rebuilds, 1, "and once, not on every look") } /// A USB interface moved to another port comes back under a new UID. /// It is found by name, and the new UID is remembered. func testADeviceBackUnderANewUIDIsFoundByName() { let machine = Machine() var moved = Machine.scarlett moved.uid = "AppleUSBAudioEngine:Focusrite:Scarlett 18i8 USB:0002:1,2" machine.devices.append(moved) AudioPreference(id: Machine.scarlett.uid, name: "Scarlett 18i8 USB").save(input: true, store) let routes = routes(machine) XCTAssertEqual(routes.currentInput, moved.uid) XCTAssertEqual(routes.routeNote, "") XCTAssertEqual(AudioPreference.load(input: true, store)?.id, moved.uid) } /// Paul's 0.3.6 panel: "Scarlett 18i8 USB isn't connected; using /// Scarlett 18i8 USB." The remembered speaker was the Scarlett, the /// module's one-entry list lacked it, and the default it fell back to /// was the Scarlett. Resolved against CoreAudio the Scarlett is simply /// found; and a note can never name the missing device as the one in /// use. func testTheNoteIsNeverSelfContradictory() { let machine = Machine() machine.devices.append(Machine.scarlett) machine.defaultOutput = Machine.scarlett.uid AudioPreference(id: Machine.scarlett.uid, name: "Scarlett 18i8 USB").save(input: false, store) AudioPreference(id: Machine.scarlett.uid, name: "Scarlett 18i8 USB").save(input: true, store) let routes = routes(machine) XCTAssertEqual(routes.routeNote, "") XCTAssertEqual(routes.currentOutput, Machine.scarlett.uid) // A second unit of the same model is the default while the chosen // one, a different unit with its own UID, is away: two with the same // name are ambiguous, so it is not taken for the chosen one, and the // note does not say "using Scarlett 18i8 USB". let fallback = SystemAudioDevice(uid: "other", name: "Scarlett 18i8 USB", inputChannels: 18, outputChannels: 8) let missing = AudioPreference(id: "gone", name: "Scarlett 18i8 USB") let two = AudioInventory( devices: [fallback, SystemAudioDevice(uid: "third", name: "Scarlett 18i8 USB", inputChannels: 18, outputChannels: 8)], defaultInput: "other", defaultOutput: "other") let resolved = AudioRouteRules.resolve(missing, two, input: true) XCTAssertEqual(resolved.current, "default") XCTAssertEqual(resolved.note, "Scarlett 18i8 USB isn’t connected; using the Mac’s default microphone.") // Every note, over a spread of cases: the missing name never // follows "using". for (pref, inventory) in [ (missing, two), (AudioPreference(id: "x", name: "MacBook Pro Microphone"), machine.inventory), (AudioPreference(id: "x", name: ""), machine.inventory), (AudioPreference(id: "x", name: "Mic"), AudioInventory()), ] { for input in [true, false] { guard let note = AudioRouteRules.resolve(pref, inventory, input: input).note else { continue } let using = note.components(separatedBy: "; using ").last ?? "" XCTAssertFalse(!pref.name.isEmpty && using.hasPrefix(pref.name), note) } } } func testChoicesAreRememberedAcrossCallsAndLaunches() throws { let machine = Machine() let routes = routes(machine) try routes.use(input: "usb") try routes.use(output: "headset") routes.reset() // leaving the call XCTAssertEqual(routes.currentInput, "usb") XCTAssertEqual(routes.currentOutput, "headset") let next = Machine() let launch = self.routes(next) XCTAssertEqual(launch.currentInput, "usb") XCTAssertEqual(launch.currentOutput, "headset") XCTAssertTrue(next.pinned == ("usb", "headset"), "applied at join, before anyone asks") next.start() XCTAssertEqual(next.applied, .pair(input: "usb", output: "headset")) XCTAssertEqual(store.string(forKey: "audioInputUID"), "usb") XCTAssertEqual(store.string(forKey: "audioInputName"), "USB Mic") } func testSystemDefaultIsRememberedWhenPicked() throws { let machine = Machine() let routes = routes(machine) try routes.use(input: "usb") try routes.use(input: "") XCTAssertEqual(routes.currentInput, "default") XCTAssertEqual(store.string(forKey: "audioInputUID"), "", "the default is a choice of its own") try routes.use(input: "usb") try routes.use(input: "default") XCTAssertEqual(store.string(forKey: "audioInputUID"), "", "the picker's own id for it too") let next = Machine() let launch = self.routes(next) XCTAssertEqual(launch.currentInput, "default") XCTAssertTrue(next.pinned == (nil, nil)) } func testDisconnectMidCallFallsBackAndComesBackOnItsOwn() throws { let machine = Machine() let routes = routes(machine) machine.start() try routes.use(input: "usb") try routes.use(output: "headset") XCTAssertEqual(machine.rebuilds, 2) machine.devices.removeAll { $0.uid == "usb" } routes.refresh() XCTAssertEqual(routes.currentInput, "default") XCTAssertEqual(routes.currentOutput, "headset") XCTAssertEqual(machine.applied, .pair(input: "BuiltInMicrophoneDevice", output: "headset")) XCTAssertEqual(routes.routeNote, "USB Mic isn’t connected; using MacBook Pro Microphone.") let count = machine.rebuilds routes.refresh() XCTAssertEqual(machine.rebuilds, count, "the engine is moved once, not on every look") machine.devices.append(SystemAudioDevice(uid: "usb", name: "USB Mic", inputChannels: 1, outputChannels: 0)) routes.refresh() XCTAssertEqual(routes.currentInput, "usb") XCTAssertEqual(machine.applied, .pair(input: "usb", output: "headset")) XCTAssertEqual(routes.routeNote, "") } func testRefusedSwitchKeepsPreviousSelection() throws { let machine = Machine() let routes = routes(machine) try routes.use(input: "usb") XCTAssertThrowsError(try routes.use(input: "missing")) { error in XCTAssertEqual((error as NSError).code, 5) } XCTAssertEqual(routes.currentInput, "usb") XCTAssertThrowsError(try routes.use(output: "missing")) XCTAssertThrowsError(try routes.use(output: "usb"), "a microphone is not a speaker") XCTAssertEqual(routes.currentOutput, "default") XCTAssertEqual(store.string(forKey: "audioInputUID"), "usb", "nor the memory") } func testSystemDefaultFollowsChangesButExplicitChoiceDoesNot() throws { let machine = Machine() let routes = routes(machine) machine.start() machine.defaultOutput = "headset" routes.refresh() XCTAssertEqual(routes.currentOutput, "default") XCTAssertEqual(routes.outputName, "System default (Headset)") XCTAssertEqual(machine.rebuilds, 0, "the module follows the default itself") try routes.use(output: "BuiltInSpeakerDevice") let count = machine.rebuilds machine.defaultOutput = "BuiltInSpeakerDevice" routes.refresh() machine.defaultOutput = "headset" routes.refresh() XCTAssertEqual(routes.currentOutput, "BuiltInSpeakerDevice") XCTAssertEqual(machine.rebuilds, count) } /// A device the engine cannot start on is said so, and set aside /// until it is chosen again or what is connected changes: never a /// loop of rebuilds. func testAnEngineFailureFallsBackSaysSoAndRetriesOnChange() throws { let machine = Machine() machine.devices.append(Machine.scarlett) let routes = routes(machine) machine.start() try routes.use(input: Machine.scarlett.uid) XCTAssertEqual(machine.rebuilds, 1) routes.engineFailed(machine.applied, reason: "the audio engine would not start on it") XCTAssertEqual(routes.currentInput, "default") XCTAssertEqual(routes.routeNote, "Calls couldn’t open Scarlett 18i8 USB; using MacBook Pro Microphone.") XCTAssertEqual(machine.applied, .systemDefault) XCTAssertEqual(machine.rebuilds, 2) routes.refresh() routes.refresh() XCTAssertEqual(machine.rebuilds, 2, "no retry loop") XCTAssertEqual(AudioPreference.load(input: true, store)?.id, Machine.scarlett.uid, "the choice is kept") machine.devices.append(SystemAudioDevice(uid: "new", name: "AirPods", inputChannels: 1, outputChannels: 2)) routes.refresh() XCTAssertEqual(routes.currentInput, Machine.scarlett.uid, "something changed: tried again") XCTAssertEqual(routes.routeNote, "") routes.engineFailed(machine.applied, reason: "again") XCTAssertEqual(routes.currentInput, "default") try routes.use(input: Machine.scarlett.uid) XCTAssertEqual(routes.currentInput, Machine.scarlett.uid, "choosing it again tries again") } func testNoModuleMeansNothingToChoose() { let routes = AudioRoutes(defaults: store) XCTAssertTrue(routes.inputs.isEmpty) XCTAssertNil(routes.currentInput) XCTAssertThrowsError(try routes.use(input: "usb")) routes.reset() XCTAssertTrue(routes.outputs.isEmpty) } } /// Where the engine's one audio unit goes (on a Mac, input and output /// are a single unit). final class EngineTargetTests: XCTestCase { func testPlans() { func plan(_ i: String?, _ o: String?, di: String? = "mic", do d: String? = "spk") -> EngineTarget { EngineTarget.plan(input: i, output: o, defaultInput: di, defaultOutput: d) } XCTAssertEqual(plan(nil, nil), .systemDefault, "nothing chosen: macOS's own default aggregate") // Paul's Mac: the Scarlett chosen as microphone, and the default // speaker is the Scarlett — one device, no aggregate. XCTAssertEqual(plan("scarlett", nil, do: "scarlett"), .device("scarlett")) XCTAssertEqual(plan("scarlett", "scarlett"), .device("scarlett")) XCTAssertEqual(plan("usb", nil), .pair(input: "usb", output: "spk")) XCTAssertEqual(plan(nil, "headset"), .pair(input: "mic", output: "headset")) XCTAssertEqual(plan("usb", nil, do: nil), .device("usb"), "no speaker at all") XCTAssertEqual(plan(nil, "headset", di: nil), .device("headset"), "no microphone at all") } /// A Bluetooth headset (or anything else with its own microphone and /// speaker) used for both is one device: never an aggregate, chosen /// on either side or both. func testOneDeviceForBothIsNeverAPair() { func plan(_ i: String?, _ o: String?, di: String?, do d: String?) -> EngineTarget { EngineTarget.plan(input: i, output: o, defaultInput: di, defaultOutput: d) } XCTAssertEqual(plan(nil, nil, di: "airpods", do: "airpods"), .systemDefault, "the default is macOS's, untouched") XCTAssertEqual(plan("airpods", nil, di: "airpods", do: "airpods"), .device("airpods")) XCTAssertEqual(plan(nil, "airpods", di: "airpods", do: "airpods"), .device("airpods")) XCTAssertEqual(plan("airpods", "airpods", di: "mic", do: "spk"), .device("airpods")) } /// When a chosen microphone's change of rate rebuilds the engine: a /// headset going to its hands-free rate under an engine wired at 48 /// kHz does; a change that matches what the engine has, an engine not /// capturing, or a second change within three seconds does not. func testARateChangeRebuildsOnlyWhenTheEngineIsWiredAtAnother() { func rule(_ device: Double, _ engine: Double?, recording: Bool = true, pair: Bool = false, last: Double = -100) -> Bool { RateRule.rebuild(deviceRate: device, engineRate: engine, recording: recording, pair: pair, lastRebuild: last, now: 10) } XCTAssertTrue(rule(24_000, 48_000), "HFP engaged under a 48 kHz engine") XCTAssertFalse(rule(24_000, 24_000), "already wired at the new rate") XCTAssertFalse(rule(24_000, nil), "not capturing: the next start reads the rate afresh") XCTAssertFalse(rule(24_000, 48_000, recording: false)) XCTAssertFalse(rule(24_000, 48_000, last: 8), "at most once every three seconds") XCTAssertTrue(rule(24_000, 48_000, last: 7)) XCTAssertTrue(rule(24_000, 48_000, pair: true)) XCTAssertTrue(rule(48_000, 48_000, pair: true), "an aggregate's rate is the speaker's, so any change rebuilds it") XCTAssertFalse(rule(0, 48_000), "a device that has gone says 0") } func testTheAggregateOffsetMapsTheMicrophonesOwnInputs() { // A speaker with two inputs of its own ahead of the microphone. XCTAssertEqual(InputChannelMap.map(channel: 0, clientChannels: 1, mappedBefore: false, offset: 2), [2]) XCTAssertEqual(InputChannelMap.map(channel: 1, clientChannels: 2, mappedBefore: false, offset: 2), [3, 3]) XCTAssertNil(InputChannelMap.map(channel: 0, clientChannels: 1, mappedBefore: false, offset: 0)) } } /// The real CoreAudio on this machine: the inventory the app lists at /// launch, with no engine and no permission needed. /// Which microphone the test says it is switching: a Bluetooth headset /// not yet at its hands-free rate, and nothing else. final class BluetoothSwitchTests: XCTestCase { private let inventory = AudioInventory( devices: [ SystemAudioDevice( uid: "airpods", name: "Paul’s AirPods Pro", inputChannels: 1, outputChannels: 2, sampleRate: 48_000, transport: "bluetooth"), SystemAudioDevice( uid: "airpods-hfp", name: "Headset", inputChannels: 1, outputChannels: 1, sampleRate: 24_000, transport: "bluetooth"), SystemAudioDevice( uid: "BuiltInMicrophoneDevice", name: "MacBook Air Microphone", inputChannels: 1, outputChannels: 0, sampleRate: 48_000, transport: "built-in"), ], defaultInput: "airpods", defaultOutput: "airpods") func testABluetoothHeadsetInItsListeningProfileIsSwitching() { XCTAssertEqual(AudioRoutes.bluetoothSwitching(uid: "airpods", inventory), "Paul’s AirPods Pro") } func testOneAlreadyAtItsHandsFreeRateIsNot() { XCTAssertNil(AudioRoutes.bluetoothSwitching(uid: "airpods-hfp", inventory)) } func testABuiltInMicrophoneOrNothingIsNot() { XCTAssertNil(AudioRoutes.bluetoothSwitching(uid: "BuiltInMicrophoneDevice", inventory)) XCTAssertNil(AudioRoutes.bluetoothSwitching(uid: nil, inventory)) } } final class CoreAudioInventoryTests: XCTestCase { func testTheInventoryIsReadyWithoutAnEngine() { let inventory = CoreAudioDevices.inventory() for device in inventory.devices { print("CoreAudio: \(device.describe)") XCTAssertFalse(device.name.isEmpty) XCTAssertFalse(CoreAudioDevices.isInternal(uid: device.uid), "no aggregate of ours or macOS's is listed") } if let input = inventory.defaultInput { XCTAssertTrue(inventory.inputs.contains { $0.uid == input } || inventory.devices.isEmpty) } } } #endif #if os(macOS) import LiveKitWebRTC /// The pin on a real engine module, playing (which needs no microphone /// permission): a chosen speaker puts the engine on an aggregate of the /// default microphone and that speaker before the module wires it, the /// engine runs there, and going back to the default rebuilds it. final class EnginePinIntegrationTests: XCTestCase { @MainActor func testAChosenSpeakerRunsTheEngineOnItAndBack() async throws { let inventory = CoreAudioDevices.inventory() guard let speaker = inventory.defaultOutput, let microphone = inventory.defaultInput else { throw XCTSkip("this machine has no default input and output") } let suite = "calls-engine-pin-\(UUID().uuidString)" let store = UserDefaults(suiteName: suite)! defer { store.removePersistentDomain(forName: suite) } let routes = AudioRoutes(defaults: store) let audio = CallAudio(routes: routes, audioDevice: nil) let module = audio.factory.audioDeviceModule try routes.use(output: speaker) // The first connection initialises the module, as in a call. let pc = audio.factory.peerConnection( with: RTCConfiguration(), constraints: RTCMediaConstraints(mandatoryConstraints: nil, optionalConstraints: nil), delegate: nil) defer { pc?.close() } XCTAssertEqual(module.initPlayout(), 0) XCTAssertEqual(module.startPlayout(), 0) defer { _ = module.stopPlayout() } XCTAssertTrue(module.isEngineRunning, "the engine runs on the pinned devices") let expected: EngineTarget = microphone == speaker ? .device(speaker) : .pair(input: microphone, output: speaker) XCTAssertEqual(audio.enginePin.lastApplied, expected) try routes.use(output: "") // The rebuild runs on the audio queue (0.3.9), never the main thread. await AudioWork.drain() XCTAssertEqual(audio.enginePin.lastApplied, .systemDefault, "rebuilt on the default") XCTAssertTrue(module.isEngineRunning) } /// A device that is both microphone and speaker (as AirPods are), /// chosen for both: the engine runs on the device itself, and no /// aggregate is made. Uses whatever such device this Mac has (a /// virtual one here, "Microsoft Teams Audio"); playing only, which /// needs no microphone permission. @MainActor func testOneDeviceForBothRunsOnItWithNoAggregate() throws { let inventory = CoreAudioDevices.inventory() guard let both = inventory.devices.first(where: { $0.inputChannels > 0 && $0.outputChannels > 0 }) else { throw XCTSkip("this machine has no device with both a microphone and a speaker") } print("same-device route on \(both.describe)") let suite = "calls-engine-same-\(UUID().uuidString)" let store = UserDefaults(suiteName: suite)! defer { store.removePersistentDomain(forName: suite) } let routes = AudioRoutes(defaults: store) let audio = CallAudio(routes: routes, audioDevice: nil) let module = audio.factory.audioDeviceModule try routes.use(input: both.uid) try routes.use(output: both.uid) let pc = audio.factory.peerConnection( with: RTCConfiguration(), constraints: RTCMediaConstraints(mandatoryConstraints: nil, optionalConstraints: nil), delegate: nil) defer { pc?.close() } XCTAssertEqual(module.initPlayout(), 0) XCTAssertEqual(module.startPlayout(), 0) defer { _ = module.stopPlayout() } XCTAssertTrue(module.isEngineRunning, "the engine runs on the one device") XCTAssertEqual(audio.enginePin.lastApplied, .device(both.uid)) XCTAssertNil(audio.enginePin.aggregateUID, "no aggregate for one device") XCTAssertFalse( CoreAudioDevices.allDeviceIDs().compactMap(CoreAudioDevices.uid).contains { $0.hasPrefix(CoreAudioDevices.ownAggregatePrefix) }, "and none left anywhere on the system") } } #endif