oficina / meet Public

Merge native-call-devices (410ddcb) into main on LiveKit's audio module

Calls 0.2.1 build 3 was built from 410ddcb, which never reached main. Its
per-call device work is merged here onto main's LiveKit WebRTC build.

Audio: the Mac stays on LiveKit's AVAudioEngine module with DeepFilterNet 3.
410ddcb's CoreAudio HAL device (CallsAudio, a vendored RTCAudioDevice.h,
AudioHardware, MacAudioDevices) is dropped: WebRTC's custom-audio-device
constructor takes no audio processing module, so it cannot carry the noise
suppressor. Its routing rules are carried onto the module in AudioRoutes:
System default first and followed (the module follows default-device and
sample-rate changes itself), an explicit choice held until changed or
disconnected, a disconnected choice falling back to the default with a
notice, choices cleared on leaving, Sound settings never touched. The
policy tests are ported against a stand-in module.

The choice is kept by AudioRoutes, not read back: LiveKit's engine module
never reports its current device (inputDevice/outputDevice are nil), and
trySet*Device reports success and failure inverted. Main's pickers read the
device back, so a non-default choice would have been reported as refused.

Kept from 410ddcb: Continuity/external camera discovery, refresh while the
device panel is open, a refused camera switch restoring the previous camera,
PATENTS.webrtc (now in both apps), the per-call audio step in native.mjs.
LICENSE.webrtc is main's (LiveKit's). The audio health readout stays.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev 1db4fce756145ca09ee427146797eae60b609e81 parent 1ab8a2eparent 410ddcb
16 files changed, +587 −84
  • native/PATENTS.webrtc +24 −0
  • native/README.md +36 −3
  • native/VERIFICATION.md +70 −0
  • native/apple/Sources/CallsInterop/Interop.swift +22 −1
  • native/apple/Sources/CallsKit/Audio.swift +155 −36
  • native/apple/Sources/CallsKit/Call.swift +27 −9
  • native/apple/Sources/CallsKit/CallAudio.swift +4 −5
  • native/apple/Sources/CallsKit/Capture.swift +27 −11
  • native/apple/Sources/CallsKit/DevicePanel.swift +46 −14
  • native/apple/Sources/CallsKit/MacCallView.swift +1 −1
  • native/apple/Sources/CallsKit/Media.swift +2 −4
  • native/apple/Tests/CallsKitTests/AudioHealthTests.swift +9 −0
  • native/apple/Tests/CallsKitTests/AudioRoutesTests.swift +144 −0
  • native/ios/project.yml +2 −0
  • native/scripts/bundle-mac.py +2 −0
  • test/browser/native.mjs +16 −0

CI passed — run details

diff --git a/native/PATENTS.webrtc b/native/PATENTS.webrtc
new file mode 100644
index 0000000..190607a
--- /dev/null
+++ b/native/PATENTS.webrtc
@@ -0,0 +1,24 @@
+Additional IP Rights Grant (Patents)
+
+"This implementation" means the copyrightable works distributed by
+Google as part of the WebRTC code package.
+
+Google hereby grants to you a perpetual, worldwide, non-exclusive,
+no-charge, irrevocable (except as stated in this section) patent
+license to make, have made, use, offer to sell, sell, import,
+transfer, and otherwise run, modify and propagate the contents of this
+implementation of the WebRTC code package, where such license applies
+only to those patent claims, both currently owned by Google and
+acquired in the future, licensable by Google that are necessarily
+infringed by this implementation of the WebRTC code package. This
+grant does not include claims that would be infringed only as a
+consequence of further modification of this implementation. If you or
+your agent or exclusive licensee institute or order or agree to the
+institution of patent litigation against any entity (including a
+cross-claim or counterclaim in a lawsuit) alleging that this
+implementation of the WebRTC code package or any code incorporated
+within this implementation of the WebRTC code package constitutes
+direct or contributory patent infringement, or inducement of patent
+infringement, then any patent rights granted to you under this License
+for this implementation of the WebRTC code package shall terminate as
+of the date such litigation is filed.
diff --git a/native/README.md b/native/README.md
index e7be6f6..855cfff 100644
--- a/native/README.md
+++ b/native/README.md
@@ -74,6 +74,40 @@ 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.
+The Mac's choices follow these rules (`AudioRoutes`, held by
+`AudioRoutesTests`):
+
+- **System default** is the first entry of each picker, and where every call
+ starts. It follows macOS: a new default device, or the device changing its
+ sample rate, is followed by the module itself, which listens for both and
+ rebuilds its engine.
+- An explicit choice keeps its device until you change it or the device
+ disconnects; a change of system default does not move it.
+- A chosen device that disconnects falls back to the system default, and the
+ panel says so.
+- Leaving the call clears the choices; the next call starts on the system
+ default.
+- Nothing changes Sound settings.
+
+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.
+
+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
+custom-audio-device constructor. That constructor takes no audio processing
+module, so it cannot carry the noise suppressor; the rules above are that
+build's, carried onto LiveKit's module, and `CallsAudio` was not kept.
+
+The camera list includes external and Continuity cameras (the Mac app
+declares `NSCameraUseContinuityCameraDeviceType`) and refreshes every second
+while the device panel is open. A camera switch that is refused tries to
+restore the previous camera, and the picker returns to the camera actually in
+use.
+
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
@@ -176,9 +210,7 @@ The **audio readout** separates the two ways received audio goes wrong,
which is the distinction a person reporting "it growled" cannot make:
concealed samples are loss and sound like dropouts, while inserted and
removed samples are the jitter buffer rescaling the signal to track the
-sender's clock — and a stretched voice is what a growl is. On macOS it is
-the only instrument there is, since a bad device cannot be ruled out by
-switching away from it.
+sender's clock — and a stretched voice is what a growl is. The device pickers let you compare routes while watching those counters.
**Shared screens** from anyone in the call — a browser or the other app —
take the stage as a tile of their own, captioned "Name’s screen" exactly as
@@ -380,6 +412,7 @@ Third-party notices ship in the apps' Resources:
|---|---|---|---|
| `LICENSE.lucide` | Lucide icons | yes | yes |
| `LICENSE.webrtc` | WebRTC's own BSD licence, as LiveKit ships it in `LiveKitWebRTC.xcframework` | yes | yes |
+| `PATENTS.webrtc` | WebRTC's additional IP rights grant (patents) | yes | yes |
| `LICENSE.rnnoise` | RNNoise | yes | yes |
| `LICENSE.deepfilternet` | DeepFilterNet's libDF | yes | no (not linked) |
| `LICENSE.deepfilternet-crates` | the 90 Rust crates libDF links, generated by `scripts/deepfilter-notices.py` after `build-deepfilter.sh`; rerun it when the DeepFilterNet pin changes | yes | no (not linked) |
diff --git a/native/VERIFICATION.md b/native/VERIFICATION.md
index 3ebb747..101bfa3 100644
--- a/native/VERIFICATION.md
+++ b/native/VERIFICATION.md
@@ -1,3 +1,38 @@
+# Per-call device routing on LiveKit's module — 2026-09-23
+
+Branch `merge/native-call-devices`: `native-call-devices` (`410ddcb`, the
+source of Calls 0.2.1 build 3) merged into `origin/main` at `1ab8a2e`. The
+Mac stays on LiveKit's AVAudioEngine module with DeepFilterNet 3; build 3's
+CoreAudio HAL device (`CallsAudio`) was dropped, because WebRTC's
+custom-audio-device constructor takes no audio processing module. Its routing
+rules were carried onto the module (`AudioRoutes`), and its camera discovery,
+refresh and restore-on-refusal kept. M1 Max, macOS 27.
+
+| Check | Result |
+| --- | --- |
+| `build-bridge.sh`, `build-rnnoise.sh`, `build-deepfilter.sh` | Built fresh in a new worktree (no cache) |
+| `deepfilter-notices.py` after that build | Regenerates `LICENSE.deepfilternet-crates` byte for byte (90 crates) |
+| `swift test` | 116 tests, 0 failures, 1 skipped (keychain test, as before); includes the seven `CallAudioSelectionTests` and a non-default choice on the real module |
+| `GOTOOLCHAIN=go1.26.5 make ci`, `make race`, `make native-test` | Passed with `GOFLAGS=-count=1` (no cached results) |
+| `make native-mac` | Built; signature verifies; bundle carries LiveKitWebRTC, the DeepFilterNet model, all five notices and `PATENTS.webrtc`; launched and stayed up |
+| `make iphone` | Simulator build succeeded |
+| `CGO_ENABLED=0 make browser` | 34 of 34 scenarios passed |
+| `make native-browser` | Passed (auth, member, guest, chat); CallsInterop rebuilt by the target; the new per-call audio step ran and passed, DeepFilterNet 3 processed the synthetic microphone |
+
+Found while porting: LiveKit's engine module (M150) never reports which device
+it is on (`inputDevice`/`outputDevice` read back nil — confirmed on this
+machine), and `trySetInputDevice`/`trySetOutputDevice` return YES when the
+native call fails (read in the source). Main's pickers read the device back,
+so a non-default choice would have been reported as refused; the choice is
+now kept by `AudioRoutes`.
+
+Not verified here: this Mac lists only the default microphone and speaker,
+so switching between two real devices, unplugging a chosen one, and the
+module following a new system default or sample rate need a hands-on pass
+with a USB or Bluetooth device.
+
+---
+
# LiveKit WebRTC and neural noise reduction — 2026-09-23
Branch: `feat/livekit-webrtc-audio`, based on `origin/main` at `3846ca4`. Not
@@ -38,6 +73,41 @@ live state on Mac and the Mic Mode menu could not be observed.
---
+# Per-call device routing — 2026-09-21
+
+Superseded for audio when this branch was merged into main on 2026-09-23:
+the CoreAudio HAL device (`CallsAudio`) it describes was not kept, and its
+routing rules now run on LiveKit's audio device module (see the README).
+
+Worktree: `meet-call-devices`, branch `native-call-devices`, based on fetched
+`origin/main` at `3846ca4`. Calls 0.2.1 (build 3) is signed with Team Tito Limited, notarized, and published.
+Download: https://raw.amadan.net/oficina/native-releases/deb0bac4cdf6ef23a8edca9094f41260fc02143d/calls/releases/net.amadan.oficina.calls-3.zip
+
+Independent release review: GPT-6 Astra (`release_review`), approved with the
+physical-device acceptance limitations below. The extracted, notarized app also
+passed Gatekeeper and a relocated launch smoke test.
+
+- Mac app builds and its development signature verifies; iPhone simulator builds.
+- All 29 Swift tests pass, including independent per-call selections, reset on
+ leave, refused changes, disconnection fallback, following system defaults,
+ and reopening devices after sample-rate changes.
+- Native/browser member and guest calls pass with the new HAL audio path.
+ Reopening both audio routes and refusing a missing microphone preserve
+ increasing audio counters and the peer connection. Camera switching, queued
+ camera changes, refusal, stream renewal and leaving also pass.
+- Native/browser chat passes. The shipping DevicePanel was rendered and visually
+ inspected in `native/build/call-devices.png`.
+- `GOTOOLCHAIN=go1.26.5 make browser` passes all 34 Chromium scenarios.
+- `GOTOOLCHAIN=go1.26.5 make ci` passes. The repository pins this Go version for
+ its dependency-notice inventory.
+
+The live gate uses the system's audio route and a synthetic camera. Audio packet
+counters establish transport continuity, not audible quality. Physically changing
+between USB/Bluetooth devices, unplugging them, and checking echo cancellation
+remain hands-on acceptance checks; unit tests cover the routing policy.
+
+---
+
# Native completion verification — 2026-09-12
Branch: `codex/iphone-and-mirrored-preview`, based on `86ed9f3`.
diff --git a/native/apple/Sources/CallsInterop/Interop.swift b/native/apple/Sources/CallsInterop/Interop.swift
index 6e6c702..fc6d4c7 100644
--- a/native/apple/Sources/CallsInterop/Interop.swift
+++ b/native/apple/Sources/CallsInterop/Interop.swift
@@ -1,6 +1,6 @@
import AppKit
-import Foundation
import CallsKit
+import Foundation
import SwiftUI
// A separate test executable: synthetic video and fixture credentials are never
@@ -62,6 +62,17 @@ struct Input: Decodable {
print("{\"invitationReady\":\(ready),\"invitationRefused\":\(refused)}")
fflush(stdout)
}
+ if command == "audio-default" || command == "audio-missing" {
+ let succeeded = await MainActor.run {
+ do {
+ try call.audio.use(input: command == "audio-default" ? "" : "no-such-device")
+ try call.audio.use(output: "")
+ return true
+ } catch { return false }
+ }
+ print("{\"audioSwitched\":\(succeeded)}")
+ fflush(stdout)
+ }
if command.hasPrefix("camera ") {
let id = String(command.dropFirst("camera ".count))
await call.useCamera(id)
@@ -172,6 +183,16 @@ struct Input: Decodable {
try? await Task.sleep(for: .milliseconds(300))
saved = saveImage(named: name) && saved
}
+ view.rootView = AnyView(
+ VStack(alignment: .leading, spacing: 0) {
+ Text("Devices").font(.headline).padding(.horizontal, 16).padding(.top, 16)
+ DevicePanel(call: call)
+ Spacer(minLength: 0)
+ }.frame(width: 300)
+ .background(Color.white).environment(\.colorScheme, .light))
+ window.setContentSize(NSSize(width: 300, height: 340))
+ try? await Task.sleep(for: .milliseconds(300))
+ saved = saveImage(named: "call-devices.png") && saved
view.rootView = AnyView(
ChatView(call: call).padding(12)
.background(Color.white).environment(\.colorScheme, .light))
diff --git a/native/apple/Sources/CallsKit/Audio.swift b/native/apple/Sources/CallsKit/Audio.swift
index 773d887..87431b4 100644
--- a/native/apple/Sources/CallsKit/Audio.swift
+++ b/native/apple/Sources/CallsKit/Audio.swift
@@ -23,6 +23,30 @@ public struct AudioDevice: Identifiable, Hashable, Sendable {
/// 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.
+///
+/// On Mac the choices belong to this call, and the rules are:
+///
+/// - **System default** is an entry of its own and is where every call
+/// starts. It follows macOS — a new default device, or a device changing
+/// its sample rate, is the module's to follow, and it does (it listens
+/// for both and rebuilds its engine).
+/// - An explicit choice stays until the person changes it or the device
+/// disconnects. A change of system default does not move it.
+/// - A chosen device that disconnects falls back to the system default,
+/// and `routeNote` says so.
+/// - Leaving the call clears the choices, so the next call starts on the
+/// system default again.
+/// - Nothing here changes the Mac's Sound settings.
+///
+/// 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?
@@ -37,7 +61,13 @@ public struct AudioDevice: Identifiable, Hashable, Sendable {
public let selectable = true
#if os(macOS)
- private weak var module: RTCAudioDeviceModule?
+ /// Said when a chosen device has gone and the call has fallen back to
+ /// the system default. Cleared by the next choice and by leaving.
+ @Published public private(set) var routeNote = ""
+ private var control: AudioModuleControl?
+ /// The person's choices for this call. Nil follows the system default.
+ private var chosenInput: String?
+ private var chosenOutput: String?
#endif
public init() {
@@ -53,60 +83,108 @@ public struct AudioDevice: Identifiable, Hashable, Sendable {
currentInput = session.currentRoute.inputs.first?.uid
outputName = session.currentRoute.outputs.first?.portName ?? ""
#else
- guard let module else {
- inputs = []
- outputs = []
- currentInput = nil
- currentOutput = nil
- outputName = ""
+ guard let control else {
+ update(\.inputs, [])
+ update(\.outputs, [])
+ update(\.currentInput, nil)
+ update(\.currentOutput, nil)
+ update(\.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 ?? ""
+ let ins = control.devices(true)
+ let outs = control.devices(false)
+ var lost: [String] = []
+ if let id = chosenInput, !ins.contains(where: { $0.id == id }) {
+ chosenInput = nil
+ lost.append("microphone")
+ // The module has already let go of a device that left its list;
+ // asking for the default again does not depend on that.
+ if let fallback = ins.first(where: \.isDefault) { _ = control.select(fallback.id, true) }
+ }
+ if let id = chosenOutput, !outs.contains(where: { $0.id == id }) {
+ chosenOutput = nil
+ lost.append("speaker")
+ if let fallback = outs.first(where: \.isDefault) { _ = control.select(fallback.id, false) }
+ }
+ if !lost.isEmpty {
+ routeNote =
+ "The \(lost.joined(separator: " and ")) you chose disconnected. This call is using the system default."
+ }
+ update(\.inputs, ins.map(AudioRoutes.device))
+ update(\.outputs, outs.map(AudioRoutes.device))
+ update(\.currentInput, chosenInput ?? ins.first(where: \.isDefault)?.id)
+ update(\.currentOutput, chosenOutput ?? outs.first(where: \.isDefault)?.id)
+ update(\.outputName, outputs.first { $0.id == currentOutput }?.name ?? "")
#endif
}
#if os(macOS)
+ /// The panel refreshes every second while it is open; publishing only
+ /// what changed keeps an open menu from being rebuilt under the pointer.
+ private func update<Value: Equatable>(
+ _ key: ReferenceWritableKeyPath<AudioRoutes, Value>, _ value: Value
+ ) {
+ if self[keyPath: key] != value { self[keyPath: key] = value }
+ }
+
func attach(_ module: RTCAudioDeviceModule) {
- self.module = module
+ attach(AudioModuleControl(module))
+ }
+
+ func attach(_ control: AudioModuleControl) {
+ self.control = control
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 {
+ nonisolated static func device(_ io: ModuleDevice) -> AudioDevice {
AudioDevice(
- id: io.deviceId, name: io.isDefault ? "System default (\(io.name))" : io.name,
+ id: io.id, 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.
+ /// An empty id is the system default.
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") }
+ try choose(id, input: true, what: "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
+ try choose(id, input: false, what: "speaker")
+ }
+
+ /// The system default is recorded as no choice at all, 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 { chosenInput = device.isDefault ? nil : device.id } else {
+ chosenOutput = device.isDefault ? nil : device.id
+ }
+ routeNote = ""
+ refresh()
+ }
+
+ /// Leaving the call: the choices were this call's, so the module goes
+ /// back to following macOS for the next one. With the call's audio
+ /// stopped this only records the device; nothing is opened.
+ func reset() {
+ chosenInput = nil
+ chosenOutput = nil
+ routeNote = ""
+ if let control {
+ for input in [true, false] {
+ if let fallback = control.devices(input).first(where: \.isDefault) {
+ _ = control.select(fallback.id, input)
+ }
+ }
+ }
refresh()
- guard currentOutput == id else { throw AudioRoutes.refused("speaker") }
}
private static func gone(_ what: String) -> NSError {
@@ -114,11 +192,6 @@ public struct AudioDevice: Identifiable, Hashable, Sendable {
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)
@@ -177,3 +250,49 @@ public struct AudioDevice: Identifiable, Hashable, Sendable {
}
#endif
}
+
+#if os(macOS)
+ /// A device as the audio device module lists it.
+ 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
+
+ init(
+ devices: @escaping (_ input: Bool) -> [ModuleDevice],
+ select: @escaping (_ id: String, _ input: Bool) -> Bool
+ ) {
+ self.devices = devices
+ self.select = select
+ }
+
+ 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
+ })
+ else { return false }
+ if input { module.inputDevice = device } else { module.outputDevice = device }
+ return true
+ }
+ }
+ }
+#endif
diff --git a/native/apple/Sources/CallsKit/Call.swift b/native/apple/Sources/CallsKit/Call.swift
index ceaf74d..40dff4d 100644
--- a/native/apple/Sources/CallsKit/Call.swift
+++ b/native/apple/Sources/CallsKit/Call.swift
@@ -61,7 +61,7 @@ private final class Events: NSObject, BridgeListenerProtocol {
/// demand, because a picker bound to a plain computed property never
/// learns that a switch finished.
@Published public private(set) var currentCamera: String?
- /// The routes this platform can report, and on iPhone choose between.
+ /// Audio routing belongs to this call.
public let audio = AudioRoutes()
/// "Reduce background noise": the neural suppressor in place of WebRTC's
/// own. On by default, and remembered.
@@ -128,7 +128,7 @@ private final class Events: NSObject, BridgeListenerProtocol {
localTrack = media.localVideo
currentCamera = media.currentCamera
reportMedia()
- audio.refresh()
+ refreshDevices()
media.audio.reduceNoise = reduceNoise
audioProcessing = media.audio.report()
KnockNotice.requestPermission()
@@ -144,6 +144,11 @@ private final class Events: NSObject, BridgeListenerProtocol {
bridge = nil
events = nil
media.close()
+ // This call's microphone and speaker choices end with it (see AudioRoutes).
+ #if os(macOS)
+ audio.reset()
+ #endif
+ cameras = []
remoteTracks = [:]
screens = []
remoteScreens = [:]
@@ -161,6 +166,7 @@ private final class Events: NSObject, BridgeListenerProtocol {
audioProcessing = nil
currentCamera = nil
knocks.reset()
+ cameraSwitches += 1
cameraWork?.cancel()
cameraWork = nil
if let old { DispatchQueue.global().async { old.close() } }
@@ -213,7 +219,16 @@ private final class Events: NSObject, BridgeListenerProtocol {
/// The cameras available, and the one in use. Read through the running
/// capture rather than from AVFoundation directly, so what the picker
/// offers is what the call could actually switch to.
- public var cameras: [CameraDevice] { media.cameras }
+ @Published public private(set) var cameras: [CameraDevice] = []
+
+ public func refreshDevices() {
+ let available = media.cameras
+ if cameras != available { cameras = available }
+ if let currentCamera, !available.contains(where: { $0.id == currentCamera }) {
+ self.currentCamera = nil
+ }
+ audio.refresh()
+ }
/// Switches camera mid-call. Serialised: two picks are two capture
/// restarts on one device, and run at once the survivor is whichever
@@ -226,20 +241,20 @@ private final class Events: NSObject, BridgeListenerProtocol {
let previous = cameraWork
let work = Task { @MainActor [weak self] in
await previous?.value
- guard let self, mine == self.cameraSwitches else { return }
+ guard let self, mine == self.cameraSwitches, !Task.isCancelled else { return }
do {
try await self.media.use(camera: id)
+ guard mine == self.cameraSwitches, !Task.isCancelled else { return }
self.deviceNote = ""
} catch is CancellationError {
} catch {
- // The call is left without a picture rather than silently on the
- // old camera: the old session has to be released before the new
- // device can be opened. Saying so is the least this can do.
+ guard mine == self.cameraSwitches, !Task.isCancelled else { return }
self.deviceNote = error.localizedDescription
}
// Read back rather than assume: a refused switch left the capture
// wherever it actually is, and the picker has to follow the camera
// and not the intention.
+ guard mine == self.cameraSwitches, !Task.isCancelled else { return }
self.currentCamera = self.media.currentCamera
}
cameraWork = work
@@ -266,6 +281,7 @@ private final class Events: NSObject, BridgeListenerProtocol {
while !Task.isCancelled {
try? await Task.sleep(nanoseconds: 5_000_000_000)
guard !Task.isCancelled, bridge != nil else { return }
+ refreshDevices()
audioHealth = AudioHealth(await media.stats())
media.audio.refresh()
audioProcessing = media.audio.report()
@@ -320,7 +336,8 @@ private final class Events: NSObject, BridgeListenerProtocol {
switch event.type {
case "sent", "send-failed":
guard let message = event.signal, message.type == "chat",
- let text = message.payload?.text else { return }
+ let text = message.payload?.text
+ else { return }
appendChat("\(event.type == "sent" ? "You" : "Not sent"): \(text)")
case "info":
if let info = event.info {
@@ -333,7 +350,8 @@ private final class Events: NSObject, BridgeListenerProtocol {
self.state = state
// Only a member is sent a waiting list at all (spec §4.3), so this
// needs no check of its own for who is allowed to be told.
- KnockNotice.announce(knocks.arrivals(waiting: state.waiting, visible: KnockNotice.appIsFrontmost))
+ KnockNotice.announce(
+ knocks.arrivals(waiting: state.waiting, visible: KnockNotice.appIsFrontmost))
switch state.phase {
case "admitted": status = "In the meeting"
case "waiting": status = "Waiting to be let in"
diff --git a/native/apple/Sources/CallsKit/CallAudio.swift b/native/apple/Sources/CallsKit/CallAudio.swift
index cf7ade5..0229345 100644
--- a/native/apple/Sources/CallsKit/CallAudio.swift
+++ b/native/apple/Sources/CallsKit/CallAudio.swift
@@ -163,11 +163,10 @@ public struct ProcessingReport: Equatable, Sendable {
_ = module.setPlatformVoiceProcessingAllowed(false)
devices = EngineWatch(routes: routes, input: input)
module.observer = devices
- if input != nil {
- _ = module.setManualRenderingMode(true)
- } else {
- routes.attach(module)
- }
+ 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)
#endif
// Set after the factory exists: handed to the module's initialiser, the
// delegate is never called (seen in the spike, M150). The property is
diff --git a/native/apple/Sources/CallsKit/Capture.swift b/native/apple/Sources/CallsKit/Capture.swift
index fa45a8d..55bbc5b 100644
--- a/native/apple/Sources/CallsKit/Capture.swift
+++ b/native/apple/Sources/CallsKit/Capture.swift
@@ -57,8 +57,19 @@ extension VideoCapture {
private var epoch = UUID()
private(set) var currentCamera: String?
+ private static var devices: [AVCaptureDevice] {
+ #if os(macOS)
+ AVCaptureDevice.DiscoverySession(
+ deviceTypes: [.builtInWideAngleCamera, .external, .continuityCamera],
+ mediaType: .video, position: .unspecified
+ ).devices
+ #else
+ RTCCameraVideoCapturer.captureDevices()
+ #endif
+ }
+
var cameras: [CameraDevice] {
- RTCCameraVideoCapturer.captureDevices().map {
+ Self.devices.map {
CameraDevice(id: $0.uniqueID, name: $0.localizedName)
}
}
@@ -77,7 +88,7 @@ extension VideoCapture {
])
}
}
- let devices = RTCCameraVideoCapturer.captureDevices()
+ let devices = Self.devices
#if os(iOS)
let preferredDevice = devices.first { $0.position == .front }
#else
@@ -102,16 +113,10 @@ extension VideoCapture {
/// exists and keeps existing is the one thing perfect negotiation never
/// has to hear about.
///
- /// A failed switch leaves the call without a picture rather than
- /// silently on the old camera, because the old capture session has to
- /// be stopped before the new device can be opened — two AVCaptureDevices
- /// cannot hold the same camera, and on a Mac they often *are* the same
- /// camera behind two names. The error says so; the picker puts itself
- /// back.
+ /// If opening the new camera fails, try to restore the previous capture.
func use(camera id: String) async throws {
guard let capture = capturer, source != nil else { return }
- guard id != currentCamera else { return }
- guard let device = RTCCameraVideoCapturer.captureDevices().first(where: { $0.uniqueID == id })
+ guard let device = Self.devices.first(where: { $0.uniqueID == id })
else {
throw NSError(
domain: "Calls", code: 4,
@@ -119,9 +124,20 @@ extension VideoCapture {
}
epoch = UUID()
let preparing = epoch
+ let previous = currentCamera
await capture.stopCapture()
+ currentCamera = nil
guard preparing == epoch else { throw CancellationError() }
- try await run(capture, device: device, preparing: preparing)
+ do {
+ try await run(capture, device: device, preparing: preparing)
+ } catch {
+ if preparing == epoch, let previous,
+ let old = Self.devices.first(where: { $0.uniqueID == previous })
+ {
+ try? await run(capture, device: old, preparing: preparing)
+ }
+ throw error
+ }
}
/// The half start and use share: pick the format nearest 640 wide, cap
diff --git a/native/apple/Sources/CallsKit/DevicePanel.swift b/native/apple/Sources/CallsKit/DevicePanel.swift
index 6449c0a..121eae2 100644
--- a/native/apple/Sources/CallsKit/DevicePanel.swift
+++ b/native/apple/Sources/CallsKit/DevicePanel.swift
@@ -16,6 +16,7 @@ import SwiftUI
@ObservedObject private var call: Call
@ObservedObject private var audio: AudioRoutes
@State private var camera: String?
+ @State private var cameraRequest = 0
#if os(iOS)
@StateObject private var micModes = MicrophoneModes()
#endif
@@ -27,12 +28,12 @@ import SwiftUI
public var body: some View {
VStack(alignment: .leading, spacing: 16) {
- if call.cameras.count > 1 {
+ // A picker even for one camera: a Continuity or external camera can
+ // arrive while the panel is open, and the list refreshes under it.
+ if !call.cameras.isEmpty {
picker(
"Camera", selection: $camera,
options: call.cameras.map { ($0.id, $0.name) }, identifier: "cameraPicker")
- } else if let only = call.cameras.first {
- row("Camera", only.name)
}
#if os(iOS)
@@ -61,8 +62,8 @@ import SwiftUI
}
.padding(16)
.onAppear {
+ call.refreshDevices()
camera = call.currentCamera
- audio.refresh()
#if os(iOS)
micModes.refresh()
#endif
@@ -71,9 +72,23 @@ import SwiftUI
// refused leaves the capture where it was, and the control has to go
// back rather than keep showing a device the call is not on.
.onChange(of: call.currentCamera) { _, now in camera = now }
+ // Devices come and go while the panel is open — a phone offered as a
+ // Continuity Camera, a headset plugged in — so it keeps looking.
+ .task {
+ while !Task.isCancelled {
+ call.refreshDevices()
+ try? await Task.sleep(for: .seconds(1))
+ }
+ }
.onChange(of: camera) { was, now in
guard let now, now != was, now != call.currentCamera else { return }
- Task { await call.useCamera(now) }
+ cameraRequest += 1
+ let request = cameraRequest
+ Task {
+ await call.useCamera(now)
+ // Only the latest pick puts the picker back where the camera is.
+ if request == cameraRequest { camera = call.currentCamera }
+ }
}
}
@@ -125,34 +140,46 @@ import SwiftUI
#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.
+ /// alone. "System default" is always the first entry, and follows macOS.
private var macAudio: some View {
VStack(alignment: .leading, spacing: 12) {
- if audio.inputs.count > 1 {
+ if !audio.inputs.isEmpty {
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) }
+ do {
+ try audio.use(input: id)
+ call.noteDevice("")
+ } 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 {
+ if !audio.outputs.isEmpty {
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) }
+ do {
+ try audio.use(output: id)
+ call.noteDevice("")
+ } 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)
+ }
+ if !audio.inputs.isEmpty || !audio.outputs.isEmpty {
+ Text("Changes apply to this call only.")
+ .font(.footnote).foregroundStyle(.secondary)
+ }
+ if !audio.routeNote.isEmpty {
+ Label(audio.routeNote, systemImage: "info.circle")
+ .font(.callout).foregroundStyle(.secondary)
+ .fixedSize(horizontal: false, vertical: true)
+ .accessibilityIdentifier("routeNote")
}
}
}
@@ -201,6 +228,11 @@ import SwiftUI
_ label: String, selection: Binding<String?>, options: [(String, String)], identifier: String
) -> some View {
Picker(label, selection: selection) {
+ // While a camera is switching there is no current one; say so
+ // rather than show a device the call is not on.
+ if selection.wrappedValue == nil {
+ Text("Choose a device").tag(Optional<String>.none)
+ }
ForEach(options, id: \.0) { option in
Text(option.1).tag(Optional(option.0))
}
diff --git a/native/apple/Sources/CallsKit/MacCallView.swift b/native/apple/Sources/CallsKit/MacCallView.swift
index 847d92f..cfb36d5 100644
--- a/native/apple/Sources/CallsKit/MacCallView.swift
+++ b/native/apple/Sources/CallsKit/MacCallView.swift
@@ -249,7 +249,7 @@
.accessibilityIdentifier("shareScreen")
Button("Devices", systemImage: "slider.horizontal.3") { showingDevices.toggle() }
.buttonStyle(CallActionStyle(color: .teal, emphasized: showingDevices))
- .help("Choose your camera, and see which microphone and speaker this call is on")
+ .help("Choose the camera, microphone and speaker for this call")
.accessibilityIdentifier("devices")
MeetingShareButton(call: call).buttonStyle(CallActionStyle(color: .indigo))
.help("Share meeting link")
diff --git a/native/apple/Sources/CallsKit/Media.swift b/native/apple/Sources/CallsKit/Media.swift
index 9cddb3b..fa1f692 100644
--- a/native/apple/Sources/CallsKit/Media.swift
+++ b/native/apple/Sources/CallsKit/Media.swift
@@ -157,10 +157,8 @@ import LiveKitWebRTC
/// sender whose clock runs at a different rate than this device's —
/// and stretching a voice is what a growl *is*.
///
- /// On macOS this is the only instrument there is. The framework's macOS
- /// slice ships no audio device API at all, so a bad device cannot be
- /// ruled out by switching away from it the way it can on iPhone; the
- /// counters are what is left. See DESIGN.md.
+ /// With the device pickers beside it, a suspect device can be ruled out
+ /// by switching away from it while watching these.
static let audioHealthFields = [
"concealedSamples", "totalSamplesReceived",
"insertedSamplesForDeceleration", "removedSamplesForAcceleration",
diff --git a/native/apple/Tests/CallsKitTests/AudioHealthTests.swift b/native/apple/Tests/CallsKitTests/AudioHealthTests.swift
index 74651c2..2597fe3 100644
--- a/native/apple/Tests/CallsKitTests/AudioHealthTests.swift
+++ b/native/apple/Tests/CallsKitTests/AudioHealthTests.swift
@@ -120,6 +120,15 @@ 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.
+ if let other = routes.inputs.first(where: { !$0.name.hasPrefix("System default") }) {
+ XCTAssertNoThrow(try routes.use(input: other.id))
+ XCTAssertEqual(routes.currentInput, other.id)
+ routes.reset()
+ XCTAssertEqual(routes.currentInput, routes.inputs.first?.id, "leaving goes back to the default")
+ }
#else
throw XCTSkip("macOS only")
#endif
diff --git a/native/apple/Tests/CallsKitTests/AudioRoutesTests.swift b/native/apple/Tests/CallsKitTests/AudioRoutesTests.swift
new file mode 100644
index 0000000..9bd5101
--- /dev/null
+++ b/native/apple/Tests/CallsKitTests/AudioRoutesTests.swift
@@ -0,0 +1,144 @@
+#if os(macOS)
+ import XCTest
+ @testable import CallsKit
+
+ /// The Mac's per-call 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.
+ @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)
+ }
+ },
+ select: { id, input in
+ guard id == "default" || (input ? self.inputs : self.outputs).contains(id) else {
+ return false
+ }
+ self.selected.append((id, input))
+ return true
+ })
+ }
+ }
+
+ private func routes(_ module: Module) -> AudioRoutes {
+ let routes = AudioRoutes()
+ routes.attach(module.control())
+ return routes
+ }
+
+ func testEveryCallStartsOnTheSystemDefault() {
+ let routes = routes(Module())
+ 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)")
+ }
+
+ func testSelectionsAreIndependentAndResetForNextCall() throws {
+ let module = Module()
+ let routes = routes(module)
+ let other = self.routes(Module())
+ try routes.use(input: "usb")
+ try routes.use(output: "headset")
+ XCTAssertEqual(routes.currentInput, "usb")
+ XCTAssertEqual(routes.currentOutput, "headset")
+ XCTAssertEqual(module.selected.map(\.id), ["usb", "headset"])
+ XCTAssertEqual(other.currentInput, "default")
+ XCTAssertEqual(other.currentOutput, "default")
+ routes.reset()
+ XCTAssertEqual(routes.currentInput, "default")
+ XCTAssertEqual(routes.currentOutput, "default")
+ XCTAssertEqual(
+ module.selected.suffix(2).map(\.id), ["default", "default"],
+ "leaving puts the module back on the system default for the next call")
+ }
+
+ func testEmptyIdIsTheSystemDefault() throws {
+ let module = Module()
+ let routes = routes(module)
+ try routes.use(input: "usb")
+ try routes.use(input: "")
+ XCTAssertEqual(routes.currentInput, "default")
+ XCTAssertEqual(module.selected.last?.id, "default")
+ }
+
+ func testRefusedSwitchKeepsPreviousSelection() throws {
+ let module = Module()
+ let routes = routes(module)
+ 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"))
+ XCTAssertEqual(routes.currentOutput, "default")
+ XCTAssertEqual(module.selected.count, 1, "a refused choice never reaches the module")
+ }
+
+ func testDisconnectedInputFallsBackWithoutChangingSelectedOutput() throws {
+ let module = Module()
+ let routes = routes(module)
+ try routes.use(input: "usb")
+ try routes.use(output: "headset")
+ module.inputs = ["built-in"]
+ routes.refresh()
+ XCTAssertEqual(routes.currentInput, "default")
+ XCTAssertEqual(routes.currentOutput, "headset")
+ XCTAssertEqual(module.selected.last?.id, "default")
+ XCTAssertEqual(module.selected.last?.input, true)
+ XCTAssertTrue(routes.routeNote.contains("microphone"))
+ XCTAssertFalse(routes.routeNote.contains("speaker"))
+ // The device coming back does not take the call back to it: the
+ // fallback is where the call is now, and the note stays until the
+ // person chooses again.
+ module.inputs = ["built-in", "usb"]
+ routes.refresh()
+ XCTAssertEqual(routes.currentInput, "default")
+ XCTAssertFalse(routes.routeNote.isEmpty)
+ try routes.use(input: "usb")
+ XCTAssertEqual(routes.routeNote, "")
+ }
+
+ func testSystemDefaultFollowsChangesButExplicitChoiceDoesNot() throws {
+ let module = Module()
+ let routes = routes(module)
+ // Following: macOS moves its default and the entry follows it, with
+ // nothing asked of the module (it follows the default itself).
+ module.defaultOutput = "Headset"
+ routes.refresh()
+ XCTAssertEqual(routes.currentOutput, "default")
+ XCTAssertEqual(routes.outputName, "System default (Headset)")
+ XCTAssertTrue(module.selected.isEmpty)
+ // Chosen: a later change of default leaves the choice where it is.
+ try routes.use(output: "speakers")
+ let count = module.selected.count
+ module.defaultOutput = "Built-in Speakers"
+ routes.refresh()
+ XCTAssertEqual(module.selected.count, count)
+ XCTAssertEqual(routes.currentOutput, "speakers")
+ }
+
+ func testNoModuleMeansNothingToChoose() {
+ let routes = AudioRoutes()
+ XCTAssertTrue(routes.inputs.isEmpty)
+ XCTAssertNil(routes.currentInput)
+ XCTAssertThrowsError(try routes.use(input: ""))
+ routes.reset()
+ XCTAssertTrue(routes.outputs.isEmpty)
+ }
+ }
+#endif
diff --git a/native/ios/project.yml b/native/ios/project.yml
index 9375d7a..864800d 100644
--- a/native/ios/project.yml
+++ b/native/ios/project.yml
@@ -30,6 +30,8 @@ targets:
buildPhase: resources
- path: ../LICENSE.webrtc
buildPhase: resources
+ - path: ../PATENTS.webrtc
+ buildPhase: resources
info:
path: ../build/ios-info.plist
properties:
diff --git a/native/scripts/bundle-mac.py b/native/scripts/bundle-mac.py
index 2911781..f478491 100644
--- a/native/scripts/bundle-mac.py
+++ b/native/scripts/bundle-mac.py
@@ -25,6 +25,7 @@ shutil.copy2(root / 'LICENSE.deepfilternet', bundle / 'Resources/LICENSE.deepfil
# own licence, for the LiveKitWebRTC framework embedded below.
shutil.copy2(root / 'LICENSE.deepfilternet-crates', bundle / 'Resources/LICENSE.deepfilternet-crates')
shutil.copy2(root / 'LICENSE.webrtc', bundle / 'Resources/LICENSE.webrtc')
+shutil.copy2(root / 'PATENTS.webrtc', bundle / 'Resources/PATENTS.webrtc')
# The Mac's noise suppressor model (scripts/build-deepfilter.sh). Without
# it the app falls back to RNNoise.
shutil.copy2(root / 'build/DeepFilterNet3_onnx.tar.gz', bundle / 'Resources/DeepFilterNet3_onnx.tar.gz')
@@ -49,6 +50,7 @@ info = {
'CFBundleIconFile': 'CallsMac.icns',
'CFBundleShortVersionString': '0.1.0', 'CFBundleVersion': '1',
'LSMinimumSystemVersion': '14.0', 'NSHighResolutionCapable': True,
+ 'NSCameraUseContinuityCameraDeviceType': True,
'NSCameraUsageDescription': 'Calls uses your camera to share video in a meeting.',
'NSMicrophoneUsageDescription': 'Calls uses your microphone so people in the meeting can hear you.',
}
diff --git a/test/browser/native.mjs b/test/browser/native.mjs
index 5eb05eb..4415282 100644
--- a/test/browser/native.mjs
+++ b/test/browser/native.mjs
@@ -36,6 +36,7 @@ try {
let invitationReady = false;
let invitationRefused = false;
let invitationSnapshotSaved = false;
+ let audioSwitched = null;
let camera = null;
let cameraRefused = false;
let exited = false;
@@ -50,6 +51,7 @@ try {
if (value.invitationReady) invitationReady = true;
if (value.invitationRefused) invitationRefused = true;
if (value.invitationSnapshotSaved) invitationSnapshotSaved = true;
+ if (value.audioSwitched !== undefined) audioSwitched = value.audioSwitched;
if (value.camera !== undefined) { camera = value.camera; cameraRefused = value.cameraRefused; }
if (value.stats) { sample = value; console.log(JSON.stringify(value)); }
} catch {}
@@ -105,6 +107,20 @@ try {
});
await wait(async () => await browserAudio() > 0, 'browser receives native audio');
console.log('PASS browser decoded native video and received native audio');
+ // Choose the system default on the audio device module during the live
+ // call, then refuse an unavailable microphone. Both must leave media and
+ // the peer connection intact. (The harness's module is in manual
+ // rendering, so this exercises the routing code, not a device change.)
+ const audioBeforeSwitch = await browserAudio();
+ native.stdin.write('audio-default\n');
+ await wait(() => audioSwitched === true, 'native applies per-call audio selection');
+ await wait(async () => await browserAudio() > audioBeforeSwitch + 1000, 'audio continues after switching');
+ native.stdin.write('audio-missing\n');
+ await wait(() => audioSwitched === false, 'native refuses missing microphone');
+ const audioAfterRefusal = await browserAudio();
+ await wait(async () => await browserAudio() > audioAfterRefusal + 1000, 'audio survives refused selection');
+ assert.equal(sample.stats.peerConnections, 1, 'audio switching keeps the same peer connection');
+ console.log('PASS per-call audio switching and refused selection preserve media');
// Changing camera mid-call. The switch rests on the capturer feeding the
// same RTCVideoSource, so the RTCVideoTrack every connection is already
// sending does not change and nothing renegotiates — the native