oficina / meet Public

Mac audio: choose devices from CoreAudio and pin the engine before it wires

Paul's Scarlett 18i8 was not picked up at launch (0.3.6) and would not stay
chosen. LiveKit's engine module (M150) lists only "default" until the first
peer connection initialises it and raises no callback when it does, so the
remembered Scarlett was "not connected" at launch; its list also drops
virtual/aggregate devices and inputs with an unknown terminal type; it wires
the microphone with the old device's format before moving the unit to the
chosen one; and trySet*Device answers the wrong way round.

The module is now left on its default. The pickers list CoreAudio's devices
(ready at launch), a remembered device is matched by UID then unique name,
and the module's will-enable hook puts the engine's single IO unit on the
choice (a private aggregate when microphone and speaker differ) before any
format is read. Failed starts are noticed and said, never looped. Every
decision is logged under net.amadan.oficina.calls/audio.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 05cf0cdd4bef9a53941ca9d881d94cd2975f60df parent 57d98cd
9 files changed, +1282 −263
  • native/README.md +56 −16
  • native/apple/Sources/CallsKit/Audio.swift +197 −134
  • native/apple/Sources/CallsKit/AudioDevices.swift +513 −0
  • native/apple/Sources/CallsKit/CallAudio.swift +93 −13
  • native/apple/Sources/CallsKit/InputChannel.swift +28 −9
  • native/apple/Tests/CallsKitTests/AudioHealthTests.swift +14 −6
  • native/apple/Tests/CallsKitTests/AudioRoutesTests.swift +317 −80
  • native/apple/Tests/CallsKitTests/InputChannelTests.swift +7 −5
  • native/apple/Tests/CallsKitTests/ScarlettReproTests.swift +57 −0
diff --git a/native/README.md b/native/README.md
index 7d8233e..4378e5f 100644
--- a/native/README.md
+++ b/native/README.md
@@ -184,10 +184,9 @@ one mid-call: the watchdog has to restart it and the browser has to decode the
picture again on the same connection.
The microphone is a picker on both platforms, and on Mac so is the speaker.
-On iPhone it is an audio session route. On Mac it is a device on the call's own
-WebRTC audio device module — LiveKit's AVAudioEngine module, which lists input
-and output devices (the system default among them, following macOS) and
-switches mid-call without renegotiating. A choice there moves this call and
+On iPhone it is an audio session route. On Mac it is a CoreAudio device that
+the call's AVAudioEngine (LiveKit's engine module) is put on; switching mid-call
+rebuilds the engine without renegotiating. A choice there moves this call and
leaves the Mac's system setting alone. Before the move to LiveKit's build the
Mac could only report what macOS had chosen: that build's macOS slice had no
audio device API at all.
@@ -196,9 +195,10 @@ The Mac's choices follow these rules (`AudioRoutes`, held by
`AudioRoutesTests`):
- **Remembered** across calls and launches, in UserDefaults: the device's
- CoreAudio UID, and its name for while it is missing. Calls 0.3.1 forgot them
- at the end of every call. The panel says "Calls remembers these for next
- time."
+ CoreAudio UID, and its name for while it is missing. A device back under a
+ new UID (a USB interface on another port) is found by that name when exactly
+ one connected device has it. Calls 0.3.1 forgot them at the end of every
+ call. The panel says "Calls remembers these for next time."
- **System default** is the first entry of each picker, where someone who has
never chosen starts, and a choice like any other (remembered when picked). It
follows macOS: a new default device, or the device changing its sample rate,
@@ -208,9 +208,19 @@ The Mac's choices follow these rules (`AudioRoutes`, held by
move it.
- A chosen device that is not connected — at join or mid-call — puts the call on
the system default for now, and the panel says so ("Scarlett 18i8 isn't
- connected; using MacBook Pro Microphone"). The choice is kept, and the call
- goes back to the device when it reappears; CoreAudio's device-list property
- is listened to for that as well as the module's own report. No poller.
+ connected; using MacBook Pro Microphone"). "Connected" means CoreAudio's own
+ device list, never the engine module's. The note never names the missing
+ device as the one in use. The choice is kept, and the call goes back to the
+ device when it reappears; CoreAudio's device list and default devices are
+ listened to for that, as well as the module's own report. No poller.
+- A device the engine will not start on (the module gives up without saying
+ so; the app notices an engine that is not running two seconds after a start,
+ with no stop since) is set aside: "Calls couldn't open Scarlett 18i8 USB;
+ using MacBook Pro Microphone", and the call goes back to the default. Choosing
+ it again, or anything being plugged in or taken out, tries it again.
+- Everything above is logged, with device lists, each engine start and the
+ device its unit is actually on:
+ `log show --last 1h --predicate 'subsystem == "net.amadan.oficina.calls" && category == "audio"' --info`.
- Nothing changes Sound settings. Mute is not remembered: every call starts
unmuted.
- **Input**, for a microphone with more than one input (an audio interface):
@@ -226,12 +236,42 @@ The Mac's choices follow these rules (`AudioRoutes`, held by
rewires the microphone with the new map — a moment's gap, as a device switch
has. Input 1 on a device never mapped is left exactly as the module had it.
-The app keeps the choice itself rather than asking the module, because
-LiveKit's engine module (M150) cannot say which device it is on —
-`inputDevice` and `outputDevice` always read back nil — and
-`trySetInputDevice`/`trySetOutputDevice` report success and failure the wrong
-way round. The device list is reliable, so a device that is not listed is
-refused, and one that leaves the list has disconnected.
+**How the engine is put on a device** (`AudioDevices.swift`, 0.3.7). Up to
+0.3.6 the app chose through LiveKit's module (`inputDevice = …`). Paul's
+Scarlett 18i8 was not picked up at launch and would not stay chosen, and the
+module's source (webrtc-sdk `m150_release`, `audio_engine_device.mm`) and a
+probe of it show four reasons, any one of them enough:
+
+1. The module lists only "default" until the first peer connection
+ initialises it, and raises no device-update callback when it does. At
+ launch the remembered Scarlett was missing from a one-entry list, so the
+ call fell back with "Scarlett 18i8 USB isn't connected; using …", and the
+ speaker's note named the Scarlett as the fallback because it was the
+ default output.
+2. Its list leaves out every virtual or aggregate device, and any device whose
+ input streams have an unknown terminal type while it also has outputs.
+3. It wires the microphone in with the *previous* device's format and only
+ then moves the engine's unit to the chosen device, so a device at another
+ sample rate starts silent or not at all. A start that fails has already
+ released the old engine, so the call is left with no audio.
+4. `trySetInputDevice`/`trySetOutputDevice` answer YES on failure and NO on
+ success, and `inputDevice`/`outputDevice` always read back nil.
+
+So the module is left on its default and never asked to choose. The pickers
+list CoreAudio's devices (ready at launch, virtual and aggregate devices
+included), and the module's "will enable" hook puts the engine's unit on the
+chosen device before the module reads a format from it. On a Mac,
+AVAudioEngine's input and output are one audio unit (measured: by default it
+runs on macOS's private `CADefaultDeviceAggregate` of the two defaults), so a
+microphone and speaker that are different devices are joined in a private
+aggregate of the app's own: microphone first (AVAudioEngine will not start an
+aggregate whose first device has no input), the speaker's clock leading, the
+microphone drift-corrected. A microphone with outputs of its own has the
+engine's output mapped past them. With nothing chosen the engine is left
+exactly as before, on macOS's own default. A change mid-call rebuilds the
+engine (both sides taken away and given back), a moment's gap in both
+directions. The device the unit is actually on is read back as the engine
+starts, and is what the readout names.
Calls 0.2.1 (build 3) routed audio differently: its own CoreAudio audio
device (`CallsAudio`, one pair of HAL units per call) behind WebRTC's
diff --git a/native/apple/Sources/CallsKit/Audio.swift b/native/apple/Sources/CallsKit/Audio.swift
index 441b2f4..207b7b2 100644
--- a/native/apple/Sources/CallsKit/Audio.swift
+++ b/native/apple/Sources/CallsKit/Audio.swift
@@ -21,11 +21,9 @@ public struct AudioDevice: Identifiable, Hashable, Sendable {
/// The audio devices a call is on, and the ability to change them.
///
/// On iPhone the microphone is a route on the audio session. On Mac it is
-/// a device on WebRTC's audio device module: the AVAudioEngine module in
-/// LiveKit's WebRTC build lists input and output devices and switches
-/// between them mid-call, which the build this app used before could not
-/// do at all (its macOS slice had no audio device API). Until a call has
-/// made that module, the Mac lists are empty.
+/// a CoreAudio device the call's audio engine is put on (AudioDevices.swift
+/// says why the engine module is no longer asked to choose). Until a call
+/// has made its audio, the Mac lists are empty.
///
/// On Mac the person's choices are remembered — across calls and across
/// launches — and the rules are:
@@ -36,26 +34,23 @@ public struct AudioDevice: Identifiable, Hashable, Sendable {
/// changing its sample rate, is the module's to follow, and it does (it
/// listens for both and rebuilds its engine).
/// - A chosen device is kept by its CoreAudio UID, with its name beside it
-/// for while it is missing. A change of system default does not move it.
+/// for while it is missing, and found by that name when it comes back
+/// under another UID (a USB interface moved to another port). A change of
+/// system default does not move it.
/// - A chosen device that is not connected — at join, or disconnecting
/// mid-call — puts the call on the system default for now, and
/// `routeNote` says so ("Scarlett 18i8 isn't connected; using MacBook Pro
-/// Microphone"). The choice is kept, and when the device comes back the
-/// call goes back to it on its own.
+/// Microphone"). "Connected" is CoreAudio's device list, which is ready at
+/// launch; the engine module's list, which is not, is never asked. The
+/// choice is kept, and when the device comes back the call goes back to
+/// it on its own.
+/// - A device the engine cannot start on is said as such ("Calls couldn't
+/// open …"), the call goes back to the default, and choosing it again, or
+/// the device list changing, tries it again.
/// - Nothing here changes the Mac's Sound settings.
///
/// Calls 0.3.1 forgot the choices at the end of every call, so a Scarlett
/// chosen yesterday was the MacBook's microphone today (Paul, 2026-09-24).
-///
-/// The choice is kept here rather than read back from the module, because
-/// the engine module in this build cannot say which device it is on:
-/// `AudioEngineDevice` does not implement `GetRecordingDevice` or
-/// `GetPlayoutDevice`, so `inputDevice` and `outputDevice` always read back
-/// nil. `trySetInputDevice`'s answer cannot be used either — it reports
-/// success when the native call returns non-zero, which is failure. What
-/// can be relied on is the device list, so a choice is refused when its
-/// device is not listed, and a disconnection is noticed when it leaves the
-/// list.
@MainActor public final class AudioRoutes: ObservableObject {
@Published public private(set) var inputs: [AudioDevice] = []
@Published public private(set) var currentInput: String?
@@ -70,8 +65,9 @@ public struct AudioDevice: Identifiable, Hashable, Sendable {
public let selectable = true
#if os(macOS)
- /// Said while a remembered device is missing and the call is on the
- /// system default instead. Clears itself when the device is back.
+ /// Said while a remembered device is missing, or could not be opened,
+ /// and the call is on the system default instead. Clears itself when
+ /// the device is back.
@Published public private(set) var routeNote = ""
/// The microphone's input channels, labelled, when it has more than
/// one (an audio interface); empty for an ordinary microphone. See
@@ -80,15 +76,20 @@ public struct AudioDevice: Identifiable, Hashable, Sendable {
/// The input the call sends from, 0-based. Remembered per device.
@Published public private(set) var inputChannel = 0
/// The microphone and, on an interface, its input: "Scarlett 18i8 USB,
- /// input 1". Empty before a call has a module.
+ /// input 1". Empty before a call has audio.
@Published public private(set) var inputSource = ""
private var inputDevice: InputDeviceChannels?
- private var control: AudioModuleControl?
+ private var control: AudioEngineControl?
let defaults: UserDefaults
- /// The device each side of the module was last moved to. Nil is where
- /// the module starts: following the system default.
- private var appliedInput: String?
- private var appliedOutput: String?
+ /// Devices the engine could not start on, by UID, with why. Cleared
+ /// when the person chooses the device again or what is connected
+ /// changes.
+ private var failed: [String: String] = [:]
+ /// The microphone the engine was last seen running on, read back from
+ /// its unit — the truth when it is known.
+ private var engineInput: String?
+ private var lastInventory: AudioInventory?
+ private var lastDecision = ""
private var listener: AudioObjectPropertyListenerBlock?
#endif
@@ -118,49 +119,48 @@ public struct AudioDevice: Identifiable, Hashable, Sendable {
update(\.currentInput, nil)
update(\.currentOutput, nil)
update(\.outputName, "")
+ update(\.routeNote, "")
return
}
- let ins = control.devices(true)
- let outs = control.devices(false)
- let input = follow(AudioPreference.load(input: true, defaults), ins, input: true, applied: &appliedInput)
- let output = follow(AudioPreference.load(input: false, defaults), outs, input: false, applied: &appliedOutput)
+ let inventory = control.inventory()
+ if inventory != lastInventory {
+ // Something was plugged in or taken out: a device that could not
+ // be opened gets another try.
+ if let last = lastInventory, Set(last.devices.map(\.uid)) != Set(inventory.devices.map(\.uid)) {
+ failed = [:]
+ }
+ lastInventory = inventory
+ AudioLog.audio.info(
+ "CoreAudio devices: \(inventory.devices.map(\.describe).joined(separator: "; "), privacy: .public); default in \(inventory.defaultInput ?? "none", privacy: .public), out \(inventory.defaultOutput ?? "none", privacy: .public)")
+ control.logModuleLists()
+ }
+ let input = AudioRouteRules.resolve(
+ AudioPreference.load(input: true, defaults), inventory, input: true, failed: failed)
+ let output = AudioRouteRules.resolve(
+ AudioPreference.load(input: false, defaults), inventory, input: false, failed: failed)
+ input.remember?.save(input: true, defaults)
+ output.remember?.save(input: false, defaults)
+ let decision = "input \(input.current): \(input.reason); output \(output.current): \(output.reason)"
+ if decision != lastDecision {
+ lastDecision = decision
+ AudioLog.audio.info("routes: \(decision, privacy: .public)")
+ }
+ if control.pin(input.explicit, output.explicit) {
+ AudioLog.audio.info(
+ "routes: engine asked for input \(input.explicit ?? "default", privacy: .public), output \(output.explicit ?? "default", privacy: .public); rebuilding a running engine")
+ control.rebuild()
+ }
update(\.routeNote, [input.note, output.note].compactMap { $0 }.joined(separator: " "))
- update(\.inputs, ins.map(AudioRoutes.device))
- update(\.outputs, outs.map(AudioRoutes.device))
+ update(\.inputs, AudioRoutes.list(inventory, input: true))
+ update(\.outputs, AudioRoutes.list(inventory, input: false))
update(\.currentInput, input.current)
update(\.currentOutput, output.current)
update(\.outputName, outputs.first { $0.id == currentOutput }?.name ?? "")
- readChannels(input.current)
+ readChannels(input.explicit ?? inventory.defaultInput)
#endif
}
#if os(macOS)
- /// One side of the module, brought into line with the remembered
- /// choice: the chosen device when it is listed, the system default
- /// when it is not (with a note), and the module moved only when that
- /// is somewhere other than where it already is.
- private func follow(
- _ preference: AudioPreference?, _ list: [ModuleDevice], input: Bool, applied: inout String?
- ) -> (current: String?, note: String?) {
- guard let control else { return (nil, nil) }
- let fallback = list.first(where: \.isDefault)
- var target = fallback?.id
- var note: String?
- if let preference, !preference.id.isEmpty {
- if list.contains(where: { $0.id == preference.id }) {
- target = preference.id
- } else {
- let what = preference.name.isEmpty ? "The \(input ? "microphone" : "speaker") you chose" : preference.name
- let using = fallback.map { $0.name } ?? "the system default"
- note = "\(what) isn’t connected; using \(using)."
- }
- }
- if let target, (applied ?? fallback?.id) != target, control.select(target, input) {
- applied = target
- }
- return (target, note)
- }
-
/// The panel refreshes while it is open; publishing only what changed
/// keeps an open menu from being rebuilt under the pointer.
private func update<Value: Equatable>(
@@ -169,49 +169,79 @@ public struct AudioDevice: Identifiable, Hashable, Sendable {
if self[keyPath: key] != value { self[keyPath: key] = value }
}
- func attach(_ module: RTCAudioDeviceModule) {
- attach(AudioModuleControl(module))
+ /// "System default (MacBook Pro Microphone)" first, then every device
+ /// CoreAudio has for that side.
+ nonisolated static func list(_ inventory: AudioInventory, input: Bool) -> [AudioDevice] {
+ let head = inventory.systemDefault(input).map {
+ AudioDevice(id: AudioRouteRules.defaultID, name: "System default (\($0.name))", selectable: true)
+ } ?? AudioDevice(id: AudioRouteRules.defaultID, name: "System default", selectable: true)
+ return [head] + inventory.list(input).map { AudioDevice(id: $0.uid, name: $0.name, selectable: true) }
+ }
+
+ func attach(_ module: RTCAudioDeviceModule, pin: AudioEnginePin) {
+ attach(AudioEngineControl(module, pin: pin))
listenForDevices()
}
- func attach(_ control: AudioModuleControl) {
+ func attach(_ control: AudioEngineControl) {
self.control = control
- appliedInput = nil
- appliedOutput = nil
+ failed = [:]
+ engineInput = nil
+ lastInventory = nil
+ lastDecision = ""
refresh()
}
- /// CoreAudio's own device list, listened to directly: the module
- /// reports list changes too, but a remembered device coming back is
- /// the moment the call should move to it, and that is worth hearing
- /// twice rather than not at all. No poller.
+ /// CoreAudio's own device list and defaults, listened to directly: the
+ /// moment a remembered device comes back is the moment the call should
+ /// move to it. No poller.
private func listenForDevices() {
guard listener == nil else { return }
- var address = AudioObjectPropertyAddress(
- mSelector: kAudioHardwarePropertyDevices, mScope: kAudioObjectPropertyScopeGlobal,
- mElement: kAudioObjectPropertyElementMain)
let block: AudioObjectPropertyListenerBlock = { [weak self] _, _ in
DispatchQueue.main.async { MainActor.assumeIsolated { self?.refresh() } }
}
- if AudioObjectAddPropertyListenerBlock(
- AudioObjectID(kAudioObjectSystemObject), &address, DispatchQueue.main, block) == noErr
- {
- listener = block
+ var ok = false
+ for selector in [
+ kAudioHardwarePropertyDevices, kAudioHardwarePropertyDefaultInputDevice,
+ kAudioHardwarePropertyDefaultOutputDevice,
+ ] {
+ var address = AudioObjectPropertyAddress(
+ mSelector: selector, mScope: kAudioObjectPropertyScopeGlobal, mElement: kAudioObjectPropertyElementMain)
+ ok =
+ AudioObjectAddPropertyListenerBlock(
+ AudioObjectID(kAudioObjectSystemObject), &address, DispatchQueue.main, block) == noErr || ok
}
+ if ok { listener = block }
}
- /// The module lists the system default as a device of its own, which
- /// follows whatever macOS is set to. Said as such, so choosing it is
- /// understood as "whatever the Mac uses" rather than one microphone.
- nonisolated static func device(_ io: ModuleDevice) -> AudioDevice {
- AudioDevice(
- id: io.id, name: io.isDefault ? "System default (\(io.name))" : io.name,
- selectable: true)
+ /// The engine started on a unit whose microphone is `uid`: what the
+ /// readout and the note name from now on.
+ func engineStarted(input uid: String?) {
+ guard uid != engineInput else { return }
+ engineInput = uid
+ AudioLog.audio.info("engine running with input \(uid ?? "unknown", privacy: .public)")
+ readChannels(currentInput == AudioRouteRules.defaultID ? lastInventory?.defaultInput : currentInput)
+ }
+
+ /// The engine did not start on the devices it was put on. They are
+ /// set aside, the call goes back to the default, and the panel says so.
+ func engineFailed(_ target: EngineTarget, reason: String) {
+ let uids: [String]
+ switch target {
+ case .systemDefault: return
+ case .device(let uid): uids = [uid]
+ case .pair(let input, let output): uids = [input, output]
+ }
+ let (input, output) = control?.choices() ?? (nil, nil)
+ for uid in uids where uid == input || uid == output { failed[uid] = reason }
+ AudioLog.audio.error(
+ "engine failed on \(target.description, privacy: .public): \(reason, privacy: .public); falling back to the system default")
+ refresh()
}
/// Chooses the microphone on Mac, mid-call if one is up, and remembers
- /// it. The engine is reconfigured underneath the running track;
- /// nothing renegotiates. An empty id is the system default.
+ /// it. The engine is rebuilt on the device underneath the running
+ /// track; nothing renegotiates. An empty id is the system default.
public func use(input id: String) throws {
try choose(id, input: true, what: "microphone")
}
@@ -224,18 +254,25 @@ public struct AudioDevice: Identifiable, Hashable, Sendable {
/// UID), so that it is followed rather than held.
private func choose(_ id: String, input: Bool, what: String) throws {
guard let control else { throw AudioRoutes.gone(what) }
- let list = control.devices(input)
- let device = id.isEmpty ? list.first(where: \.isDefault) : list.first { $0.id == id }
- guard let device, control.select(device.id, input) else { throw AudioRoutes.gone(what) }
- if input { appliedInput = device.id } else { appliedOutput = device.id }
- AudioPreference(id: device.isDefault ? "" : device.id, name: device.isDefault ? "" : device.name)
- .save(input: input, defaults)
+ if id.isEmpty || id == AudioRouteRules.defaultID {
+ AudioPreference(id: "", name: "").save(input: input, defaults)
+ AudioLog.audio.info("chosen \(what, privacy: .public): system default")
+ refresh()
+ return
+ }
+ guard let device = control.inventory().list(input).first(where: { $0.uid == id }) else {
+ AudioLog.audio.error("chosen \(what, privacy: .public) \(id, privacy: .public) is not connected; refused")
+ throw AudioRoutes.gone(what)
+ }
+ failed[device.uid] = nil
+ AudioPreference(id: device.uid, name: device.name).save(input: input, defaults)
+ AudioLog.audio.info("chosen \(what, privacy: .public): \(device.describe, privacy: .public)")
refresh()
}
/// The current microphone's channels and the remembered one of them.
- private func readChannels(_ id: String?) {
- let device = control.flatMap { $0.channels(id) }
+ private func readChannels(_ uid: String?) {
+ let device = control.flatMap { $0.channels(uid) }
inputDevice = device
let channel = device.map { InputChannelPreference.load(uid: $0.uid, channels: $0.count, defaults) } ?? 0
update(\.inputChannels, device.map { d in d.count > 1 ? (0..<d.count).map(d.label) : [] } ?? [])
@@ -327,9 +364,9 @@ public struct AudioDevice: Identifiable, Hashable, Sendable {
}
#if os(macOS)
- /// A remembered microphone or speaker: the CoreAudio UID the module
- /// lists it by, and its name for while it is missing. An empty UID is
- /// the system default, chosen.
+ /// A remembered microphone or speaker: its CoreAudio UID, and its name
+ /// for while it is missing (and for finding it again under a new UID).
+ /// An empty UID is the system default, chosen.
struct AudioPreference: Equatable, Sendable {
var id: String
var name: String
@@ -349,64 +386,82 @@ public struct AudioDevice: Identifiable, Hashable, Sendable {
}
}
- /// A device as the audio device module lists it.
+ /// A device as the audio device module lists it. Logged beside
+ /// CoreAudio's list, never chosen from.
struct ModuleDevice: Equatable, Sendable {
let id: String
let name: String
let isDefault: Bool
}
- /// The audio device module as the routing rules see it: two lists and a
- /// way to move the call. The real one is LiveKit's engine module; the
- /// tests give it lists and record the moves, so the rules are held
- /// without opening a microphone.
- struct AudioModuleControl {
- var devices: (_ input: Bool) -> [ModuleDevice]
- /// False when the device is not listed. See AudioRoutes for why the
- /// module's own answer is not asked.
- var select: (_ id: String, _ input: Bool) -> Bool
- /// The input channels of the device behind a module device id.
- var channels: (_ id: String?) -> InputDeviceChannels?
+ /// The call's audio as the routing rules see it: what is connected, and
+ /// a way to put the engine on a device. The real one is CoreAudio and
+ /// LiveKit's engine module; the tests give it an inventory and record
+ /// what is asked, so the rules are held without opening a microphone.
+ struct AudioEngineControl {
+ var inventory: () -> AudioInventory
+ /// Hands the explicit choices to the engine. True when an engine is
+ /// running on something else and has to be rebuilt to move.
+ var pin: (_ input: String?, _ output: String?) -> Bool
+ var choices: () -> (input: String?, output: String?)
+ /// Makes the module build its engine again, so the pin applies now.
+ var rebuild: () -> Void
+ /// The input channels of the device with this UID (nil: the default).
+ var channels: (_ uid: String?) -> InputDeviceChannels?
/// Makes the module wire its microphone in again, so a new channel
/// map is applied. A no-op while it is not capturing: the map is read
/// whenever the input is next wired in.
var rebuildInput: () -> Void
+ /// For the log: what the engine module lists, beside CoreAudio.
+ var logModuleLists: () -> Void
init(
- devices: @escaping (_ input: Bool) -> [ModuleDevice],
- select: @escaping (_ id: String, _ input: Bool) -> Bool,
- channels: @escaping (_ id: String?) -> InputDeviceChannels? = { _ in nil },
- rebuildInput: @escaping () -> Void = {}
+ inventory: @escaping () -> AudioInventory,
+ pin: @escaping (_ input: String?, _ output: String?) -> Bool = { _, _ in false },
+ choices: @escaping () -> (input: String?, output: String?) = { (nil, nil) },
+ rebuild: @escaping () -> Void = {},
+ channels: @escaping (_ uid: String?) -> InputDeviceChannels? = { _ in nil },
+ rebuildInput: @escaping () -> Void = {},
+ logModuleLists: @escaping () -> Void = {}
) {
- self.devices = devices
- self.select = select
+ self.inventory = inventory
+ self.pin = pin
+ self.choices = choices
+ self.rebuild = rebuild
self.channels = channels
self.rebuildInput = rebuildInput
+ self.logModuleLists = logModuleLists
}
- init(_ module: RTCAudioDeviceModule) {
- devices = { [weak module] input in
- guard let module else { return [] }
- return (input ? module.inputDevices : module.outputDevices).map {
- ModuleDevice(id: $0.deviceId, name: $0.name, isDefault: $0.isDefault)
- }
- }
- select = { [weak module] id, input in
- guard let module,
- let device = (input ? module.inputDevices : module.outputDevices).first(where: {
- $0.deviceId == id
- })
+ init(_ module: RTCAudioDeviceModule, pin enginePin: AudioEnginePin) {
+ inventory = CoreAudioDevices.inventory
+ choices = { enginePin.choices }
+ // A running engine is rebuilt when what it would be put on now is
+ // not what it was put on. Before an engine exists (the module makes
+ // one for the first connection) there is nothing to move: the first
+ // engine is made on the choice.
+ pin = { [weak module] input, output in
+ _ = enginePin.set(input: input, output: output)
+ guard let module, !module.isManualRenderingMode, module.isPlaying || module.isRecording,
+ let applied = enginePin.lastApplied
else { return false }
- if input { module.inputDevice = device } else { module.outputDevice = device }
- return true
+ return enginePin.target() != applied
}
- channels = { id in CoreAudioInput.device(forModuleID: id).flatMap(CoreAudioInput.describe) }
- // The module has no "rewire the input" call. Taking the input away
- // and giving it back does it: the module recreates its engine for
- // output alone, then wires the microphone in again, asking the
- // observer first — which is where the map is applied. (Re-selecting
- // the same device does nothing: the module ignores a state that has
- // not changed.)
+ // The module has no "rebuild" call. Taking both sides away releases
+ // its engine; giving them back makes a new one, and the pin is
+ // applied to it in the "will enable" hook.
+ rebuild = { [weak module] in
+ guard let module, !module.isManualRenderingMode else { return }
+ let saved = module.engineAvailability
+ _ = module.setEngineAvailability(RTCAudioEngineAvailability(isInputAvailable: false, isOutputAvailable: false))
+ let result = module.setEngineAvailability(saved)
+ AudioLog.audio.info("engine rebuilt (result \(result), running \(module.isEngineRunning))")
+ }
+ channels = { uid in CoreAudioInput.device(forModuleID: uid).flatMap(CoreAudioInput.describe) }
+ // Taking the input away and giving it back rewires the microphone,
+ // asking the observer first — which is where the map is applied.
+ // (Re-selecting the same device does nothing: the module ignores a
+ // state that has not changed.)
rebuildInput = { [weak module] in
guard let module, module.isRecording, !module.isManualRenderingMode else { return }
_ = module.setEngineAvailability(
@@ -414,6 +469,14 @@ public struct AudioDevice: Identifiable, Hashable, Sendable {
_ = module.setEngineAvailability(
RTCAudioEngineAvailability(isInputAvailable: true, isOutputAvailable: true))
}
+ logModuleLists = { [weak module] in
+ guard let module else { return }
+ func line(_ list: [RTCIODevice]) -> String {
+ list.map { "\($0.name) [\($0.deviceId)]" }.joined(separator: "; ")
+ }
+ AudioLog.audio.info(
+ "engine module lists (not used to choose): inputs \(line(module.inputDevices), privacy: .public); outputs \(line(module.outputDevices), privacy: .public)")
+ }
}
}
#endif
diff --git a/native/apple/Sources/CallsKit/AudioDevices.swift b/native/apple/Sources/CallsKit/AudioDevices.swift
new file mode 100644
index 0000000..51acaad
--- /dev/null
+++ b/native/apple/Sources/CallsKit/AudioDevices.swift
@@ -0,0 +1,513 @@
+#if os(macOS)
+ import AudioToolbox
+ import AVFoundation
+ import CoreAudio
+ import Foundation
+ import os
+
+ // Which devices a Mac call's audio runs on, and how the call is put on
+ // them.
+ //
+ // Calls 0.3.0 to 0.3.6 chose devices through LiveKit's engine module
+ // (`inputDevice = …`). Paul's Scarlett 18i8 was not picked up at launch
+ // and would not stay chosen (2026-09-25). Read from the module's source
+ // (webrtc-sdk m150_release, audio_engine_device.mm and
+ // RTCAudioDeviceModule.mm) and measured on a Mac, that route has four
+ // faults, any one of which loses the Scarlett:
+ //
+ // 1. The module lists only "default" until the first peer connection
+ // initialises it, and says nothing when it does (no device-update
+ // callback). The app attached to it at launch, found its remembered
+ // Scarlett missing from a one-entry list, said "Scarlett 18i8 USB
+ // isn't connected; using …" and put the call on the default.
+ // 2. Its list drops any device whose input streams have an unknown
+ // terminal type while it also has outputs, and every virtual or
+ // aggregate device, so an interface can be missing from it for good.
+ // 3. It wires the microphone in with the *old* device's format and only
+ // then moves the engine to the chosen device, so a device at another
+ // sample rate starts silent or not at all — and a start that fails
+ // releases the engine it had, leaving the call with no audio.
+ // 4. `trySetInputDevice` answers YES on failure and NO on success.
+ //
+ // So the module is left on its default and never asked to choose. The
+ // app lists devices from CoreAudio itself (ready at launch), and puts the
+ // engine on the chosen device from the module's own "will enable" hook,
+ // before the module reads any format: `AudioEnginePin`. On a Mac the
+ // engine's input and output are one audio unit, so a microphone and a
+ // speaker that are different devices are joined in a private aggregate
+ // device, as macOS itself does for the two system defaults.
+
+ /// The subsystem Paul's `log show` reads:
+ /// `log show --last 1h --predicate 'subsystem == "net.amadan.oficina.calls"'`.
+ enum AudioLog {
+ static let audio = Logger(subsystem: "net.amadan.oficina.calls", category: "audio")
+ }
+
+ /// One CoreAudio device, as the routing rules need it.
+ struct SystemAudioDevice: Equatable, Sendable {
+ var uid: String
+ var name: String
+ var inputChannels: Int
+ var outputChannels: Int
+ var sampleRate: Double = 0
+ var transport: String = ""
+
+ var describe: String {
+ "\(name) [\(uid)] in=\(inputChannels) out=\(outputChannels) \(Int(sampleRate)) Hz \(transport)"
+ }
+ }
+
+ /// What is connected, and which devices macOS has as its defaults.
+ struct AudioInventory: Equatable, Sendable {
+ var devices: [SystemAudioDevice] = []
+ var defaultInput: String?
+ var defaultOutput: String?
+
+ var inputs: [SystemAudioDevice] { devices.filter { $0.inputChannels > 0 } }
+ var outputs: [SystemAudioDevice] { devices.filter { $0.outputChannels > 0 } }
+ func list(_ input: Bool) -> [SystemAudioDevice] { input ? inputs : outputs }
+ func systemDefault(_ input: Bool) -> SystemAudioDevice? {
+ let uid = input ? defaultInput : defaultOutput
+ return devices.first { $0.uid == uid }
+ }
+ }
+
+ /// CoreAudio, read. Property queries only: nothing here opens a device
+ /// or needs microphone permission.
+ enum CoreAudioDevices {
+ /// Private aggregates this app makes (AudioEnginePin), and the one
+ /// AVAudioEngine makes of the two system defaults. Neither is a device
+ /// anyone chose.
+ static let ownAggregatePrefix = "net.amadan.oficina.calls.pair."
+ static func isInternal(uid: String) -> Bool {
+ uid.hasPrefix(ownAggregatePrefix) || uid.hasPrefix("CADefaultDeviceAggregate")
+ }
+
+ static func inventory() -> AudioInventory {
+ var list: [SystemAudioDevice] = []
+ for id in allDeviceIDs() {
+ guard let uid = CoreAudioInput.string(id, kAudioDevicePropertyDeviceUID),
+ !isInternal(uid: uid), !isHidden(id)
+ else { continue }
+ let device = SystemAudioDevice(
+ uid: uid, name: CoreAudioInput.string(id, kAudioObjectPropertyName) ?? uid,
+ inputChannels: channels(id, input: true), outputChannels: channels(id, input: false),
+ sampleRate: sampleRate(id), transport: transport(id))
+ if device.inputChannels > 0 || device.outputChannels > 0 { list.append(device) }
+ }
+ return AudioInventory(
+ devices: list,
+ defaultInput: defaultDevice(input: true).flatMap { CoreAudioInput.string($0, kAudioDevicePropertyDeviceUID) },
+ defaultOutput: defaultDevice(input: false).flatMap { CoreAudioInput.string($0, kAudioDevicePropertyDeviceUID) })
+ }
+
+ static func allDeviceIDs() -> [AudioDeviceID] {
+ var address = AudioObjectPropertyAddress(
+ mSelector: kAudioHardwarePropertyDevices, mScope: kAudioObjectPropertyScopeGlobal,
+ mElement: kAudioObjectPropertyElementMain)
+ var size: UInt32 = 0
+ guard
+ AudioObjectGetPropertyDataSize(AudioObjectID(kAudioObjectSystemObject), &address, 0, nil, &size)
+ == noErr, size > 0
+ else { return [] }
+ var ids = [AudioDeviceID](repeating: 0, count: Int(size) / MemoryLayout<AudioDeviceID>.size)
+ guard
+ AudioObjectGetPropertyData(AudioObjectID(kAudioObjectSystemObject), &address, 0, nil, &size, &ids)
+ == noErr
+ else { return [] }
+ return ids
+ }
+
+ static func defaultDevice(input: Bool) -> AudioDeviceID? {
+ input ? CoreAudioInput.defaultInput() : defaultOutput()
+ }
+
+ static func defaultOutput() -> AudioDeviceID? {
+ var address = AudioObjectPropertyAddress(
+ mSelector: kAudioHardwarePropertyDefaultOutputDevice,
+ mScope: kAudioObjectPropertyScopeGlobal, mElement: kAudioObjectPropertyElementMain)
+ var device = AudioDeviceID(kAudioObjectUnknown)
+ var size = UInt32(MemoryLayout<AudioDeviceID>.size)
+ let status = AudioObjectGetPropertyData(
+ AudioObjectID(kAudioObjectSystemObject), &address, 0, nil, &size, &device)
+ return status == noErr && device != kAudioObjectUnknown ? device : nil
+ }
+
+ static func channels(_ device: AudioDeviceID, input: Bool) -> Int {
+ var address = AudioObjectPropertyAddress(
+ mSelector: kAudioDevicePropertyStreamConfiguration,
+ mScope: input ? kAudioObjectPropertyScopeInput : kAudioObjectPropertyScopeOutput,
+ mElement: kAudioObjectPropertyElementMain)
+ var size: UInt32 = 0
+ guard AudioObjectGetPropertyDataSize(device, &address, 0, nil, &size) == noErr, size > 0
+ else { return 0 }
+ let raw = UnsafeMutableRawPointer.allocate(
+ byteCount: Int(size), alignment: MemoryLayout<AudioBufferList>.alignment)
+ defer { raw.deallocate() }
+ let list = raw.bindMemory(to: AudioBufferList.self, capacity: 1)
+ guard AudioObjectGetPropertyData(device, &address, 0, nil, &size, list) == noErr else { return 0 }
+ return InputChannelMap.count(UnsafeMutableAudioBufferListPointer(list))
+ }
+
+ static func uint32(_ device: AudioDeviceID, _ selector: AudioObjectPropertySelector) -> UInt32? {
+ var address = AudioObjectPropertyAddress(
+ mSelector: selector, mScope: kAudioObjectPropertyScopeGlobal, mElement: kAudioObjectPropertyElementMain)
+ guard AudioObjectHasProperty(device, &address) else { return nil }
+ var value: UInt32 = 0
+ var size = UInt32(MemoryLayout<UInt32>.size)
+ return AudioObjectGetPropertyData(device, &address, 0, nil, &size, &value) == noErr ? value : nil
+ }
+
+ static func isHidden(_ device: AudioDeviceID) -> Bool {
+ uint32(device, kAudioDevicePropertyIsHidden) == 1
+ }
+
+ static func isAlive(_ device: AudioDeviceID) -> Bool {
+ uint32(device, kAudioDevicePropertyDeviceIsAlive) != 0
+ }
+
+ static func sampleRate(_ device: AudioDeviceID) -> Double {
+ var address = AudioObjectPropertyAddress(
+ mSelector: kAudioDevicePropertyNominalSampleRate, mScope: kAudioObjectPropertyScopeGlobal,
+ mElement: kAudioObjectPropertyElementMain)
+ var rate: Float64 = 0
+ var size = UInt32(MemoryLayout<Float64>.size)
+ return AudioObjectGetPropertyData(device, &address, 0, nil, &size, &rate) == noErr ? rate : 0
+ }
+
+ static func transport(_ device: AudioDeviceID) -> String {
+ guard let type = uint32(device, kAudioDevicePropertyTransportType) else { return "" }
+ switch type {
+ case kAudioDeviceTransportTypeBuiltIn: return "built-in"
+ case kAudioDeviceTransportTypeUSB: return "usb"
+ case kAudioDeviceTransportTypeBluetooth, kAudioDeviceTransportTypeBluetoothLE: return "bluetooth"
+ case kAudioDeviceTransportTypeVirtual: return "virtual"
+ case kAudioDeviceTransportTypeAggregate: return "aggregate"
+ case kAudioDeviceTransportTypeThunderbolt: return "thunderbolt"
+ case kAudioDeviceTransportTypeHDMI, kAudioDeviceTransportTypeDisplayPort: return "display"
+ case kAudioDeviceTransportTypeAirPlay: return "airplay"
+ default: return String(format: "0x%08x", type)
+ }
+ }
+
+ static func uid(_ device: AudioDeviceID) -> String? {
+ CoreAudioInput.string(device, kAudioDevicePropertyDeviceUID)
+ }
+
+ /// The device an audio unit is on.
+ static func current(of unit: AudioUnit) -> AudioDeviceID? { CoreAudioInput.device(of: unit) }
+
+ static func setCurrent(_ device: AudioDeviceID, on unit: AudioUnit) -> OSStatus {
+ var device = device
+ return AudioUnitSetProperty(
+ unit, kAudioOutputUnitProperty_CurrentDevice, kAudioUnitScope_Global, 0, &device,
+ UInt32(MemoryLayout<AudioDeviceID>.size))
+ }
+ }
+
+ /// A remembered choice matched against what is connected, and what the
+ /// call does about it. Pure: the tests hold every case of it.
+ struct RouteResolution: Equatable, Sendable {
+ /// The picker's selection: "default", or the chosen device's UID.
+ var current: String
+ /// The device the choice asks the engine for; nil follows the system
+ /// default.
+ var explicit: String?
+ /// Said when the choice cannot be honoured.
+ var note: String?
+ /// A preference to store in place of the old one: the device came back
+ /// under a new UID (a USB interface on another port) and was found by
+ /// its name.
+ var remember: AudioPreference?
+ /// Why, for the log.
+ var reason: String
+ }
+
+ enum AudioRouteRules {
+ static let defaultID = "default"
+
+ /// - Parameters:
+ /// - failed: devices the engine could not start on, by UID, with why.
+ static func resolve(
+ _ preference: AudioPreference?, _ inventory: AudioInventory, input: Bool,
+ failed: [String: String] = [:]
+ ) -> RouteResolution {
+ guard let preference, !preference.id.isEmpty, preference.id != defaultID else {
+ return RouteResolution(
+ current: defaultID, explicit: nil, note: nil,
+ reason: preference == nil ? "nothing chosen: system default" : "system default chosen")
+ }
+ let list = inventory.list(input)
+ var device = list.first { $0.uid == preference.id }
+ var remember: AudioPreference?
+ var how = "by UID"
+ if device == nil, !preference.name.isEmpty {
+ let named = list.filter { $0.name == preference.name }
+ if named.count == 1 {
+ device = named[0]
+ remember = AudioPreference(id: named[0].uid, name: named[0].name)
+ how = "by name (UID was \(preference.id))"
+ }
+ }
+ let what = preference.name.isEmpty ? "The \(input ? "microphone" : "speaker") you chose" : preference.name
+ guard let device else {
+ return RouteResolution(
+ current: defaultID, explicit: nil,
+ note: note("\(what) isn’t connected", fallback: inventory.systemDefault(input), missing: what, input: input),
+ reason: "remembered \(preference.id) (\(preference.name)) is not in CoreAudio's \(input ? "input" : "output") list")
+ }
+ if let why = failed[device.uid] {
+ return RouteResolution(
+ current: defaultID, explicit: nil,
+ note: note("Calls couldn’t open \(device.name)", fallback: inventory.systemDefault(input), missing: device.name, input: input),
+ remember: remember, reason: "\(device.uid) failed to start: \(why)")
+ }
+ return RouteResolution(
+ current: device.uid, explicit: device.uid, note: nil, remember: remember,
+ reason: "remembered device connected, matched \(how)")
+ }
+
+ /// "Scarlett 18i8 USB isn’t connected; using MacBook Pro Microphone."
+ /// Never names the missing device as the one in use: when the default
+ /// has the same name (a second unit of the same model), it is called
+ /// the Mac's default instead.
+ static func note(_ head: String, fallback: SystemAudioDevice?, missing: String, input: Bool) -> String {
+ let generic = "the Mac’s default \(input ? "microphone" : "speaker")"
+ guard let fallback, !fallback.name.isEmpty, fallback.name != missing else {
+ return "\(head); using \(generic)."
+ }
+ return "\(head); using \(fallback.name)."
+ }
+ }
+
+ /// Where the engine's one audio unit should be: left to AVAudioEngine's
+ /// own default, on one device, or on a pair joined in an aggregate.
+ enum EngineTarget: Equatable, Sendable, CustomStringConvertible {
+ case systemDefault
+ case device(String)
+ case pair(input: String, output: String)
+
+ /// Planned as for a call that both plays and records, even while the
+ /// engine only plays: the module makes its engine for playout first and
+ /// adds the microphone to that same engine later, and a unit moved to
+ /// another device in between would keep the format it was wired with.
+ ///
+ /// - Parameters:
+ /// - input/output: the explicit choices (nil follows the default).
+ static func plan(input: String?, output: String?, defaultInput: String?, defaultOutput: String?)
+ -> EngineTarget
+ {
+ guard input != nil || output != nil else { return .systemDefault }
+ switch (input ?? defaultInput, output ?? defaultOutput) {
+ case (let i?, let o?): return i == o ? .device(i) : .pair(input: i, output: o)
+ case (let i?, nil): return .device(i)
+ case (nil, let o?): return .device(o)
+ case (nil, nil): return .systemDefault
+ }
+ }
+
+ var description: String {
+ switch self {
+ case .systemDefault: return "system default"
+ case .device(let uid): return uid
+ case .pair(let i, let o): return "\(i) + \(o)"
+ }
+ }
+ }
+
+ /// The chosen devices, handed from the main actor to the module's
+ /// thread, and applied to each engine the module makes before it reads a
+ /// format from it. Also where the engine's actual device is read back.
+ final class AudioEnginePin: @unchecked Sendable {
+ private let lock = NSLock()
+ private var input: String?
+ private var output: String?
+ private var aggregate: (key: EngineTarget, id: AudioDeviceID, uid: String, offset: Int, outputOffset: Int)?
+ /// What the last engine was put on, and whether that was an explicit
+ /// choice. Read on the main actor to tell whether a rebuild is due.
+ private var applied: EngineTarget?
+
+ /// Returns true when the choice changed.
+ func set(input: String?, output: String?) -> Bool {
+ lock.lock()
+ defer { lock.unlock() }
+ guard input != self.input || output != self.output else { return false }
+ self.input = input
+ self.output = output
+ return true
+ }
+
+ var choices: (input: String?, output: String?) {
+ lock.lock()
+ defer { lock.unlock() }
+ return (input, output)
+ }
+
+ var lastApplied: EngineTarget? {
+ lock.lock()
+ defer { lock.unlock() }
+ return applied
+ }
+
+ /// What putting an engine on its devices came to.
+ enum Outcome: Equatable {
+ case applied(EngineTarget)
+ /// The devices could not be set; the engine is on the default.
+ case failed(EngineTarget, String)
+ }
+
+ /// The target for the choices as they stand, against the defaults as
+ /// they stand. Devices that have gone are left out, so a microphone
+ /// unplugged a moment ago is not asked for.
+ func target() -> EngineTarget {
+ let (input, output) = choices
+ return EngineTarget.plan(
+ input: input.flatMap(Self.alive), output: output.flatMap(Self.alive),
+ defaultInput: CoreAudioDevices.defaultDevice(input: true).flatMap(CoreAudioDevices.uid),
+ defaultOutput: CoreAudioDevices.defaultDevice(input: false).flatMap(CoreAudioDevices.uid))
+ }
+
+ /// Called from the module's "will enable" hook, before it reads the
+ /// engine's formats and wires anything.
+ func apply(to engine: AVAudioEngine, recording: Bool) -> Outcome {
+ let target = target()
+ lock.lock()
+ applied = target
+ lock.unlock()
+ guard target != .systemDefault else { return .applied(target) }
+ func fail(_ why: String) -> Outcome {
+ lock.lock()
+ applied = .systemDefault
+ lock.unlock()
+ return .failed(target, why)
+ }
+ guard let device = resolve(target) else { return fail("no device could be made for it") }
+ // Input and output are one unit on a Mac; asking for `inputNode`
+ // makes an input, so it is only asked for when recording.
+ guard let unit = recording ? engine.inputNode.audioUnit : engine.outputNode.audioUnit else {
+ return fail("the engine has no audio unit")
+ }
+ let status = CoreAudioDevices.setCurrent(device, on: unit)
+ return status == noErr ? .applied(target) : fail("setting the device failed, OSStatus \(status)")
+ }
+
+ /// The input device behind a device an engine is on, and where its
+ /// channels start on it (0: our aggregates put the microphone first).
+ /// Nil for a device that is not one of ours.
+ func inputLayout(of device: AudioDeviceID) -> (uid: String, offset: Int)? {
+ lock.lock()
+ defer { lock.unlock() }
+ guard let aggregate, aggregate.id == device, case .pair(let input, _) = aggregate.key else { return nil }
+ return (input, aggregate.offset)
+ }
+
+ /// The microphone the engine is actually on, read back from its unit:
+ /// the device itself, the microphone half of one of our aggregates, or
+ /// the system default input under macOS's own aggregate.
+ func actualInput(of unit: AudioUnit) -> String? {
+ guard let device = CoreAudioDevices.current(of: unit), let uid = CoreAudioDevices.uid(device) else {
+ return nil
+ }
+ if let layout = inputLayout(of: device) { return layout.uid }
+ if CoreAudioDevices.isInternal(uid: uid) {
+ return CoreAudioDevices.defaultDevice(input: true).flatMap(CoreAudioDevices.uid)
+ }
+ return uid
+ }
+
+ private static func alive(_ uid: String) -> String? {
+ guard let id = CoreAudioInput.device(uid: uid), CoreAudioDevices.isAlive(id) else { return nil }
+ return uid
+ }
+
+ private func resolve(_ target: EngineTarget) -> AudioDeviceID? {
+ switch target {
+ case .systemDefault: return nil
+ case .device(let uid): return CoreAudioInput.device(uid: uid)
+ case .pair(let input, let output):
+ lock.lock()
+ if let aggregate, aggregate.key == target, CoreAudioDevices.isAlive(aggregate.id) {
+ lock.unlock()
+ return aggregate.id
+ }
+ let old = aggregate
+ aggregate = nil
+ lock.unlock()
+ if let old { AudioHardwareDestroyAggregateDevice(old.id) }
+ guard let made = Self.makePair(input: input, output: output) else { return nil }
+ lock.lock()
+ aggregate = (target, made.id, made.uid, made.offset, made.outputOffset)
+ lock.unlock()
+ return made.id
+ }
+ }
+
+ /// A private aggregate of a microphone and a speaker. The microphone
+ /// goes first: AVAudioEngine refuses an aggregate whose first device
+ /// has no input ("IsFormatSampleRateAndChannelCountValid(outputHWFormat)",
+ /// measured on macOS 26 with the MacBook's own two), and its inputs then
+ /// start at channel 1. The speaker's clock leads, with the microphone
+ /// drift-corrected to it. A microphone with outputs of its own (an
+ /// interface) puts those ahead of the speaker's, so the engine's output
+ /// is mapped past them (`outputOffset`, applied when it starts).
+ private static func makePair(input: String, output: String)
+ -> (id: AudioDeviceID, uid: String, offset: Int, outputOffset: Int)?
+ {
+ let uid = CoreAudioDevices.ownAggregatePrefix + UUID().uuidString
+ let description: [String: Any] = [
+ kAudioAggregateDeviceNameKey: "Calls",
+ kAudioAggregateDeviceUIDKey: uid,
+ kAudioAggregateDeviceIsPrivateKey: 1,
+ kAudioAggregateDeviceMainSubDeviceKey: output,
+ kAudioAggregateDeviceSubDeviceListKey: [
+ [kAudioSubDeviceUIDKey: input, kAudioSubDeviceDriftCompensationKey: 1],
+ [kAudioSubDeviceUIDKey: output],
+ ],
+ ]
+ var id = AudioDeviceID(kAudioObjectUnknown)
+ let status = AudioHardwareCreateAggregateDevice(description as CFDictionary, &id)
+ guard status == noErr, id != kAudioObjectUnknown else {
+ AudioLog.audio.error("aggregate of \(input, privacy: .public) + \(output, privacy: .public) refused, OSStatus \(status)")
+ return nil
+ }
+ let outputOffset = CoreAudioInput.device(uid: input).map { CoreAudioDevices.channels($0, input: false) } ?? 0
+ AudioLog.audio.info(
+ "aggregate \(uid, privacy: .public): in \(input, privacy: .public), out \(output, privacy: .public) from channel \(outputOffset + 1); in=\(CoreAudioDevices.channels(id, input: true)) out=\(CoreAudioDevices.channels(id, input: false))")
+ return (id, uid, 0, outputOffset)
+ }
+
+ /// Sends the engine's output to the speaker's channels of one of our
+ /// aggregates when the microphone's own outputs come first. Called as
+ /// the engine starts, once its output format is set.
+ func mapOutput(on unit: AudioUnit) {
+ guard let device = CoreAudioDevices.current(of: unit) else { return }
+ lock.lock()
+ let offset = aggregate.flatMap { $0.id == device ? $0.outputOffset : nil } ?? 0
+ lock.unlock()
+ guard offset > 0 else { return }
+ var format = AudioStreamBasicDescription()
+ var size = UInt32(MemoryLayout<AudioStreamBasicDescription>.size)
+ guard AudioUnitGetProperty(unit, kAudioUnitProperty_StreamFormat, kAudioUnitScope_Input, 0, &format, &size) == noErr
+ else { return }
+ let total = CoreAudioDevices.channels(device, input: false)
+ let client = Int(format.mChannelsPerFrame)
+ var map = [Int32](repeating: -1, count: total)
+ for k in 0..<client where offset + k < total { map[offset + k] = Int32(k) }
+ let status = AudioUnitSetProperty(
+ unit, kAudioOutputUnitProperty_ChannelMap, kAudioUnitScope_Input, 0, &map,
+ UInt32(MemoryLayout<Int32>.size * map.count))
+ AudioLog.audio.info("output channel map past \(offset) microphone outputs: OSStatus \(status)")
+ }
+
+ /// The aggregate goes with the call's audio.
+ func close() {
+ lock.lock()
+ let old = aggregate
+ aggregate = nil
+ lock.unlock()
+ if let old { AudioHardwareDestroyAggregateDevice(old.id) }
+ }
+
+ deinit { close() }
+ }
+#endif
diff --git a/native/apple/Sources/CallsKit/CallAudio.swift b/native/apple/Sources/CallsKit/CallAudio.swift
index 05772fa..b3f6a25 100644
--- a/native/apple/Sources/CallsKit/CallAudio.swift
+++ b/native/apple/Sources/CallsKit/CallAudio.swift
@@ -121,6 +121,8 @@ public struct ProcessingReport: Equatable, Sendable {
#if os(macOS)
private let devices: EngineWatch
+ /// Where the engine is put (AudioDevices.swift); read by the tests.
+ var enginePin: AudioEnginePin { devices.pin }
#endif
/// `audioDevice` is the iPhone tests' silent device. It takes the old
@@ -171,12 +173,15 @@ public struct ProcessingReport: Equatable, Sendable {
// canceller. WebRTC's software processing is what ran before this
// module, and is what runs now.
_ = module.setPlatformVoiceProcessingAllowed(false)
- devices = EngineWatch(routes: routes, input: input, channels: InputChannelMapper(defaults: routes.defaults))
+ let pin = AudioEnginePin()
+ devices = EngineWatch(
+ routes: routes, module: module, input: input, pin: pin,
+ channels: InputChannelMapper(defaults: routes.defaults, layout: pin.inputLayout))
module.observer = devices
if input != nil { _ = module.setManualRenderingMode(true) }
// Attached in manual rendering too, so the interop harness drives the
// same routing code as the app; no device is opened either way.
- routes.attach(module)
+ routes.attach(module, pin: pin)
#endif
// The listener first, then the delegate: attaching the delegate can
// initialise it at once, and that report used to go nowhere.
@@ -317,20 +322,32 @@ public struct ProcessingReport: Equatable, Sendable {
#if os(macOS)
/// The module's observer. The device-list callback keeps the pickers
- /// current; configuring the input is where a synthetic source is wired
- /// in, or — for a real microphone — where the chosen input of a
- /// multi-channel one is mapped (InputChannel.swift). The rest of the
- /// protocol is the engine lifecycle, left alone (every one of those
- /// answers 0, "carry on") except that the map is applied once more just
- /// before the engine starts.
+ /// current; "will enable" is where the engine is put on the chosen
+ /// devices, before the module reads a format from it (AudioDevices.swift);
+ /// configuring the input is where a synthetic source is wired in, or —
+ /// for a real microphone — where the chosen input of a multi-channel one
+ /// is mapped (InputChannel.swift). Starting and stopping are watched so a
+ /// device the engine cannot start on is noticed rather than left silent.
private final class EngineWatch: NSObject, RTCAudioDeviceModuleDelegate, @unchecked Sendable {
weak var routes: AudioRoutes?
+ weak var module: RTCAudioDeviceModule?
let input: AudioInputSource?
+ let pin: AudioEnginePin
let channels: InputChannelMapper
private var node: AVAudioSourceNode?
- init(routes: AudioRoutes, input: AudioInputSource?, channels: InputChannelMapper) {
+ private let lock = NSLock()
+ /// Starts and stops seen, so a start that is followed by neither a
+ /// running engine nor a deliberate stop is known to have failed.
+ private var starts = 0
+ private var stops = 0
+ init(
+ routes: AudioRoutes, module: RTCAudioDeviceModule, input: AudioInputSource?, pin: AudioEnginePin,
+ channels: InputChannelMapper
+ ) {
self.routes = routes
+ self.module = module
self.input = input
+ self.pin = pin
self.channels = channels
}
@@ -340,6 +357,25 @@ public struct ProcessingReport: Equatable, Sendable {
}
}
+ func audioDeviceModule(
+ _ audioDeviceModule: RTCAudioDeviceModule, willEnableEngine engine: AVAudioEngine,
+ isPlayoutEnabled: Bool, isRecordingEnabled: Bool, isVoiceProcessingEnabled: Bool
+ ) -> Int {
+ guard input == nil, !engine.isInManualRenderingMode else { return 0 }
+ let outcome = pin.apply(to: engine, recording: isRecordingEnabled)
+ switch outcome {
+ case .applied(let target):
+ AudioLog.audio.info(
+ "engine will enable (playout \(isPlayoutEnabled), recording \(isRecordingEnabled)): on \(target.description, privacy: .public)")
+ case .failed(let target, let why):
+ AudioLog.audio.error(
+ "engine will enable: could not put it on \(target.description, privacy: .public): \(why, privacy: .public)")
+ DispatchQueue.main.async { [weak self] in
+ MainActor.assumeIsolated { self?.routes?.engineFailed(target, reason: why) }
+ }
+ }
+ return 0
+ }
func audioDeviceModule(
_ audioDeviceModule: RTCAudioDeviceModule,
didReceiveSpeechActivityEvent speechActivityEvent: RTCSpeechActivityEvent
@@ -354,22 +390,66 @@ public struct ProcessingReport: Equatable, Sendable {
// By now the engine has connected the input and set the unit's
// client format, so the map is sized to what it will run with.
// Only with the microphone in use: asking for `inputNode` makes one.
- if input == nil, isRecordingEnabled, !engine.isInManualRenderingMode {
- channels.apply(to: engine.inputNode)
+ guard input == nil, !engine.isInManualRenderingMode else { return 0 }
+ if isRecordingEnabled { channels.apply(to: engine.inputNode) }
+ let unit = isRecordingEnabled ? engine.inputNode.audioUnit : engine.outputNode.audioUnit
+ if isPlayoutEnabled, let unit { pin.mapOutput(on: unit) }
+ let actual = unit.flatMap(pin.actualInput)
+ let target = pin.lastApplied ?? .systemDefault
+ if isRecordingEnabled {
+ let format = engine.inputNode.inputFormat(forBus: 0)
+ AudioLog.audio.info(
+ "engine starting on \(target.description, privacy: .public): microphone \(actual ?? "unknown", privacy: .public), \(format.channelCount) ch at \(Int(format.sampleRate)) Hz")
+ } else {
+ AudioLog.audio.info("engine starting on \(target.description, privacy: .public) for playout only")
+ }
+ lock.lock()
+ starts += 1
+ let seen = (starts, stops)
+ lock.unlock()
+ DispatchQueue.main.async { [weak self] in
+ MainActor.assumeIsolated {
+ if isRecordingEnabled { self?.routes?.engineStarted(input: actual) }
+ }
+ }
+ // The module retries a start a few times and then gives up without
+ // telling its observer. Looked at once it has had time: an engine
+ // that is not running, with no stop since, did not start.
+ DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
+ MainActor.assumeIsolated {
+ guard let self, let module = self.module else { return }
+ self.lock.lock()
+ let now = (self.starts, self.stops)
+ self.lock.unlock()
+ guard now == seen, !module.isEngineRunning else { return }
+ AudioLog.audio.error("engine did not start on \(target.description, privacy: .public)")
+ self.routes?.engineFailed(target, reason: "the audio engine would not start on it")
+ }
}
return 0
}
func audioDeviceModule(
_ audioDeviceModule: RTCAudioDeviceModule, didStopEngine engine: AVAudioEngine,
isPlayoutEnabled: Bool, isRecordingEnabled: Bool
- ) -> Int { 0 }
+ ) -> Int {
+ stopped()
+ return 0
+ }
func audioDeviceModule(
_ audioDeviceModule: RTCAudioDeviceModule, didDisableEngine engine: AVAudioEngine,
isPlayoutEnabled: Bool, isRecordingEnabled: Bool
) -> Int { 0 }
func audioDeviceModule(
_ audioDeviceModule: RTCAudioDeviceModule, willReleaseEngine engine: AVAudioEngine
- ) -> Int { 0 }
+ ) -> Int {
+ stopped()
+ return 0
+ }
+ private func stopped() {
+ lock.lock()
+ stops += 1
+ lock.unlock()
+ }
func audioDeviceModule(
_ audioDeviceModule: RTCAudioDeviceModule, engine: AVAudioEngine,
configureInputFromSource source: AVAudioNode?, toDestination destination: AVAudioNode,
diff --git a/native/apple/Sources/CallsKit/InputChannel.swift b/native/apple/Sources/CallsKit/InputChannel.swift
index a50db7a..f9de6a5 100644
--- a/native/apple/Sources/CallsKit/InputChannel.swift
+++ b/native/apple/Sources/CallsKit/InputChannel.swift
@@ -79,13 +79,21 @@
/// (0-based), so a mono mixdown of them — or the first of them — is
/// that input. Nil means leave the unit alone: input 1 on a unit this
/// app has never mapped is exactly what the module does already.
- public static func map(channel: Int, clientChannels: Int, mappedBefore: Bool) -> [Int32]? {
+ ///
+ /// `offset` is where the microphone's channels start on the unit's
+ /// device: 0 for the microphone itself, and past the speaker's own
+ /// inputs on an aggregate that joins the two (AudioEnginePin), where
+ /// input 1 of the microphone is not input 1 of the device.
+ public static func map(channel: Int, clientChannels: Int, mappedBefore: Bool, offset: Int = 0)
+ -> [Int32]?
+ {
let width = max(clientChannels, 1)
- if channel <= 0 {
+ let source = max(channel, 0) + max(offset, 0)
+ if source <= 0 {
// Undo an earlier choice on the same unit: straight through.
return mappedBefore ? (0..<width).map { Int32($0) } : nil
}
- return Array(repeating: Int32(channel), count: width)
+ return Array(repeating: Int32(source), count: width)
}
/// Input channels in a stream configuration: the sum over its buffers.
@@ -208,34 +216,45 @@
/// time the unit's client format is the one it will run with).
final class InputChannelMapper: @unchecked Sendable {
private let defaults: UserDefaults
+ /// The microphone behind an aggregate the engine is on, and where its
+ /// channels start (AudioEnginePin.inputLayout). Nil for a device that
+ /// is the microphone itself.
+ private let layout: (AudioDeviceID) -> (uid: String, offset: Int)?
private let lock = NSLock()
private var mapped = false
/// What was last applied, for the readout: the device and the channel.
private(set) var applied: (uid: String, channel: Int)?
- init(defaults: UserDefaults) { self.defaults = defaults }
+ init(defaults: UserDefaults, layout: @escaping (AudioDeviceID) -> (uid: String, offset: Int)? = { _ in nil }) {
+ self.defaults = defaults
+ self.layout = layout
+ }
func apply(to node: AVAudioNode?) {
guard let node = node as? AVAudioInputNode, let unit = node.audioUnit,
let device = CoreAudioInput.device(of: unit),
- let uid = CoreAudioInput.string(device, kAudioDevicePropertyDeviceUID)
+ let unitUID = CoreAudioInput.string(device, kAudioDevicePropertyDeviceUID)
else { return }
- let count = CoreAudioInput.inputChannelCount(device)
+ let (uid, offset) = layout(device) ?? (unitUID, 0)
+ let count = CoreAudioInput.device(uid: uid).map(CoreAudioInput.inputChannelCount) ?? 0
let channel = InputChannelPreference.load(uid: uid, channels: count, defaults)
lock.lock()
defer { lock.unlock() }
guard
let map = InputChannelMap.map(
channel: channel, clientChannels: CoreAudioInput.clientChannels(of: unit),
- mappedBefore: mapped)
+ mappedBefore: mapped, offset: offset)
else {
applied = (uid, 0)
return
}
- if CoreAudioInput.setChannelMap(map, on: unit) == noErr {
- mapped = channel > 0
+ let status = CoreAudioInput.setChannelMap(map, on: unit)
+ if status == noErr {
+ mapped = channel + offset > 0
applied = (uid, channel)
}
+ AudioLog.audio.info(
+ "input channel map \(map.map(String.init).joined(separator: ","), privacy: .public) for \(uid, privacy: .public) input \(channel + 1): OSStatus \(status)")
}
}
#endif
diff --git a/native/apple/Tests/CallsKitTests/AudioHealthTests.swift b/native/apple/Tests/CallsKitTests/AudioHealthTests.swift
index 6c7f093..8f2242a 100644
--- a/native/apple/Tests/CallsKitTests/AudioHealthTests.swift
+++ b/native/apple/Tests/CallsKitTests/AudioHealthTests.swift
@@ -105,10 +105,19 @@ final class AudioRoutesTests: XCTestCase {
XCTAssertThrowsError(try routes.use(input: "anything"))
let audio = CallAudio(routes: routes, audioDevice: nil)
- _ = audio
XCTAssertTrue(routes.selectable)
- // What this machine's module lists, for the record: every input
- // device macOS has, named as macOS names it, the default among them.
+ // The regression behind Paul's "not picking up the Scarlett at
+ // launch": the engine module lists only its "default" entry until a
+ // connection initialises it. The pickers come from CoreAudio, which
+ // is complete now.
+ XCTAssertEqual(
+ audio.factory.audioDeviceModule.inputDevices.map(\.deviceId), ["default"],
+ "LiveKit's module has not enumerated before the first connection")
+ XCTAssertEqual(
+ routes.inputs.count, CoreAudioDevices.inventory().inputs.count + 1,
+ "every CoreAudio input, and System default, before any connection")
+ // What this machine lists, for the record: every input device macOS
+ // has, named as macOS names it, the default among them.
for device in routes.inputs { print("audio input: \(device.name) [\(device.id)]") }
for device in routes.outputs { print("audio output: \(device.name) [\(device.id)]") }
// A machine with no input at all is possible, so this asserts the
@@ -127,9 +136,8 @@ final class AudioRoutesTests: XCTestCase {
XCTAssertNoThrow(try routes.use(input: first.id))
XCTAssertEqual(routes.currentInput, first.id)
}
- // A device other than the default, on the real module: the choice
- // must hold, although this module cannot read back which device it
- // is on (see AudioRoutes). Choosing only records it; nothing records.
+ // A device other than the default: the choice must hold. Choosing
+ // only records it; no engine is running, so nothing records.
if let other = routes.inputs.first(where: { !$0.name.hasPrefix("System default") }) {
XCTAssertNoThrow(try routes.use(input: other.id))
XCTAssertEqual(routes.currentInput, other.id)
diff --git a/native/apple/Tests/CallsKitTests/AudioRoutesTests.swift b/native/apple/Tests/CallsKitTests/AudioRoutesTests.swift
index 90ec0db..4973f72 100644
--- a/native/apple/Tests/CallsKitTests/AudioRoutesTests.swift
+++ b/native/apple/Tests/CallsKitTests/AudioRoutesTests.swift
@@ -2,36 +2,61 @@
import XCTest
@testable import CallsKit
- /// The Mac's remembered routing rules, held against a stand-in for the
- /// audio device module 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.
+ /// 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 {
- private final class Module {
- var defaultInput = "Built-in Microphone"
- var defaultOutput = "Built-in Speakers"
- var inputs = ["built-in", "usb"]
- var outputs = ["speakers", "headset"]
- var selected: [(id: String, input: Bool)] = []
-
- func control() -> AudioModuleControl {
- AudioModuleControl(
- devices: { input in
- let head = ModuleDevice(
- id: "default", name: input ? self.defaultInput : self.defaultOutput, isDefault: true)
- return [head]
- + (input ? self.inputs : self.outputs).map {
- ModuleDevice(id: $0, name: $0, isDefault: false)
- }
+ /// 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
},
- select: { id, input in
- guard id == "default" || (input ? self.inputs : self.outputs).contains(id) else {
- return false
- }
- self.selected.append((id, input))
- return true
+ 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!
@@ -47,127 +72,339 @@
super.tearDown()
}
- private func routes(_ module: Module) -> AudioRoutes {
+ private func routes(_ machine: Machine) -> AudioRoutes {
let routes = AudioRoutes(defaults: store)
- routes.attach(module.control())
+ routes.attach(machine.control())
return routes
}
func testFirstCallStartsOnTheSystemDefault() {
- let module = Module()
- let routes = routes(module)
+ let machine = Machine()
+ let routes = routes(machine)
XCTAssertEqual(routes.currentInput, "default")
XCTAssertEqual(routes.currentOutput, "default")
- XCTAssertEqual(routes.inputs.first?.name, "System default (Built-in Microphone)")
- XCTAssertEqual(routes.outputName, "System default (Built-in Speakers)")
- XCTAssertTrue(module.selected.isEmpty, "the module already follows the 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)
+ }
+ }
}
- /// Paul, 2026-09-24: "it should remember the Scarlett between app
- /// launches". A new AudioRoutes on the same defaults is a new launch.
func testChoicesAreRememberedAcrossCallsAndLaunches() throws {
- let routes = routes(Module())
+ 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 = Module()
+ let next = Machine()
let launch = self.routes(next)
XCTAssertEqual(launch.currentInput, "usb")
XCTAssertEqual(launch.currentOutput, "headset")
- XCTAssertEqual(next.selected.map(\.id), ["usb", "headset"], "applied at join, before anyone asks")
+ 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")
+ XCTAssertEqual(store.string(forKey: "audioInputName"), "USB Mic")
}
func testSystemDefaultIsRememberedWhenPicked() throws {
- let routes = routes(Module())
+ 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")
- let next = Module()
+ 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.selected.isEmpty)
- }
-
- func testMissingDeviceAtJoinUsesTheDefaultSaysSoAndReturns() {
- AudioPreference(id: "scarlett", name: "Scarlett 18i8").save(input: true, store)
- let module = Module()
- let routes = routes(module)
- XCTAssertEqual(routes.currentInput, "default")
- XCTAssertEqual(routes.routeNote, "Scarlett 18i8 isn’t connected; using Built-in Microphone.")
- XCTAssertTrue(module.selected.isEmpty)
- module.inputs.append("scarlett")
- routes.refresh()
- XCTAssertEqual(routes.currentInput, "scarlett")
- XCTAssertEqual(module.selected.last?.id, "scarlett")
- XCTAssertEqual(routes.routeNote, "")
- XCTAssertEqual(AudioPreference.load(input: true, store)?.id, "scarlett", "the preference was kept")
+ XCTAssertTrue(next.pinned == (nil, nil))
}
func testDisconnectMidCallFallsBackAndComesBackOnItsOwn() throws {
- let module = Module()
- let routes = routes(module)
+ let machine = Machine()
+ let routes = routes(machine)
+ machine.start()
try routes.use(input: "usb")
try routes.use(output: "headset")
- module.inputs = ["built-in"]
+ XCTAssertEqual(machine.rebuilds, 2)
+ machine.devices.removeAll { $0.uid == "usb" }
routes.refresh()
XCTAssertEqual(routes.currentInput, "default")
XCTAssertEqual(routes.currentOutput, "headset")
- XCTAssertEqual(module.selected.last?.id, "default")
- XCTAssertEqual(module.selected.last?.input, true)
- XCTAssertEqual(routes.routeNote, "usb isn’t connected; using Built-in Microphone.")
- let count = module.selected.count
+ 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(module.selected.count, count, "the module is moved once, not on every look")
- module.inputs = ["built-in", "usb"]
+ 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(module.selected.last?.id, "usb")
+ XCTAssertEqual(machine.applied, .pair(input: "usb", output: "headset"))
XCTAssertEqual(routes.routeNote, "")
}
func testRefusedSwitchKeepsPreviousSelection() throws {
- let module = Module()
- let routes = routes(module)
+ 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(module.selected.count, 1, "a refused choice never reaches the module")
XCTAssertEqual(store.string(forKey: "audioInputUID"), "usb", "nor the memory")
}
func testSystemDefaultFollowsChangesButExplicitChoiceDoesNot() throws {
- let module = Module()
- let routes = routes(module)
- module.defaultOutput = "Headset"
+ let machine = Machine()
+ let routes = routes(machine)
+ machine.start()
+ machine.defaultOutput = "headset"
routes.refresh()
XCTAssertEqual(routes.currentOutput, "default")
XCTAssertEqual(routes.outputName, "System default (Headset)")
- XCTAssertTrue(module.selected.isEmpty)
- try routes.use(output: "speakers")
- let count = module.selected.count
- module.defaultOutput = "Built-in Speakers"
+ 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(module.selected.count, count)
- XCTAssertEqual(routes.currentOutput, "speakers")
+ 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: ""))
+ 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")
+ }
+
+ 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.
+ 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() 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: "")
+ XCTAssertEqual(audio.enginePin.lastApplied, .systemDefault, "rebuilt on the default")
+ XCTAssertTrue(module.isEngineRunning)
+ }
+ }
#endif
diff --git a/native/apple/Tests/CallsKitTests/InputChannelTests.swift b/native/apple/Tests/CallsKitTests/InputChannelTests.swift
index cfafd6a..f34076e 100644
--- a/native/apple/Tests/CallsKitTests/InputChannelTests.swift
+++ b/native/apple/Tests/CallsKitTests/InputChannelTests.swift
@@ -79,12 +79,14 @@
private func routes(channels: InputDeviceChannels?, rebuilt: @escaping () -> Void) -> AudioRoutes {
let routes = AudioRoutes(defaults: store)
routes.attach(
- AudioModuleControl(
- devices: { input in
- [ModuleDevice(id: "default", name: "Default", isDefault: true)]
- + (input ? [ModuleDevice(id: "scarlett", name: "Scarlett 18i8 USB", isDefault: false)] : [])
+ AudioEngineControl(
+ inventory: {
+ AudioInventory(
+ devices: [
+ SystemAudioDevice(uid: "mbp", name: "MacBook Pro Microphone", inputChannels: 1, outputChannels: 0),
+ SystemAudioDevice(uid: "scarlett", name: "Scarlett 18i8 USB", inputChannels: 4, outputChannels: 0),
+ ], defaultInput: "mbp", defaultOutput: nil)
},
- select: { _, _ in true },
channels: { id in id == "scarlett" ? channels : nil },
rebuildInput: rebuilt))
return routes
diff --git a/native/apple/Tests/CallsKitTests/ScarlettReproTests.swift b/native/apple/Tests/CallsKitTests/ScarlettReproTests.swift
new file mode 100644
index 0000000..588cc6b
--- /dev/null
+++ b/native/apple/Tests/CallsKitTests/ScarlettReproTests.swift
@@ -0,0 +1,57 @@
+#if os(macOS)
+ import CoreAudio
+ import LiveKitWebRTC
+ import XCTest
+ @testable import CallsKit
+
+ /// Paul's first-launch report, reproduced without a Scarlett: a public
+ /// aggregate device stands in for an interface that is connected but is
+ /// not the system default, and is remembered as the microphone. Opt-in
+ /// (CALLS_AUDIO_REPRO=1) because it adds a device to the Mac for the
+ /// length of the test.
+ final class ScarlettReproTests: XCTestCase {
+ @MainActor func testARememberedNonDefaultInterfaceIsUsedAtFirstLaunch() throws {
+ guard ProcessInfo.processInfo.environment["CALLS_AUDIO_REPRO"] == "1" else {
+ throw XCTSkip("set CALLS_AUDIO_REPRO=1 to add a stand-in interface to this Mac")
+ }
+ let inventory = CoreAudioDevices.inventory()
+ guard let mic = inventory.defaultInput, let speaker = inventory.defaultOutput else {
+ throw XCTSkip("no default devices")
+ }
+ let uid = "net.amadan.oficina.calls.repro.\(UUID().uuidString)"
+ let description: [String: Any] = [
+ kAudioAggregateDeviceNameKey: "Stand-in Scarlett", kAudioAggregateDeviceUIDKey: uid,
+ kAudioAggregateDeviceMainSubDeviceKey: speaker,
+ kAudioAggregateDeviceSubDeviceListKey: [
+ [kAudioSubDeviceUIDKey: mic, kAudioSubDeviceDriftCompensationKey: 1], [kAudioSubDeviceUIDKey: speaker],
+ ],
+ ]
+ var device = AudioDeviceID(0)
+ XCTAssertEqual(AudioHardwareCreateAggregateDevice(description as CFDictionary, &device), noErr)
+ defer { AudioHardwareDestroyAggregateDevice(device) }
+ RunLoop.main.run(until: Date().addingTimeInterval(0.5))
+
+ let suite = "calls-repro-\(UUID().uuidString)"
+ let store = UserDefaults(suiteName: suite)!
+ defer { store.removePersistentDomain(forName: suite) }
+ store.set(uid, forKey: "audioInputUID")
+ store.set("Stand-in Scarlett", forKey: "audioInputName")
+
+ let routes = AudioRoutes(defaults: store)
+ let audio = CallAudio(routes: routes, audioDevice: nil)
+ print("REPRO at launch: input \(routes.currentInput ?? "nil"), note \"\(routes.routeNote)\"")
+ XCTAssertEqual(routes.currentInput, uid, "the remembered interface at launch")
+ XCTAssertEqual(routes.routeNote, "")
+
+ let pc = audio.factory.peerConnection(
+ with: RTCConfiguration(),
+ constraints: RTCMediaConstraints(mandatoryConstraints: nil, optionalConstraints: nil), delegate: nil)
+ defer { pc?.close() }
+ let listed = audio.factory.audioDeviceModule.inputDevices.map(\.deviceId)
+ print("REPRO engine module inputs after the first connection: \(listed)")
+ routes.refresh()
+ XCTAssertEqual(routes.currentInput, uid, "and after the first connection")
+ XCTAssertEqual(routes.routeNote, "")
+ }
+ }
+#endif