oficina / meet Public

Choose the Mac microphone and speaker on WebRTC's own module

The Mac app now runs on LiveKit's AVAudioEngine audio device module,
which lists input and output devices and switches between them mid-call.
The device panel's read-only CoreAudio readout and its Sound settings
button give way to a microphone picker and a speaker picker; the audio
health readout below them is unchanged. A choice moves this call and
leaves the Mac's system setting alone.

Apple's voice processing is not allowed on Mac. Left allowed, the module
resolves echo cancellation, noise suppression and gain to Apple's coupled
voice-processing unit (the probe shows all three resolving to platform);
with it off WebRTC's software processing runs, as it always has on Mac.
macOS offers its Mic Mode menu only to apps on voice processing, so Calls
on Mac is not offered it.

The interop harness cannot open a microphone, and on this module that
meant no audio was sent at all. CallsInterop now feeds a synthetic voice
through the module's manual rendering mode, as it feeds synthetic video;
no device is opened. iPhone keeps the platform-default module.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev b1ee2b38afc58e244160d4553a3586946fb29a2f parent e51e746
9 files changed, +347 −141
  • native/README.md +21 −14
  • native/apple/Sources/CallsInterop/Interop.swift +1 −1
  • native/apple/Sources/CallsInterop/SyntheticVoice.swift +25 −0
  • native/apple/Sources/CallsKit/Audio.swift +83 −72
  • native/apple/Sources/CallsKit/Call.swift +9 −5
  • native/apple/Sources/CallsKit/CallAudio.swift +144 −0
  • native/apple/Sources/CallsKit/DevicePanel.swift +34 −22
  • native/apple/Sources/CallsKit/Media.swift +6 −7
  • native/apple/Tests/CallsKitTests/AudioHealthTests.swift +24 −20
diff --git a/native/README.md b/native/README.md
index 2713786..8991207 100644
--- a/native/README.md
+++ b/native/README.md
@@ -37,20 +37,27 @@ connection already holds does not change and nothing renegotiates — the
native counterpart of the browser's `replaceTrack`. Switches are serialised;
the last pick wins.
-Audio is where the platforms part, and not by choice. WebRTC's iOS slices
-ship `RTCAudioDevice`, `RTCAudioSession` and three more audio headers; the
-macOS slice ships two, `RTCAudioSource` and `RTCAudioTrack`. There is no
-supported way to enumerate or select an audio device for WebRTC on macOS
-through this framework, and no way to supply a custom audio device module
-either, because the protocol is not in the slice. (It *is* in the Mac
-Catalyst slice, so this is a packaging boundary rather than a platform
-limit — but Catalyst would be a poor trade for a real AppKit app.)
-
-So on iPhone the microphone is a picker, and on Mac the panel reports which
-devices macOS has chosen — read from CoreAudio, which is what WebRTC's
-audio device module follows — and offers Sound settings. Naming them is
-worth doing on its own: the first thing someone needs when a call sounds
-wrong is to know what they are connected to.
+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
+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.
+
+Apple's voice processing is **not** allowed on Mac
+(`setPlatformVoiceProcessingAllowed(false)`). Left allowed, the engine module
+resolves echo cancellation, noise suppression and gain to Apple's coupled
+voice-processing unit; with it off, WebRTC's own software processing runs, as
+it always has on Mac. One consequence: macOS offers its **Mic Mode** menu
+(Voice Isolation and the rest) only to apps on voice processing, so it is not
+offered to Calls on Mac. The iPhone still runs on the voice-processing unit.
+
+The native/browser gate cannot open a microphone (a test process has no
+permission to), so `CallsInterop` gives the engine module a synthetic voice
+through manual rendering mode, as it gives the capturer synthetic frames; no
+device is opened on either side of it.
Choosing a Bluetooth microphone on iPhone puts the call on the hands-free
profile, which is narrowband in both directions. That used to happen
diff --git a/native/apple/Sources/CallsInterop/Interop.swift b/native/apple/Sources/CallsInterop/Interop.swift
index c358db5..be0ff5b 100644
--- a/native/apple/Sources/CallsInterop/Interop.swift
+++ b/native/apple/Sources/CallsInterop/Interop.swift
@@ -22,7 +22,7 @@ struct Input: Decodable {
let input = try? JSONDecoder().decode(Input.self, from: Data(line.utf8)),
let url = URL(string: input.origin), url.scheme == "http", url.host == "127.0.0.1"
else { exit(2) }
- let call = Call(playAudio: false)
+ let call = Call(playAudio: false, audioInput: SyntheticVoice())
let preview = input.chatSnapshot.map { ChatPreview(call: call, path: $0) }
do {
try await call.join(
diff --git a/native/apple/Sources/CallsInterop/SyntheticVoice.swift b/native/apple/Sources/CallsInterop/SyntheticVoice.swift
new file mode 100644
index 0000000..230057f
--- /dev/null
+++ b/native/apple/Sources/CallsInterop/SyntheticVoice.swift
@@ -0,0 +1,25 @@
+import CallsKit
+import Foundation
+
+/// The harness's microphone: a voiced, syllable-shaped tone over steady
+/// noise, so the far side receives audio with speech-like structure and
+/// the capture processing has something to work on. Real speech it is
+/// not; the fixture test in CallsKitTests is where suppression is judged.
+final class SyntheticVoice: AudioInputSource, @unchecked Sendable {
+ private var t = 0.0
+ private var seed: UInt32 = 0x1234_5678
+
+ func render(_ samples: UnsafeMutablePointer<Float>, count: Int, sampleRate: Double) {
+ for i in 0..<count {
+ // Four syllables a second, a 140 Hz voice with two harmonics.
+ let envelope = max(0, sin(2 * .pi * 2 * t))
+ let voice =
+ envelope
+ * (sin(2 * .pi * 140 * t) + 0.5 * sin(2 * .pi * 280 * t) + 0.25 * sin(2 * .pi * 420 * t))
+ seed = seed &* 1_664_525 &+ 1_013_904_223
+ let noise = Double(Int32(bitPattern: seed)) / Double(Int32.max)
+ samples[i] = Float(0.2 * voice + 0.02 * noise)
+ t += 1 / sampleRate
+ }
+ }
+}
diff --git a/native/apple/Sources/CallsKit/Audio.swift b/native/apple/Sources/CallsKit/Audio.swift
index 679c1ca..773d887 100644
--- a/native/apple/Sources/CallsKit/Audio.swift
+++ b/native/apple/Sources/CallsKit/Audio.swift
@@ -1,17 +1,9 @@
import AVFoundation
import Foundation
-#if os(macOS)
- import CoreAudio
-#endif
+import LiveKitWebRTC
-#if os(iOS)
- import LiveKitWebRTC
-#endif
-
-/// One audio device the person can be on. `selectable` is the whole
-/// platform difference in one flag: on iPhone a route can be chosen, and
-/// on Mac it can only be reported.
+/// One audio device the person can be on.
public struct AudioDevice: Identifiable, Hashable, Sendable {
public let id: String
public let name: String
@@ -23,39 +15,32 @@ public struct AudioDevice: Identifiable, Hashable, Sendable {
}
}
-/// The audio devices a call is on, and — where the platform allows it —
-/// the ability to change them.
-///
-/// The asymmetry here is not a design choice. WebRTC's macOS slice ships
-/// two audio headers, RTCAudioSource and RTCAudioTrack; the iOS slices
-/// ship five, including RTCAudioDevice and RTCAudioSession. There is no
-/// supported way to enumerate or select an audio device for WebRTC on
-/// macOS through this framework, and no way to supply a custom audio
-/// device module either, because the protocol that would be implemented
-/// is not in the slice. (It *is* in the Mac Catalyst slice, so this is a
-/// packaging boundary rather than a platform impossibility — but a
-/// Catalyst build would be a poor trade for a real AppKit app.)
+/// The audio devices a call is on, and the ability to change them.
///
-/// So on Mac this reports what macOS has chosen, read straight from
-/// CoreAudio, and offers the Sound settings pane to change it. Naming the
-/// device is worth doing on its own: the first thing a person needs when
-/// a call sounds wrong is to know what they are actually connected to,
-/// and until now the app could not tell 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.
@MainActor public final class AudioRoutes: ObservableObject {
@Published public private(set) var inputs: [AudioDevice] = []
@Published public private(set) var currentInput: String?
@Published public private(set) var outputName = ""
+ /// Speakers, where they can be chosen (Mac). Empty on iPhone, where the
+ /// output follows the route and is only named.
+ @Published public private(set) var outputs: [AudioDevice] = []
+ @Published public private(set) var currentOutput: String?
/// True where inputs can actually be chosen. The UI shows a picker when
/// it is set and a plain line of text when it is not, rather than a
/// disabled control that looks like something is broken.
- public let selectable: Bool
+ public let selectable = true
+
+ #if os(macOS)
+ private weak var module: RTCAudioDeviceModule?
+ #endif
public init() {
- #if os(iOS)
- selectable = true
- #else
- selectable = false
- #endif
refresh()
}
@@ -68,12 +53,74 @@ public struct AudioDevice: Identifiable, Hashable, Sendable {
currentInput = session.currentRoute.inputs.first?.uid
outputName = session.currentRoute.outputs.first?.portName ?? ""
#else
- inputs = macDevice(input: true).map { [$0] } ?? []
- currentInput = inputs.first?.id
- outputName = macDevice(input: false)?.name ?? ""
+ guard let module else {
+ inputs = []
+ outputs = []
+ currentInput = nil
+ currentOutput = nil
+ outputName = ""
+ return
+ }
+ inputs = module.inputDevices.map(AudioRoutes.device)
+ outputs = module.outputDevices.map(AudioRoutes.device)
+ // Nothing chosen yet reads back as an empty device, which is the
+ // system default in effect; the picker shows it as that entry.
+ currentInput = AudioRoutes.chosen(module.inputDevice, in: module.inputDevices)
+ currentOutput = AudioRoutes.chosen(module.outputDevice, in: module.outputDevices)
+ outputName = outputs.first { $0.id == currentOutput }?.name ?? ""
#endif
}
+ #if os(macOS)
+ func attach(_ module: RTCAudioDeviceModule) {
+ self.module = module
+ refresh()
+ }
+
+ /// 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: RTCIODevice) -> AudioDevice {
+ AudioDevice(
+ id: io.deviceId, name: io.isDefault ? "System default (\(io.name))" : io.name,
+ selectable: true)
+ }
+
+ nonisolated static func chosen(_ device: RTCIODevice, in list: [RTCIODevice]) -> String? {
+ if !device.deviceId.isEmpty { return device.deviceId }
+ return list.first(where: \.isDefault)?.deviceId
+ }
+
+ /// Chooses the microphone on Mac, mid-call if one is up. The engine is
+ /// reconfigured underneath the running track; nothing renegotiates.
+ public func use(input id: String) throws {
+ guard let module, let device = module.inputDevices.first(where: { $0.deviceId == id })
+ else { throw AudioRoutes.gone("microphone") }
+ module.inputDevice = device
+ refresh()
+ guard currentInput == id else { throw AudioRoutes.refused("microphone") }
+ }
+
+ public func use(output id: String) throws {
+ guard let module, let device = module.outputDevices.first(where: { $0.deviceId == id })
+ else { throw AudioRoutes.gone("speaker") }
+ module.outputDevice = device
+ refresh()
+ guard currentOutput == id else { throw AudioRoutes.refused("speaker") }
+ }
+
+ private static func gone(_ what: String) -> NSError {
+ NSError(
+ domain: "Calls", code: 5,
+ userInfo: [NSLocalizedDescriptionKey: "That \(what) is no longer available."])
+ }
+ private static func refused(_ what: String) -> NSError {
+ NSError(
+ domain: "Calls", code: 6,
+ userInfo: [NSLocalizedDescriptionKey: "The call could not switch to that \(what)."])
+ }
+ #endif
+
#if os(iOS)
/// Chooses the microphone, and decides the Bluetooth question with it.
///
@@ -129,40 +176,4 @@ public struct AudioDevice: Identifiable, Hashable, Sendable {
: [.defaultToSpeaker, .allowBluetoothA2DP]
}
#endif
-
- #if os(macOS)
- /// The system's default input or output device, by name. CoreAudio is
- /// the only way to ask: the WebRTC slice cannot say, and this is
- /// precisely the device it will be using, because its audio device
- /// module follows the system default.
- private func macDevice(input: Bool) -> AudioDevice? {
- var id = AudioDeviceID(0)
- var size = UInt32(MemoryLayout<AudioDeviceID>.size)
- var address = AudioObjectPropertyAddress(
- mSelector: input
- ? kAudioHardwarePropertyDefaultInputDevice : kAudioHardwarePropertyDefaultOutputDevice,
- mScope: kAudioObjectPropertyScopeGlobal,
- mElement: kAudioObjectPropertyElementMain)
- guard
- AudioObjectGetPropertyData(
- AudioObjectID(kAudioObjectSystemObject), &address, 0, nil, &size, &id) == noErr,
- id != kAudioObjectUnknown
- else { return nil }
- // Unmanaged, not CFString: CoreAudio writes a retained reference into
- // this buffer, and pointing at a managed CFString both trips ARC's
- // "object reference in a raw buffer" warning and leaves the +1
- // unbalanced. takeRetainedValue consumes it exactly once.
- var name: Unmanaged<CFString>?
- var nameSize = UInt32(MemoryLayout<Unmanaged<CFString>?>.size)
- var nameAddress = AudioObjectPropertyAddress(
- mSelector: kAudioObjectPropertyName,
- mScope: kAudioObjectPropertyScopeGlobal,
- mElement: kAudioObjectPropertyElementMain)
- guard
- AudioObjectGetPropertyData(id, &nameAddress, 0, nil, &nameSize, &name) == noErr,
- let value = name?.takeRetainedValue()
- else { return nil }
- return AudioDevice(id: String(id), name: value as String, selectable: false)
- }
- #endif
}
diff --git a/native/apple/Sources/CallsKit/Call.swift b/native/apple/Sources/CallsKit/Call.swift
index 6f02b08..e3605e4 100644
--- a/native/apple/Sources/CallsKit/Call.swift
+++ b/native/apple/Sources/CallsKit/Call.swift
@@ -71,7 +71,12 @@ private final class Events: NSObject, BridgeListenerProtocol {
self.playAudio = playAudio
}
#else
- public init(playAudio: Bool = true) { self.playAudio = playAudio }
+ let audioInput: AudioInputSource?
+ /// `audioInput` stands in for the microphone (the interop harness).
+ public init(playAudio: Bool = true, audioInput: AudioInputSource? = nil) {
+ self.playAudio = playAudio
+ self.audioInput = audioInput
+ }
#endif
public func join(origin: String, room: String, cookies: String, capture: VideoCapture? = nil)
async throws
@@ -195,10 +200,9 @@ private final class Events: NSObject, BridgeListenerProtocol {
/// This exists because of a call on 2026-09-17 that came back as an
/// unintelligible growl, and because nothing in this app recorded
/// anything about it — no log, no counters, no artefact. A reproduction
- /// would have produced nothing either. On macOS it is also the *only*
- /// instrument available, since the framework's macOS slice has no audio
- /// device API and a bad device cannot be ruled out by switching away
- /// from it.
+ /// would have produced nothing either. (Until the move to LiveKit's
+ /// WebRTC build it was also the only instrument on macOS, which had no
+ /// way to switch away from a suspect device.)
///
/// Five seconds because this is a readout a person glances at when
/// something sounds wrong, not a time series.
diff --git a/native/apple/Sources/CallsKit/CallAudio.swift b/native/apple/Sources/CallsKit/CallAudio.swift
new file mode 100644
index 0000000..2555d06
--- /dev/null
+++ b/native/apple/Sources/CallsKit/CallAudio.swift
@@ -0,0 +1,144 @@
+import AVFoundation
+import Foundation
+import LiveKitWebRTC
+
+/// Something other than a microphone to capture from: the interop
+/// harness's synthetic voice, as SyntheticVideo is its camera. Called on
+/// the audio engine's render thread with mono float samples in -1...1.
+/// On Mac only, where the engine module can run without any device.
+public protocol AudioInputSource: AnyObject, Sendable {
+ func render(_ samples: UnsafeMutablePointer<Float>, count: Int, sampleRate: Double)
+}
+
+/// How a call's audio is built: which audio device module WebRTC runs on,
+/// and — on Mac — what the app is told about the devices it can use.
+///
+/// Kept out of Media.swift so that file only changes where the factory is
+/// made.
+@MainActor final class CallAudio {
+ let factory: RTCPeerConnectionFactory
+
+ #if os(macOS)
+ private let devices: EngineWatch
+ #endif
+
+ /// `audioDevice` is the iPhone tests' silent device. It takes the old
+ /// constructor, which has no audio device module to speak of.
+ ///
+ /// `input`, on Mac, replaces the microphone and the speaker both: the
+ /// engine runs in manual rendering mode, touching no device at all.
+ init(routes: AudioRoutes, audioDevice: RTCAudioDevice?, input: AudioInputSource? = nil) {
+ let encoder = RTCDefaultVideoEncoderFactory()
+ let decoder = RTCDefaultVideoDecoderFactory()
+ #if os(iOS)
+ if let audioDevice {
+ factory = RTCPeerConnectionFactory(
+ encoderFactory: encoder, decoderFactory: decoder, audioDevice: audioDevice)
+ return
+ }
+ // The platform default on iPhone is the voice-processing I/O unit it
+ // has always been.
+ factory = RTCPeerConnectionFactory(
+ audioDeviceModuleType: .platformDefault, bypassVoiceProcessing: false,
+ encoderFactory: encoder, decoderFactory: decoder, audioProcessingModule: nil)
+ #else
+ // AVAudioEngine rather than the CoreAudio module: it is the one with a
+ // device list and a way to choose from it.
+ factory = RTCPeerConnectionFactory(
+ audioDeviceModuleType: .audioEngine, bypassVoiceProcessing: false,
+ encoderFactory: encoder, decoderFactory: decoder, audioProcessingModule: nil)
+ let module = factory.audioDeviceModule
+ // Apple's voice processing stays off on Mac. Left allowed, the engine
+ // module resolves echo cancellation, noise suppression and gain to
+ // Apple's coupled voice-processing unit instead of WebRTC's own —
+ // a different pipeline from the one the Mac app has always had, and
+ // one whose noise suppression cannot be separated from its echo
+ // 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)
+ module.observer = devices
+ if input != nil {
+ _ = module.setManualRenderingMode(true)
+ } else {
+ routes.attach(module)
+ }
+ #endif
+ }
+}
+
+#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. The rest of the protocol is the engine lifecycle, left alone
+ /// (every one of those answers 0, "carry on").
+ private final class EngineWatch: NSObject, RTCAudioDeviceModuleDelegate, @unchecked Sendable {
+ weak var routes: AudioRoutes?
+ let input: AudioInputSource?
+ private var node: AVAudioSourceNode?
+ init(routes: AudioRoutes, input: AudioInputSource?) {
+ self.routes = routes
+ self.input = input
+ }
+
+ func audioDeviceModuleDidUpdateDevices(_ audioDeviceModule: RTCAudioDeviceModule) {
+ DispatchQueue.main.async { [weak self] in
+ MainActor.assumeIsolated { self?.routes?.refresh() }
+ }
+ }
+
+ func audioDeviceModule(
+ _ audioDeviceModule: RTCAudioDeviceModule,
+ didReceiveSpeechActivityEvent speechActivityEvent: RTCSpeechActivityEvent
+ ) {}
+ func audioDeviceModule(_ audioDeviceModule: RTCAudioDeviceModule, didCreateEngine engine: AVAudioEngine)
+ -> Int
+ { 0 }
+ func audioDeviceModule(
+ _ audioDeviceModule: RTCAudioDeviceModule, willStartEngine engine: AVAudioEngine,
+ isPlayoutEnabled: Bool, isRecordingEnabled: Bool
+ ) -> Int { 0 }
+ func audioDeviceModule(
+ _ audioDeviceModule: RTCAudioDeviceModule, didStopEngine engine: AVAudioEngine,
+ isPlayoutEnabled: Bool, isRecordingEnabled: Bool
+ ) -> Int { 0 }
+ func audioDeviceModule(
+ _ audioDeviceModule: RTCAudioDeviceModule, didDisableEngine engine: AVAudioEngine,
+ isPlayoutEnabled: Bool, isRecordingEnabled: Bool
+ ) -> Int { 0 }
+ func audioDeviceModule(
+ _ audioDeviceModule: RTCAudioDeviceModule, willReleaseEngine engine: AVAudioEngine
+ ) -> Int { 0 }
+ func audioDeviceModule(
+ _ audioDeviceModule: RTCAudioDeviceModule, engine: AVAudioEngine,
+ configureInputFromSource source: AVAudioNode?, toDestination destination: AVAudioNode,
+ format: AVAudioFormat, context: [AnyHashable: Any]
+ ) -> Int {
+ guard let input,
+ let mono = AVAudioFormat(standardFormatWithSampleRate: format.sampleRate, channels: 1)
+ else { return 0 }
+ if let node { engine.detach(node) }
+ let rate = format.sampleRate
+ let node = AVAudioSourceNode(format: mono) { _, _, frames, buffers in
+ for buffer in UnsafeMutableAudioBufferListPointer(buffers) {
+ guard let data = buffer.mData else { continue }
+ input.render(data.assumingMemoryBound(to: Float.self), count: Int(frames), sampleRate: rate)
+ }
+ return noErr
+ }
+ self.node = node
+ engine.attach(node)
+ let mixer =
+ engine.isInManualRenderingMode
+ ? engine.mainMixerNode
+ : (context[kLKRTCAudioEngineInputMixerNodeKey] as? AVAudioMixerNode) ?? engine.mainMixerNode
+ engine.connect(node, to: mixer, format: mono)
+ return 0
+ }
+ func audioDeviceModule(
+ _ audioDeviceModule: RTCAudioDeviceModule, engine: AVAudioEngine,
+ configureOutputFromSource source: AVAudioNode, toDestination destination: AVAudioNode?,
+ format: AVAudioFormat, context: [AnyHashable: Any]
+ ) -> Int { 0 }
+ }
+#endif
diff --git a/native/apple/Sources/CallsKit/DevicePanel.swift b/native/apple/Sources/CallsKit/DevicePanel.swift
index ab3c609..3f9f85b 100644
--- a/native/apple/Sources/CallsKit/DevicePanel.swift
+++ b/native/apple/Sources/CallsKit/DevicePanel.swift
@@ -1,17 +1,12 @@
import SwiftUI
-#if os(macOS)
- import AppKit
-#endif
-
-/// The camera, microphone and speaker a call is on — chosen where the
-/// platform allows it, reported where it does not.
+/// The camera, microphone and speaker a call is on, and a choice of each
+/// where there is more than one.
///
/// One view for both apps, because the thing a person wants here is the
-/// same on a Mac and an iPhone even though what they can do about it is
-/// not. On iPhone every row is a choice. On Mac the camera is a choice
-/// and audio is a statement of fact with a way out to Sound settings,
-/// because WebRTC's macOS slice carries no audio device API at all.
+/// same on a Mac and an iPhone. On iPhone the microphone is an audio
+/// session route and the speaker follows it; on Mac both are devices on
+/// the call's own audio device module.
///
/// The audio readout at the bottom is the reason this panel opens at
/// all, most of the time: someone has noticed the call sounds wrong. It
@@ -98,20 +93,37 @@ import SwiftUI
#endif
#if os(macOS)
+ /// Microphone and speaker on the call's own audio device module, so a
+ /// choice here moves this call and leaves the Mac's system setting
+ /// alone.
private var macAudio: some View {
- VStack(alignment: .leading, spacing: 8) {
- if let input = audio.inputs.first { row("Microphone", input.name) }
- if !audio.outputName.isEmpty { row("Speaker", audio.outputName) }
- Text("Calls uses whichever microphone and speaker macOS is set to.")
- .font(.footnote).foregroundStyle(.secondary)
- .fixedSize(horizontal: false, vertical: true)
- Button("Open Sound settings", systemImage: "gearshape") {
- if let url = URL(string: "x-apple.systempreferences:com.apple.Sound-Settings.extension") {
- NSWorkspace.shared.open(url)
- }
+ VStack(alignment: .leading, spacing: 12) {
+ if audio.inputs.count > 1 {
+ picker(
+ "Microphone",
+ selection: Binding(
+ get: { audio.currentInput },
+ set: { id in
+ guard let id, id != audio.currentInput else { return }
+ do { try audio.use(input: id) } catch { call.noteDevice(error.localizedDescription) }
+ }),
+ options: audio.inputs.map { ($0.id, $0.name) }, identifier: "microphonePicker")
+ } else if let only = audio.inputs.first {
+ row("Microphone", only.name)
+ }
+ if audio.outputs.count > 1 {
+ picker(
+ "Speaker",
+ selection: Binding(
+ get: { audio.currentOutput },
+ set: { id in
+ guard let id, id != audio.currentOutput else { return }
+ do { try audio.use(output: id) } catch { call.noteDevice(error.localizedDescription) }
+ }),
+ options: audio.outputs.map { ($0.id, $0.name) }, identifier: "speakerPicker")
+ } else if let only = audio.outputs.first {
+ row("Speaker", only.name)
}
- .buttonStyle(.bordered)
- .accessibilityIdentifier("openSoundSettings")
}
}
#endif
diff --git a/native/apple/Sources/CallsKit/Media.swift b/native/apple/Sources/CallsKit/Media.swift
index 69dfbab..1562123 100644
--- a/native/apple/Sources/CallsKit/Media.swift
+++ b/native/apple/Sources/CallsKit/Media.swift
@@ -11,19 +11,18 @@ import LiveKitWebRTC
private var capture: VideoCapture?
private var peers: [String: MediaPeer] = [:]
+ /// The audio device module and processing behind the factory. See CallAudio.
+ let audio: CallAudio
+
init(call: Call) {
self.call = call
RTCInitializeSSL()
#if os(iOS)
- factory = RTCPeerConnectionFactory(
- encoderFactory: RTCDefaultVideoEncoderFactory(),
- decoderFactory: RTCDefaultVideoDecoderFactory(),
- audioDevice: call.customAudioDevice)
+ audio = CallAudio(routes: call.audio, audioDevice: call.customAudioDevice)
#else
- factory = RTCPeerConnectionFactory(
- encoderFactory: RTCDefaultVideoEncoderFactory(),
- decoderFactory: RTCDefaultVideoDecoderFactory())
+ audio = CallAudio(routes: call.audio, audioDevice: nil, input: call.audioInput)
#endif
+ factory = audio.factory
}
func prepare(capture supplied: VideoCapture?) async throws {
close()
diff --git a/native/apple/Tests/CallsKitTests/AudioHealthTests.swift b/native/apple/Tests/CallsKitTests/AudioHealthTests.swift
index de58c53..74651c2 100644
--- a/native/apple/Tests/CallsKitTests/AudioHealthTests.swift
+++ b/native/apple/Tests/CallsKitTests/AudioHealthTests.swift
@@ -90,32 +90,36 @@ final class AudioHealthTests: XCTestCase {
}
}
-/// AudioRoutes on macOS is read-only, and everything it reports comes
-/// from CoreAudio rather than from WebRTC — which has no audio device API
-/// in its macOS slice at all. These run against the real machine, because
-/// the thing worth testing is that the CoreAudio calls are right.
+/// AudioRoutes on macOS reads and chooses devices on the call's own
+/// WebRTC audio device module. These run against the real machine and a
+/// real module (no recording is started), because the thing worth testing
+/// is that the module's lists come through named and choosable.
final class AudioRoutesTests: XCTestCase {
- @MainActor func testMacReportsTheSystemDevicesAndOffersNoChoice() throws {
+ @MainActor func testMacListsTheModuleDevicesAndCanChoose() throws {
#if os(macOS)
let routes = AudioRoutes()
- XCTAssertFalse(
- routes.selectable,
- "macOS cannot select an audio device for WebRTC; the UI must not offer a picker")
+ XCTAssertTrue(routes.inputs.isEmpty, "no module yet, so nothing to list")
+ XCTAssertThrowsError(try routes.use(input: "anything"))
+
+ let audio = CallAudio(routes: routes, audioDevice: nil)
+ _ = audio
+ XCTAssertTrue(routes.selectable)
// A machine with no input at all is possible, so this asserts the
- // shape rather than the presence: whatever is reported must be
- // named, because an unnamed device tells a person nothing and the
- // whole point of the Mac panel is saying what you are connected to.
- for device in routes.inputs {
- XCTAssertFalse(device.name.isEmpty, "a reported device must have a name")
- XCTAssertFalse(device.selectable)
+ // shape rather than the presence.
+ for device in routes.inputs + routes.outputs {
+ XCTAssertFalse(device.name.isEmpty, "a listed device must have a name")
+ XCTAssertTrue(device.selectable)
+ }
+ if let current = routes.currentInput, !routes.inputs.isEmpty {
+ XCTAssertTrue(routes.inputs.contains { $0.id == current }, "the current input is one listed")
+ }
+ XCTAssertThrowsError(try routes.use(input: "no-such-device")) { error in
+ XCTAssertEqual((error as NSError).code, 5)
}
- if !routes.outputName.isEmpty {
- XCTAssertFalse(routes.outputName.hasPrefix("<"), "output name should be a real name")
+ if let first = routes.inputs.first {
+ XCTAssertNoThrow(try routes.use(input: first.id))
+ XCTAssertEqual(routes.currentInput, first.id)
}
- // Reading twice must give the same answer and must not crash on the
- // retained CFString CoreAudio hands back.
- let again = AudioRoutes()
- XCTAssertEqual(again.outputName, routes.outputName)
#else
throw XCTSkip("macOS only")
#endif