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>
16 files changed,
+587
−84
native/PATENTS.webrtc+24 −0native/README.md+36 −3native/VERIFICATION.md+70 −0native/apple/Sources/CallsInterop/Interop.swift+22 −1native/apple/Sources/CallsKit/Audio.swift+155 −36native/apple/Sources/CallsKit/Call.swift+27 −9native/apple/Sources/CallsKit/CallAudio.swift+4 −5native/apple/Sources/CallsKit/Capture.swift+27 −11native/apple/Sources/CallsKit/DevicePanel.swift+46 −14native/apple/Sources/CallsKit/MacCallView.swift+1 −1native/apple/Sources/CallsKit/Media.swift+2 −4native/apple/Tests/CallsKitTests/AudioHealthTests.swift+9 −0native/apple/Tests/CallsKitTests/AudioRoutesTests.swift+144 −0native/ios/project.yml+2 −0native/scripts/bundle-mac.py+2 −0test/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 |