oficina / meet Public

Add per-call camera and audio device selection on Mac

codex agent, operated by paul@keymail.dev 410ddcb55bc892b7120aa81981bfb29c65f5e089 parent 3846ca4
22 files changed, +1174 −169
  • native/LICENSE.webrtc +29 −0
  • native/PATENTS.webrtc +24 −0
  • native/README.md +15 −17
  • native/VERIFICATION.md +31 −0
  • native/apple/Package.swift +2 −1
  • native/apple/Sources/CallsAudio/CallsAudio.m +216 −0
  • native/apple/Sources/CallsAudio/README.md +26 −0
  • native/apple/Sources/CallsAudio/include/CallsAudio.h +11 −0
  • native/apple/Sources/CallsAudio/include/RTCAudioDevice.h +335 −0
  • native/apple/Sources/CallsInterop/Interop.swift +22 −1
  • native/apple/Sources/CallsKit/Audio.swift +96 −64
  • native/apple/Sources/CallsKit/AudioHardware.swift +23 −0
  • native/apple/Sources/CallsKit/Call.swift +31 −19
  • native/apple/Sources/CallsKit/Capture.swift +27 −11
  • native/apple/Sources/CallsKit/DevicePanel.swift +58 −34
  • native/apple/Sources/CallsKit/MacAudioDevices.swift +85 −0
  • native/apple/Sources/CallsKit/MacCallView.swift +1 −1
  • native/apple/Sources/CallsKit/Media.swift +2 −5
  • native/apple/Tests/CallsKitTests/AudioHealthTests.swift +13 −15
  • native/apple/Tests/CallsKitTests/AudioRoutesTests.swift +110 −0
  • native/scripts/bundle-mac.py +3 −1
  • test/browser/native.mjs +14 −0

CI passed — run details

diff --git a/native/LICENSE.webrtc b/native/LICENSE.webrtc
new file mode 100644
index 0000000..4c41b7b
--- /dev/null
+++ b/native/LICENSE.webrtc
@@ -0,0 +1,29 @@
+Copyright (c) 2011, The WebRTC project authors. All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are
+met:
+
+ * Redistributions of source code must retain the above copyright
+ notice, this list of conditions and the following disclaimer.
+
+ * Redistributions in binary form must reproduce the above copyright
+ notice, this list of conditions and the following disclaimer in
+ the documentation and/or other materials provided with the
+ distribution.
+
+ * Neither the name of Google nor the names of its contributors may
+ be used to endorse or promote products derived from this software
+ without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
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 5f6a204..49599ed 100644
--- a/native/README.md
+++ b/native/README.md
@@ -37,20 +37,20 @@ connection already holds does not change and nothing renegotiates — the
native counterpart of the browser's `replaceTrack`. Switches are serialised;
the last pick wins.
-Audio is where the platforms part, and not by choice. WebRTC's iOS slices
-ship `RTCAudioDevice`, `RTCAudioSession` and three more audio headers; the
-macOS slice ships two, `RTCAudioSource` and `RTCAudioTrack`. There is no
-supported way to enumerate or select an audio device for WebRTC on macOS
-through this framework, and no way to supply a custom audio device module
-either, because the protocol is not in the slice. (It *is* in the Mac
-Catalyst slice, so this is a packaging boundary rather than a platform
-limit — but Catalyst would be a poor trade for a real AppKit app.)
-
-So on iPhone the microphone is a picker, and on Mac the panel reports which
-devices macOS has chosen — read from CoreAudio, which is what WebRTC's
-audio device module follows — and offers Sound settings. Naming them is
-worth doing on its own: the first thing someone needs when a call sounds
-wrong is to know what they are connected to.
+On Mac, microphone and speaker pickers override the route for this call only.
+“System default” follows macOS; explicit choices keep their device until you
+change them or it disconnects. Disconnecting a selected device falls back to
+the system default with a notice. Leaving clears the overrides. These choices
+never change Sound settings or another call's devices.
+
+The existing WebRTC binary supports custom audio devices; its Mac package
+omits the protocol header. CallsAudio exposes the matching header from the
+same pinned artifact and supplies an independent pair of CoreAudio HAL units
+per call. See `apple/Sources/CallsAudio/README.md` for the bridge contract.
+
+Camera discovery includes external and Continuity cameras and refreshes while
+the panel is open. A refused camera switch attempts to restore the previous
+camera and returns the picker to the actual selection.
Choosing a Bluetooth microphone on iPhone puts the call on the hands-free
profile, which is narrowband in both directions. That used to happen
@@ -62,9 +62,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.
**Knocks** are announced when someone asks to be let in while the app is
not frontmost. The admission queue is already on screen for a member who is
diff --git a/native/VERIFICATION.md b/native/VERIFICATION.md
index b33c1bb..fc2605e 100644
--- a/native/VERIFICATION.md
+++ b/native/VERIFICATION.md
@@ -1,3 +1,34 @@
+# Per-call device routing — 2026-09-21
+
+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/Package.swift b/native/apple/Package.swift
index 522f2fd..9fe5c35 100644
--- a/native/apple/Package.swift
+++ b/native/apple/Package.swift
@@ -19,8 +19,9 @@ let package = Package(
],
targets: [
.binaryTarget(name: "CallsBridge", path: "../build/CallsBridge.xcframework"),
+ .target(name: "CallsAudio", dependencies: [.product(name: "WebRTC", package: "WebRTC")], exclude: ["README.md"]),
.target(
- name: "CallsKit", dependencies: ["CallsBridge", .product(name: "WebRTC", package: "WebRTC")]),
+ name: "CallsKit", dependencies: ["CallsBridge", .target(name: "CallsAudio", condition: .when(platforms: [.macOS])), .product(name: "WebRTC", package: "WebRTC")]),
.executableTarget(name: "CallsMac", dependencies: [.product(name: "RastrilloMacUpdates", package: "native", condition: .when(platforms: [.macOS])), "CallsKit"], resources: [.copy("Resources/AppIcon.png"), .copy("Resources/MacIcon.png")]),
.executableTarget(name: "CallsInterop", dependencies: ["CallsKit"]),
.testTarget(name: "CallsKitTests", dependencies: ["CallsKit"]),
diff --git a/native/apple/Sources/CallsAudio/CallsAudio.m b/native/apple/Sources/CallsAudio/CallsAudio.m
new file mode 100644
index 0000000..c6328e1
--- /dev/null
+++ b/native/apple/Sources/CallsAudio/CallsAudio.m
@@ -0,0 +1,216 @@
+#import "CallsAudio.h"
+
+// WebRTC calls this object on its ADM thread. UI route changes are marshalled
+// back to that thread; stopping a HAL unit drains callbacks before disposal.
+@implementation CallsAudioDevice {
+ id<RTCAudioDeviceDelegate> _delegate;
+ AudioUnit _inputUnit, _outputUnit;
+ AudioDeviceID _input, _output;
+ double _inputRate, _outputRate;
+ NSTimeInterval _inputDuration, _outputDuration, _inputLatency, _outputLatency;
+ BOOL _recording, _playing;
+}
+
+static OSStatus Recorded(void *context, AudioUnitRenderActionFlags *flags,
+ const AudioTimeStamp *time, UInt32 bus, UInt32 frames,
+ AudioBufferList *data) {
+ CallsAudioDevice *device = (__bridge CallsAudioDevice *)context;
+ return device->_delegate.deliverRecordedData(flags, time, bus, frames, NULL,
+ device->_inputUnit, ^OSStatus(AudioUnitRenderActionFlags *f, const AudioTimeStamp *t,
+ NSInteger b, UInt32 n, AudioBufferList *buffer, void *unit) {
+ return AudioUnitRender(unit, f, t, 1, n, buffer);
+ });
+}
+
+static OSStatus Playback(void *context, AudioUnitRenderActionFlags *flags,
+ const AudioTimeStamp *time, UInt32 bus, UInt32 frames,
+ AudioBufferList *data) {
+ CallsAudioDevice *device = (__bridge CallsAudioDevice *)context;
+ return device->_delegate.getPlayoutData(flags, time, bus, frames, data);
+}
+
+static void Dispose(AudioUnit *unit) {
+ if (!*unit) return;
+ AudioOutputUnitStop(*unit);
+ AudioUnitUninitialize(*unit);
+ AudioComponentInstanceDispose(*unit);
+ *unit = NULL;
+}
+
+// Zero means follow the system default, without writing it.
+static AudioDeviceID DefaultDevice(BOOL input) {
+ AudioDeviceID device = 0;
+ UInt32 size = sizeof(device);
+ AudioObjectPropertyAddress address = {
+ input ? kAudioHardwarePropertyDefaultInputDevice : kAudioHardwarePropertyDefaultOutputDevice,
+ kAudioObjectPropertyScopeGlobal, kAudioObjectPropertyElementMain};
+ AudioObjectGetPropertyData(kAudioObjectSystemObject, &address, 0, NULL, &size, &device);
+ return device;
+}
+
+static UInt32 DeviceFrames(AudioDeviceID device, AudioObjectPropertySelector selector,
+ AudioObjectPropertyScope scope) {
+ UInt32 frames = 0, size = sizeof(frames);
+ AudioObjectPropertyAddress address = {selector, scope, kAudioObjectPropertyElementMain};
+ AudioObjectGetPropertyData(device, &address, 0, NULL, &size, &frames);
+ return frames;
+}
+
+- (OSStatus)createUnit:(BOOL)input {
+ AudioUnit *unit = input ? &_inputUnit : &_outputUnit;
+ if (*unit) return noErr;
+ AudioDeviceID device = input ? _input : _output;
+ if (!device) device = DefaultDevice(input);
+ if (!device) return kAudioHardwareBadDeviceError;
+ AudioComponentDescription description = {kAudioUnitType_Output, kAudioUnitSubType_HALOutput,
+ kAudioUnitManufacturer_Apple, 0, 0};
+ AudioComponent component = AudioComponentFindNext(NULL, &description);
+ if (!component) return kAudio_ParamError;
+ OSStatus status = AudioComponentInstanceNew(component, unit);
+ if (status) return status;
+ #define CHECK(operation) do { status = (operation); if (status) { Dispose(unit); return status; } } while (0)
+ UInt32 enabled = 1, disabled = 0;
+ CHECK(AudioUnitSetProperty(*unit, kAudioOutputUnitProperty_EnableIO,
+ kAudioUnitScope_Input, 1, input ? &enabled : &disabled, sizeof(enabled)));
+ CHECK(AudioUnitSetProperty(*unit, kAudioOutputUnitProperty_EnableIO,
+ kAudioUnitScope_Output, 0, input ? &disabled : &enabled, sizeof(enabled)));
+ CHECK(AudioUnitSetProperty(*unit, kAudioOutputUnitProperty_CurrentDevice,
+ kAudioUnitScope_Global, 0, &device, sizeof(device)));
+ AudioStreamBasicDescription hardware = {0};
+ UInt32 size = sizeof(hardware);
+ CHECK(AudioUnitGetProperty(*unit, kAudioUnitProperty_StreamFormat,
+ input ? kAudioUnitScope_Input : kAudioUnitScope_Output, input ? 1 : 0, &hardware, &size));
+ // The HAL converts the device's channel layout and PCM representation.
+ // Use its sample rate so capture needs no asynchronous rate converter.
+ double rate = hardware.mSampleRate;
+ UInt32 channels = 1;
+ AudioStreamBasicDescription format = {rate, kAudioFormatLinearPCM,
+ kAudioFormatFlagIsSignedInteger | kAudioFormatFlagIsPacked,
+ channels * 2, 1, channels * 2, channels, 16, 0};
+ CHECK(AudioUnitSetProperty(*unit, kAudioUnitProperty_StreamFormat,
+ input ? kAudioUnitScope_Output : kAudioUnitScope_Input, input ? 1 : 0, &format, sizeof(format)));
+ // Mirror mono call audio to the first two speakers; leave other interface
+ // channels silent. Input uses the first microphone channel.
+ UInt32 mapCount = input ? 1 : hardware.mChannelsPerFrame;
+ SInt32 *map = calloc(mapCount, sizeof(SInt32));
+ if (!map) { Dispose(unit); return kAudio_MemFullError; }
+ for (UInt32 channel = 0; channel < mapCount; channel++) map[channel] = channel < 2 ? 0 : -1;
+ status = AudioUnitSetProperty(*unit, kAudioOutputUnitProperty_ChannelMap,
+ input ? kAudioUnitScope_Output : kAudioUnitScope_Input, input ? 1 : 0,
+ map, mapCount * sizeof(SInt32));
+ free(map);
+ if (status) { Dispose(unit); return status; }
+ AURenderCallbackStruct callback = {input ? Recorded : Playback, (__bridge void *)self};
+ CHECK(AudioUnitSetProperty(*unit,
+ input ? kAudioOutputUnitProperty_SetInputCallback : kAudioUnitProperty_SetRenderCallback,
+ input ? kAudioUnitScope_Global : kAudioUnitScope_Input, input ? 1 : 0,
+ &callback, sizeof(callback)));
+ CHECK(AudioUnitInitialize(*unit));
+ UInt32 frames = DeviceFrames(device, kAudioDevicePropertyBufferFrameSize, kAudioObjectPropertyScopeGlobal);
+ AudioObjectPropertyScope scope = input ? kAudioDevicePropertyScopeInput : kAudioDevicePropertyScopeOutput;
+ UInt32 latency = DeviceFrames(device, kAudioDevicePropertyLatency, scope)
+ + DeviceFrames(device, kAudioDevicePropertySafetyOffset, scope);
+ if (input) {
+ _inputRate = rate; _inputDuration = frames / rate; _inputLatency = (latency + frames) / rate;
+ } else {
+ _outputRate = rate; _outputDuration = frames / rate; _outputLatency = (latency + frames) / rate;
+ }
+ #undef CHECK
+ return noErr;
+}
+
+- (BOOL)selectInput:(AudioDeviceID)input output:(AudioDeviceID)output error:(NSError **)error {
+ __block OSStatus status = noErr;
+ void (^change)(void) = ^{
+ BOOL recording = self->_recording, playing = self->_playing;
+ AudioDeviceID oldInput = self->_input, oldOutput = self->_output;
+ Dispose(&self->_inputUnit);
+ Dispose(&self->_outputUnit);
+ [self->_delegate notifyAudioInputInterrupted];
+ [self->_delegate notifyAudioOutputInterrupted];
+ self->_input = input;
+ self->_output = output;
+ status = [self restartRecording:recording playing:playing];
+ if (status) {
+ // Keep the previous route when a device disappears during selection.
+ Dispose(&self->_inputUnit);
+ Dispose(&self->_outputUnit);
+ [self->_delegate notifyAudioInputInterrupted];
+ [self->_delegate notifyAudioOutputInterrupted];
+ self->_input = oldInput;
+ self->_output = oldOutput;
+ [self restartRecording:recording playing:playing];
+ }
+ };
+ if (_delegate) [_delegate dispatchSync:change]; else { _input = input; _output = output; }
+ if (status && error) {
+ *error = [NSError errorWithDomain:NSOSStatusErrorDomain code:status userInfo:@{
+ NSLocalizedDescriptionKey: @"That audio device could not be opened. Check its connection and try again."}];
+ }
+ return status == noErr;
+}
+
+- (OSStatus)restartRecording:(BOOL)recording playing:(BOOL)playing {
+ OSStatus status = noErr;
+ // Validate both selected devices even when there are no remote participants.
+ if ((status = [self createUnit:YES])) return status;
+ if ((status = [self createUnit:NO])) return status;
+ [_delegate notifyAudioInputParametersChange];
+ [_delegate notifyAudioOutputParametersChange];
+ if (recording && (status = AudioOutputUnitStart(_inputUnit))) return status;
+ if (playing && (status = AudioOutputUnitStart(_outputUnit))) return status;
+ return noErr;
+}
+
+- (void)resetSelection {
+ void (^reset)(void) = ^{
+ Dispose(&self->_inputUnit); Dispose(&self->_outputUnit);
+ [self->_delegate notifyAudioInputInterrupted];
+ [self->_delegate notifyAudioOutputInterrupted];
+ self->_input = 0; self->_output = 0;
+ self->_recording = NO; self->_playing = NO;
+ };
+ if (_delegate) [_delegate dispatchSync:reset]; else reset();
+}
+
+- (double)deviceInputSampleRate { return _inputRate ?: 48000; }
+- (double)deviceOutputSampleRate { return _outputRate ?: 48000; }
+- (NSTimeInterval)inputIOBufferDuration { return _inputDuration ?: 0.01; }
+- (NSTimeInterval)outputIOBufferDuration { return _outputDuration ?: 0.01; }
+- (NSInteger)inputNumberOfChannels { return 1; }
+- (NSInteger)outputNumberOfChannels { return 1; }
+- (NSTimeInterval)inputLatency { return _inputLatency; }
+- (NSTimeInterval)outputLatency { return _outputLatency; }
+- (BOOL)isInitialized { return _delegate != nil; }
+- (BOOL)initializeWithDelegate:(id<RTCAudioDeviceDelegate>)delegate { _delegate = delegate; return YES; }
+- (BOOL)terminateDevice {
+ Dispose(&_inputUnit); Dispose(&_outputUnit);
+ _playing = NO; _recording = NO; _delegate = nil;
+ return YES;
+}
+- (BOOL)isPlayoutInitialized { return _outputUnit != NULL; }
+- (BOOL)isRecordingInitialized { return _inputUnit != NULL; }
+- (BOOL)initializePlayout {
+ if ([self createUnit:NO]) return NO;
+ [_delegate notifyAudioOutputParametersChange];
+ return YES;
+}
+- (BOOL)initializeRecording {
+ if ([self createUnit:YES]) return NO;
+ [_delegate notifyAudioInputParametersChange];
+ return YES;
+}
+- (BOOL)isPlaying { return _playing; }
+- (BOOL)isRecording { return _recording; }
+- (BOOL)startPlayout {
+ if (![self initializePlayout] || AudioOutputUnitStart(_outputUnit)) return NO;
+ _playing = YES; return YES;
+}
+- (BOOL)startRecording {
+ if (![self initializeRecording] || AudioOutputUnitStart(_inputUnit)) return NO;
+ _recording = YES; return YES;
+}
+- (BOOL)stopPlayout { Dispose(&_outputUnit); [_delegate notifyAudioOutputInterrupted]; _playing = NO; return YES; }
+- (BOOL)stopRecording { Dispose(&_inputUnit); [_delegate notifyAudioInputInterrupted]; _recording = NO; return YES; }
+- (void)dealloc { Dispose(&_inputUnit); Dispose(&_outputUnit); }
+@end
diff --git a/native/apple/Sources/CallsAudio/README.md b/native/apple/Sources/CallsAudio/README.md
new file mode 100644
index 0000000..2bfc1cb
--- /dev/null
+++ b/native/apple/Sources/CallsAudio/README.md
@@ -0,0 +1,26 @@
+# macOS call audio
+
+`RTCAudioDevice.h` is copied verbatim from the **iOS slice of the existing
+stasel/WebRTC 152.0.0 artifact** (checksum pinned in that package). The Mac
+slice omits this header, but includes both the factory's `audioDevice:`
+initializer and its Objective-C audio-device implementation. CallsAudio
+makes the same public protocol visible on Mac; it does not use private
+selectors, C++ ABI declarations, a replacement framework, or global audio
+preferences. Keep this header in sync when updating WebRTC.
+
+The upstream copyright notice remains in the header; license and patent
+grant are in `native/LICENSE.webrtc` and `native/PATENTS.webrtc`.
+
+Each call supplies its own CallsAudioDevice to its peer connection factory.
+The device owns an input and output HAL unit. Route changes execute on the
+WebRTC audio thread and stop/drain callbacks before disposing units, notify
+WebRTC of the interruption and new formats, then restart the existing tracks.
+A refused route attempts to restore the old pair. Units use native sample
+rates and interleaved signed 16-bit mono PCM, with the microphone's first
+channel and mono playback mirrored to the first two output channels.
+WebRTC retains responsibility for voice processing and transport.
+
+CoreAudio enumeration and default-device reads are in CallsKit. Call-local
+UID choices resolve to device IDs at use time. The call refreshes routes
+while active (also every second while the device panel is open), recovering
+disconnected selections and following default-device/sample-rate changes.
diff --git a/native/apple/Sources/CallsAudio/include/CallsAudio.h b/native/apple/Sources/CallsAudio/include/CallsAudio.h
new file mode 100644
index 0000000..6e8a22c
--- /dev/null
+++ b/native/apple/Sources/CallsAudio/include/CallsAudio.h
@@ -0,0 +1,11 @@
+#import <Foundation/Foundation.h>
+#import <CoreAudio/CoreAudio.h>
+#import "RTCAudioDevice.h"
+
+NS_ASSUME_NONNULL_BEGIN
+/// An independent pair of HAL audio units for one call. Never changes system defaults.
+@interface CallsAudioDevice : NSObject <RTCAudioDevice>
+- (BOOL)selectInput:(AudioDeviceID)input output:(AudioDeviceID)output error:(NSError **)error;
+- (void)resetSelection;
+@end
+NS_ASSUME_NONNULL_END
diff --git a/native/apple/Sources/CallsAudio/include/RTCAudioDevice.h b/native/apple/Sources/CallsAudio/include/RTCAudioDevice.h
new file mode 100644
index 0000000..c28076c
--- /dev/null
+++ b/native/apple/Sources/CallsAudio/include/RTCAudioDevice.h
@@ -0,0 +1,335 @@
+/*
+ * Copyright 2022 The WebRTC project authors. All Rights Reserved.
+ *
+ * Use of this source code is governed by a BSD-style license
+ * that can be found in the LICENSE file in the root of the source
+ * tree. An additional intellectual property rights grant can be found
+ * in the file PATENTS. All contributing project authors may
+ * be found in the AUTHORS file in the root of the source tree.
+ */
+
+#import <AudioUnit/AudioUnit.h>
+#import <Foundation/Foundation.h>
+
+#import <WebRTC/RTCMacros.h>
+
+NS_ASSUME_NONNULL_BEGIN
+
+typedef OSStatus (^RTC_OBJC_TYPE(RTCAudioDeviceGetPlayoutDataBlock))(
+ AudioUnitRenderActionFlags *_Nonnull actionFlags,
+ const AudioTimeStamp *_Nonnull timestamp,
+ NSInteger inputBusNumber,
+ UInt32 frameCount,
+ AudioBufferList *_Nonnull outputData);
+
+typedef OSStatus (^RTC_OBJC_TYPE(RTCAudioDeviceRenderRecordedDataBlock))(
+ AudioUnitRenderActionFlags *_Nonnull actionFlags,
+ const AudioTimeStamp *_Nonnull timestamp,
+ NSInteger inputBusNumber,
+ UInt32 frameCount,
+ AudioBufferList *_Nonnull inputData,
+ void *_Nullable renderContext);
+
+typedef OSStatus (^RTC_OBJC_TYPE(RTCAudioDeviceDeliverRecordedDataBlock))(
+ AudioUnitRenderActionFlags *_Nonnull actionFlags,
+ const AudioTimeStamp *_Nonnull timestamp,
+ NSInteger inputBusNumber,
+ UInt32 frameCount,
+ const AudioBufferList *_Nullable inputData,
+ void *_Nullable renderContext,
+ NS_NOESCAPE RTC_OBJC_TYPE(
+ RTCAudioDeviceRenderRecordedDataBlock) _Nullable renderBlock);
+
+/**
+ * Delegate object provided by native ADM during RTCAudioDevice initialization.
+ * Provides blocks to poll playback audio samples from native ADM and to feed
+ * recorded audio samples into native ADM.
+ */
+RTC_OBJC_EXPORT @protocol RTC_OBJC_TYPE
+(RTCAudioDeviceDelegate)<NSObject>
+ /**
+ * Implementation of RTCAudioSource should call this block to feed recorded
+ * PCM (16-bit integer) into native ADM. Stereo data is expected to be
+ * interleaved starting with the left channel. Either `inputData` with
+ * pre-filled audio data must be provided during block call or `renderBlock`
+ * must be provided which must fill provided audio buffer with recorded
+ * samples.
+ *
+ * NOTE: Implementation of RTCAudioDevice is expected to call the block on
+ * the same thread until `notifyAudioInterrupted` is called. When
+ * `notifyAudioInterrupted` is called implementation can call the block on a
+ * different thread.
+ */
+ @property(readonly, nonnull)
+ RTC_OBJC_TYPE(RTCAudioDeviceDeliverRecordedDataBlock)
+ deliverRecordedData;
+
+/**
+ * Provides input sample rate preference as it preferred by native ADM.
+ */
+@property(readonly) double preferredInputSampleRate;
+
+/**
+ * Provides input IO buffer duration preference as it preferred by native ADM.
+ */
+@property(readonly) NSTimeInterval preferredInputIOBufferDuration;
+
+/**
+ * Provides output sample rate preference as it preferred by native ADM.
+ */
+@property(readonly) double preferredOutputSampleRate;
+
+/**
+ * Provides output IO buffer duration preference as it preferred by native ADM.
+ */
+@property(readonly) NSTimeInterval preferredOutputIOBufferDuration;
+
+/**
+ * Implementation of RTCAudioDevice should call this block to request PCM
+ * (16-bit integer) from native ADM to play. Stereo data is interleaved starting
+ * with the left channel.
+ *
+ * NOTE: Implementation of RTCAudioDevice is expected to invoke of this block on
+ * the same thread until `notifyAudioInterrupted` is called. When
+ * `notifyAudioInterrupted` is called implementation can call the block from a
+ * different thread.
+ */
+@property(readonly, nonnull) RTC_OBJC_TYPE(RTCAudioDeviceGetPlayoutDataBlock)
+ getPlayoutData;
+
+/**
+ * Notifies native ADM that some of the audio input parameters of RTCAudioDevice
+ * like samle rate and/or IO buffer duration and/or IO latency had possibly
+ * changed. Native ADM will adjust its audio input buffer to match current
+ * parameters of audio device.
+ *
+ * NOTE: Must be called within block executed via `dispatchAsync` or
+ * `dispatchSync`.
+ */
+- (void)notifyAudioInputParametersChange;
+
+/**
+ * Notifies native ADM that some of the audio output parameters of
+ * RTCAudioDevice like samle rate and/or IO buffer duration and/or IO latency
+ * had possibly changed. Native ADM will adjust its audio output buffer to match
+ * current parameters of audio device.
+ *
+ * NOTE: Must be called within block executed via `dispatchAsync` or
+ * `dispatchSync`.
+ */
+- (void)notifyAudioOutputParametersChange;
+
+/**
+ * Notifies native ADM that audio input is interrupted and further audio playout
+ * and recording might happen on a different thread.
+ *
+ * NOTE: Must be called within block executed via `dispatchAsync` or
+ * `dispatchSync`.
+ */
+- (void)notifyAudioInputInterrupted;
+
+/**
+ * Notifies native ADM that audio output is interrupted and further audio
+ * playout and recording might happen on a different thread.
+ *
+ * NOTE: Must be called within block executed via `dispatchAsync` or
+ * `dispatchSync`.
+ */
+- (void)notifyAudioOutputInterrupted;
+
+/**
+ * Asynchronously execute block of code within the context of
+ * thread which owns native ADM.
+ *
+ * NOTE: Intended to be used to invoke `notifyAudioInputParametersChange`,
+ * `notifyAudioOutputParametersChange`, `notifyAudioInputInterrupted`,
+ * `notifyAudioOutputInterrupted` on native ADM thread.
+ * Also could be used by `RTCAudioDevice` implementation to tie
+ * mutations of underlying audio objects (AVAudioEngine, AudioUnit, etc)
+ * to the native ADM thread. Could be useful to handle events like audio route
+ * change, which could lead to audio parameters change.
+ */
+- (void)dispatchAsync:(dispatch_block_t)block;
+
+/**
+ * Synchronously execute block of code within the context of
+ * thread which owns native ADM. Allows reentrancy.
+ *
+ * NOTE: Intended to be used to invoke `notifyAudioInputParametersChange`,
+ * `notifyAudioOutputParametersChange`, `notifyAudioInputInterrupted`,
+ * `notifyAudioOutputInterrupted` on native ADM thread and make sure
+ * aforementioned is completed before `dispatchSync` returns. Could be useful
+ * when implementation of `RTCAudioDevice` tie mutation to underlying audio
+ * objects (AVAudioEngine, AudioUnit, etc) to own thread to satisfy requirement
+ * that native ADM audio parameters must be kept in sync with current audio
+ * parameters before audio is actually played or recorded.
+ */
+- (void)dispatchSync:(dispatch_block_t)block;
+
+@end
+
+/**
+ * Protocol to abstract platform specific ways to implement playback and
+ * recording.
+ *
+ * NOTE: All the members of protocol are called by native ADM from the same
+ * thread between calls to `initializeWithDelegate` and `terminate`. NOTE:
+ * Implementation is fully responsible for configuring application's
+ * AVAudioSession. An example implementation of RTCAudioDevice:
+ * https://github.com/mstyura/RTCAudioDevice
+ * TODO(yura.yaroshevich): Implement custom RTCAudioDevice for AppRTCMobile demo
+ * app.
+ */
+RTC_OBJC_EXPORT @protocol RTC_OBJC_TYPE
+(RTCAudioDevice)<NSObject>
+
+ /**
+ * Indicates current sample rate of audio recording. Changes to this
+ * property must be notified back to native ADM via
+ * `-[RTCAudioDeviceDelegate notifyAudioParametersChange]`.
+ */
+ @property(readonly) double deviceInputSampleRate;
+
+/**
+ * Indicates current size of record buffer. Changes to this property
+ * must be notified back to native ADM via `-[RTCAudioDeviceDelegate
+ * notifyAudioParametersChange]`.
+ */
+@property(readonly) NSTimeInterval inputIOBufferDuration;
+
+/**
+ * Indicates current number of recorded audio channels. Changes to this property
+ * must be notified back to native ADM via `-[RTCAudioDeviceDelegate
+ * notifyAudioParametersChange]`.
+ */
+@property(readonly) NSInteger inputNumberOfChannels;
+
+/**
+ * Indicates current input latency
+ */
+@property(readonly) NSTimeInterval inputLatency;
+
+/**
+ * Indicates current sample rate of audio playback. Changes to this property
+ * must be notified back to native ADM via `-[RTCAudioDeviceDelegate
+ * notifyAudioParametersChange]`.
+ */
+@property(readonly) double deviceOutputSampleRate;
+
+/**
+ * Indicates current size of playback buffer. Changes to this property
+ * must be notified back to native ADM via `-[RTCAudioDeviceDelegate
+ * notifyAudioParametersChange]`.
+ */
+@property(readonly) NSTimeInterval outputIOBufferDuration;
+
+/**
+ * Indicates current number of playback audio channels. Changes to this property
+ * must be notified back to WebRTC via `[RTCAudioDeviceDelegate
+ * notifyAudioParametersChange]`.
+ */
+@property(readonly) NSInteger outputNumberOfChannels;
+
+/**
+ * Indicates current output latency
+ */
+@property(readonly) NSTimeInterval outputLatency;
+
+/**
+ * Indicates if invocation of `initializeWithDelegate` required before usage of
+ * RTCAudioDevice. YES indicates that `initializeWithDelegate` was called
+ * earlier without subsequent call to `terminate`. NO indicates that either
+ * `initializeWithDelegate` not called or `terminate` called.
+ */
+@property(readonly) BOOL isInitialized;
+
+/**
+ * Initializes RTCAudioDevice with RTCAudioDeviceDelegate.
+ * Implementation must return YES if RTCAudioDevice initialized successfully and
+ * NO otherwise.
+ */
+- (BOOL)initializeWithDelegate:
+ (id<RTC_OBJC_TYPE(RTCAudioDeviceDelegate)>)delegate;
+
+/**
+ * De-initializes RTCAudioDevice. Implementation should forget about `delegate`
+ * provided in `initializeWithDelegate`.
+ */
+- (BOOL)terminateDevice;
+
+/**
+ * Property to indicate if `initializePlayout` call required before invocation
+ * of `startPlayout`. YES indicates that `initializePlayout` was successfully
+ * invoked earlier or not necessary, NO indicates that `initializePlayout`
+ * invocation required.
+ */
+@property(readonly) BOOL isPlayoutInitialized;
+
+/**
+ * Prepares RTCAudioDevice to play audio.
+ * Called by native ADM before invocation of `startPlayout`.
+ * Implementation is expected to return YES in case of successful playout
+ * initialization and NO otherwise.
+ */
+- (BOOL)initializePlayout;
+
+/**
+ * Property to indicate if RTCAudioDevice should be playing according to
+ * earlier calls of `startPlayout` and `stopPlayout`.
+ */
+@property(readonly) BOOL isPlaying;
+
+/**
+ * Method is called when native ADM wants to play audio.
+ * Implementation is expected to return YES if playback start request
+ * successfully handled and NO otherwise.
+ */
+- (BOOL)startPlayout;
+
+/**
+ * Method is called when native ADM no longer needs to play audio.
+ * Implementation is expected to return YES if playback stop request
+ * successfully handled and NO otherwise.
+ */
+- (BOOL)stopPlayout;
+
+/**
+ * Property to indicate if `initializeRecording` call required before usage of
+ * `startRecording`. YES indicates that `initializeRecording` was successfully
+ * invoked earlier or not necessary, NO indicates that `initializeRecording`
+ * invocation required.
+ */
+@property(readonly) BOOL isRecordingInitialized;
+
+/**
+ * Prepares RTCAudioDevice to record audio.
+ * Called by native ADM before invocation of `startRecording`.
+ * Implementation may use this method to prepare resources required to record
+ * audio. Implementation is expected to return YES in case of successful record
+ * initialization and NO otherwise.
+ */
+- (BOOL)initializeRecording;
+
+/**
+ * Property to indicate if RTCAudioDevice should record audio according to
+ * earlier calls to `startRecording` and `stopRecording`.
+ */
+@property(readonly) BOOL isRecording;
+
+/**
+ * Method is called when native ADM wants to record audio.
+ * Implementation is expected to return YES if recording start request
+ * successfully handled and NO otherwise.
+ */
+- (BOOL)startRecording;
+
+/**
+ * Method is called when native ADM no longer needs to record audio.
+ * Implementation is expected to return YES if recording stop request
+ * successfully handled and NO otherwise.
+ */
+- (BOOL)stopRecording;
+
+@end
+
+NS_ASSUME_NONNULL_END
diff --git a/native/apple/Sources/CallsInterop/Interop.swift b/native/apple/Sources/CallsInterop/Interop.swift
index c358db5..d28c8b1 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
@@ -56,6 +56,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)
@@ -134,6 +145,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 495036d..c04884e 100644
--- a/native/apple/Sources/CallsKit/Audio.swift
+++ b/native/apple/Sources/CallsKit/Audio.swift
@@ -3,15 +3,14 @@ import Foundation
#if os(macOS)
import CoreAudio
+ import CallsAudio
#endif
#if os(iOS)
import WebRTC
#endif
-/// One audio device the person can be on. `selectable` is the whole
-/// platform difference in one flag: on iPhone a route can be chosen, and
-/// on Mac it can only be reported.
+/// A named audio route available to the call.
public struct AudioDevice: Identifiable, Hashable, Sendable {
public let id: String
public let name: String
@@ -23,42 +22,40 @@ public struct AudioDevice: Identifiable, Hashable, Sendable {
}
}
-/// The audio devices a call is on, and — where the platform allows it —
-/// the ability to change them.
-///
-/// The asymmetry here is not a design choice. WebRTC's macOS slice ships
-/// two audio headers, RTCAudioSource and RTCAudioTrack; the iOS slices
-/// ship five, including RTCAudioDevice and RTCAudioSession. There is no
-/// supported way to enumerate or select an audio device for WebRTC on
-/// macOS through this framework, and no way to supply a custom audio
-/// device module either, because the protocol that would be implemented
-/// is not in the slice. (It *is* in the Mac Catalyst slice, so this is a
-/// packaging boundary rather than a platform impossibility — but a
-/// Catalyst build would be a poor trade for a real AppKit app.)
-///
-/// So on Mac this reports what macOS has chosen, read straight from
-/// CoreAudio, and offers the Sound settings pane to change it. Naming the
-/// device is worth doing on its own: the first thing a person needs when
-/// a call sounds wrong is to know what they are actually connected to,
-/// and until now the app could not tell them.
+/// Call-local audio choices. macOS routes directly through independent HAL units.
@MainActor public final class AudioRoutes: ObservableObject {
@Published public private(set) var inputs: [AudioDevice] = []
@Published public private(set) var currentInput: String?
@Published public private(set) var outputName = ""
- /// True where inputs can actually be chosen. The UI shows a picker when
- /// it is set and a plain line of text when it is not, rather than a
- /// disabled control that looks like something is broken.
- public let selectable: Bool
+ public let selectable = true
+ #if os(macOS)
+ let device = CallsAudioDevice()
+ private lazy var hardware = AudioHardware(device: device)
+ @Published public private(set) var outputs: [AudioDevice] = []
+ @Published public private(set) var currentOutput: String? = ""
+ @Published public private(set) var routeNote = ""
+ private var activeInput: AudioDeviceID = 0
+ private var activeOutput: AudioDeviceID = 0
+ private var inputRate = 0.0
+ private var outputRate = 0.0
+ private var routing = false
+ #endif
public init() {
- #if os(iOS)
- selectable = true
- #else
- selectable = false
+ #if os(macOS)
+ currentInput = ""
#endif
refresh()
}
+ #if os(macOS)
+ init(hardware: AudioHardware) {
+ self.hardware = hardware
+ currentInput = ""
+ refresh()
+ }
+ #endif
+
public func refresh() {
#if os(iOS)
let session = AVAudioSession.sharedInstance()
@@ -68,9 +65,30 @@ public struct AudioDevice: Identifiable, Hashable, Sendable {
currentInput = session.currentRoute.inputs.first?.uid
outputName = session.currentRoute.outputs.first?.portName ?? ""
#else
- inputs = macDevice(input: true).map { [$0] } ?? []
- currentInput = inputs.first?.id
- outputName = macDevice(input: false)?.name ?? ""
+ let availableInputs = hardware.list(true)
+ let availableOutputs = hardware.list(false)
+ if inputs != availableInputs { inputs = availableInputs }
+ if outputs != availableOutputs { outputs = availableOutputs }
+ let lostInput = !(currentInput ?? "").isEmpty && !inputs.contains { $0.id == currentInput }
+ let lostOutput =
+ !(currentOutput ?? "").isEmpty && !outputs.contains { $0.id == currentOutput }
+ let input = lostInput ? "" : currentInput ?? ""
+ let output = lostOutput ? "" : currentOutput ?? ""
+ let inputID = hardware.resolve(input, true)
+ let outputID = hardware.resolve(output, false)
+ if routing
+ && (inputID != activeInput || outputID != activeOutput
+ || hardware.sampleRate(inputID) != inputRate
+ || hardware.sampleRate(outputID) != outputRate)
+ {
+ do {
+ try apply(input: input, output: output)
+ if lostInput || lostOutput {
+ routeNote = "A selected device disconnected. This call is using the system default."
+ }
+ } catch { routeNote = error.localizedDescription }
+ }
+ if !routing { outputName = hardware.name(outputID) }
#endif
}
@@ -131,38 +149,52 @@ public struct AudioDevice: Identifiable, Hashable, Sendable {
#endif
#if os(macOS)
- /// The system's default input or output device, by name. CoreAudio is
- /// the only way to ask: the WebRTC slice cannot say, and this is
- /// precisely the device it will be using, because its audio device
- /// module follows the system default.
- private func macDevice(input: Bool) -> AudioDevice? {
- var id = AudioDeviceID(0)
- var size = UInt32(MemoryLayout<AudioDeviceID>.size)
- var address = AudioObjectPropertyAddress(
- mSelector: input
- ? kAudioHardwarePropertyDefaultInputDevice : kAudioHardwarePropertyDefaultOutputDevice,
- mScope: kAudioObjectPropertyScopeGlobal,
- mElement: kAudioObjectPropertyElementMain)
- guard
- AudioObjectGetPropertyData(
- AudioObjectID(kAudioObjectSystemObject), &address, 0, nil, &size, &id) == noErr,
- id != kAudioObjectUnknown
- else { return nil }
- // Unmanaged, not CFString: CoreAudio writes a retained reference into
- // this buffer, and pointing at a managed CFString both trips ARC's
- // "object reference in a raw buffer" warning and leaves the +1
- // unbalanced. takeRetainedValue consumes it exactly once.
- var name: Unmanaged<CFString>?
- var nameSize = UInt32(MemoryLayout<Unmanaged<CFString>?>.size)
- var nameAddress = AudioObjectPropertyAddress(
- mSelector: kAudioObjectPropertyName,
- mScope: kAudioObjectPropertyScopeGlobal,
- mElement: kAudioObjectPropertyElementMain)
- guard
- AudioObjectGetPropertyData(id, &nameAddress, 0, nil, &nameSize, &name) == noErr,
- let value = name?.takeRetainedValue()
- else { return nil }
- return AudioDevice(id: String(id), name: value as String, selectable: false)
+ public func use(input id: String) throws {
+ try apply(input: id, output: currentOutput ?? "")
+ }
+
+ public func use(output id: String) throws {
+ try apply(input: currentInput ?? "", output: id)
+ }
+
+ func begin() {
+ routing = true
+ activeInput = hardware.resolve(currentInput, true)
+ activeOutput = hardware.resolve(currentOutput, false)
+ inputRate = hardware.sampleRate(activeInput)
+ outputRate = hardware.sampleRate(activeOutput)
+ outputName = hardware.name(activeOutput)
+ refresh()
+ }
+
+ func reset() {
+ routing = false
+ // Peers have already closed. Clear preferences without opening hardware.
+ hardware.reset()
+ currentInput = ""
+ currentOutput = ""
+ activeInput = 0
+ activeOutput = 0
+ routeNote = ""
+ }
+
+ private func apply(input: String, output: String) throws {
+ let inputID = hardware.resolve(input, true)
+ let outputID = hardware.resolve(output, false)
+ guard inputID != 0, outputID != 0 else {
+ throw NSError(
+ domain: "Calls", code: 5,
+ userInfo: [NSLocalizedDescriptionKey: "That audio device is no longer available."])
+ }
+ try hardware.select(inputID, outputID)
+ currentInput = input
+ currentOutput = output
+ activeInput = inputID
+ activeOutput = outputID
+ inputRate = hardware.sampleRate(inputID)
+ outputRate = hardware.sampleRate(outputID)
+ outputName = hardware.name(outputID)
+ routeNote = ""
}
#endif
}
diff --git a/native/apple/Sources/CallsKit/AudioHardware.swift b/native/apple/Sources/CallsKit/AudioHardware.swift
new file mode 100644
index 0000000..e2be580
--- /dev/null
+++ b/native/apple/Sources/CallsKit/AudioHardware.swift
@@ -0,0 +1,23 @@
+#if os(macOS)
+ import CallsAudio
+ import CoreAudio
+
+ /// The hardware boundary also lets route recovery be tested without opening a microphone.
+ struct AudioHardware {
+ var list: (Bool) -> [AudioDevice]
+ var resolve: (String?, Bool) -> AudioDeviceID
+ var sampleRate: (AudioDeviceID) -> Double
+ var name: (AudioDeviceID) -> String
+ var select: (AudioDeviceID, AudioDeviceID) throws -> Void
+ var reset: () -> Void
+
+ init(device: CallsAudioDevice) {
+ list = { MacAudioDevices.list(input: $0) }
+ resolve = { MacAudioDevices.resolve($0, input: $1) }
+ sampleRate = { MacAudioDevices.sampleRate($0) }
+ name = { MacAudioDevices.name($0) }
+ select = { try device.selectInput($0, output: $1) }
+ reset = { device.resetSelection() }
+ }
+ }
+#endif
diff --git a/native/apple/Sources/CallsKit/Call.swift b/native/apple/Sources/CallsKit/Call.swift
index 9235300..df6617d 100644
--- a/native/apple/Sources/CallsKit/Call.swift
+++ b/native/apple/Sources/CallsKit/Call.swift
@@ -1,7 +1,7 @@
-import Combine
-import Foundation
// Go serializes this API with a mutex; Objective-C bindings carry no Sendable annotation.
@preconcurrency import CallsBridge
+import Combine
+import Foundation
import WebRTC
// The Go callback only enqueues. The main actor owns the interface and media;
@@ -50,7 +50,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()
private var cameraWork: Task<Void, Never>?
private var cameraSwitches = 0
@@ -99,7 +99,10 @@ private final class Events: NSObject, BridgeListenerProtocol {
guard epoch == joiningEpoch, bridge === client else { throw CancellationError() }
localTrack = media.localVideo
currentCamera = media.currentCamera
- audio.refresh()
+ #if os(macOS)
+ audio.begin()
+ #endif
+ refreshDevices()
KnockNotice.requestPermission()
// Asking Apple at join, once there is a session to register the
// answer against. Apple replies on its own schedule, at the app
@@ -113,6 +116,10 @@ private final class Events: NSObject, BridgeListenerProtocol {
bridge = nil
events = nil
media.close()
+ #if os(macOS)
+ audio.reset()
+ #endif
+ cameras = []
remoteTracks = [:]
localTrack = nil
ice = nil
@@ -125,6 +132,7 @@ private final class Events: NSObject, BridgeListenerProtocol {
audioHealth = nil
currentCamera = nil
knocks.reset()
+ cameraSwitches += 1
cameraWork?.cancel()
cameraWork = nil
if let old { DispatchQueue.global().async { old.close() } }
@@ -152,7 +160,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
@@ -165,20 +182,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
@@ -192,20 +209,13 @@ private final class Events: NSObject, BridgeListenerProtocol {
/// Polls the audio-health counters while a call is up.
///
- /// This exists because of a call on 2026-09-17 that came back as an
- /// unintelligible growl, and because nothing in this app recorded
- /// anything about it — no log, no counters, no artefact. A reproduction
- /// would have produced nothing either. On macOS it is also the *only*
- /// instrument available, since the framework's macOS slice has no audio
- /// device API and a bad device cannot be ruled out by switching away
- /// from it.
- ///
/// Five seconds because this is a readout a person glances at when
/// something sounds wrong, not a time series.
public func watchAudio() async {
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())
}
}
@@ -258,7 +268,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 {
@@ -271,7 +282,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/Capture.swift b/native/apple/Sources/CallsKit/Capture.swift
index 8f4e51f..bf8ec8b 100644
--- a/native/apple/Sources/CallsKit/Capture.swift
+++ b/native/apple/Sources/CallsKit/Capture.swift
@@ -43,8 +43,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)
}
}
@@ -63,7 +74,7 @@ extension VideoCapture {
])
}
}
- let devices = RTCCameraVideoCapturer.captureDevices()
+ let devices = Self.devices
#if os(iOS)
let preferredDevice = devices.first { $0.position == .front }
#else
@@ -88,16 +99,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,
@@ -105,9 +110,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 ab3c609..150f964 100644
--- a/native/apple/Sources/CallsKit/DevicePanel.swift
+++ b/native/apple/Sources/CallsKit/DevicePanel.swift
@@ -1,26 +1,13 @@
import SwiftUI
-#if os(macOS)
- import AppKit
-#endif
-
-/// The camera, microphone and speaker a call is on — chosen where the
-/// platform allows it, reported where it does not.
-///
-/// One view for both apps, because the thing a person wants here is the
-/// same on a Mac and an iPhone even though what they can do about it is
-/// not. On iPhone every row is a choice. On Mac the camera is a choice
-/// and audio is a statement of fact with a way out to Sound settings,
-/// because WebRTC's macOS slice carries no audio device API at all.
-///
-/// The audio readout at the bottom is the reason this panel opens at
-/// all, most of the time: someone has noticed the call sounds wrong. It
-/// says which half of "wrong" the counters are seeing, in a sentence,
-/// rather than making them guess between a bad network and a bad device.
+/// Device choices apply immediately to this call.
@MainActor public struct DevicePanel: View {
@ObservedObject private var call: Call
@ObservedObject private var audio: AudioRoutes
@State private var camera: String?
+ @State private var cameraRequest = 0
+ @State private var microphone: String?
+ @State private var speaker: String?
public init(call: Call) {
self.call = call
@@ -29,12 +16,10 @@ import SwiftUI
public var body: some View {
VStack(alignment: .leading, spacing: 16) {
- if call.cameras.count > 1 {
+ 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)
@@ -60,16 +45,31 @@ import SwiftUI
}
.padding(16)
.onAppear {
+ call.refreshDevices()
camera = call.currentCamera
- audio.refresh()
+ microphone = audio.currentInput
+ #if os(macOS)
+ speaker = audio.currentOutput
+ #endif
}
// The picker follows the camera, not the intention: a switch that was
// 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 }
+ .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)
+ if request == cameraRequest { camera = call.currentCamera }
+ }
}
}
@@ -77,7 +77,8 @@ import SwiftUI
private var microphonePicker: some View {
VStack(alignment: .leading, spacing: 6) {
picker(
- "Microphone", selection: Binding(
+ "Microphone",
+ selection: Binding(
get: { audio.currentInput },
set: { id in
guard let id else { return }
@@ -99,19 +100,39 @@ import SwiftUI
#if os(macOS)
private var macAudio: some View {
- VStack(alignment: .leading, spacing: 8) {
- if let input = audio.inputs.first { row("Microphone", input.name) }
- if !audio.outputName.isEmpty { row("Speaker", audio.outputName) }
- Text("Calls uses whichever microphone and speaker macOS is set to.")
+ VStack(alignment: .leading, spacing: 12) {
+ picker(
+ "Microphone", selection: $microphone,
+ options: [("", "System default")] + audio.inputs.map { ($0.id, $0.name) },
+ identifier: "microphonePicker")
+ picker(
+ "Speaker", selection: $speaker,
+ options: [("", "System default")] + audio.outputs.map { ($0.id, $0.name) },
+ identifier: "speakerPicker")
+ Text("Changes apply to this call only.")
.font(.footnote).foregroundStyle(.secondary)
- .fixedSize(horizontal: false, vertical: true)
- Button("Open Sound settings", systemImage: "gearshape") {
- if let url = URL(string: "x-apple.systempreferences:com.apple.Sound-Settings.extension") {
- NSWorkspace.shared.open(url)
- }
+ if !audio.routeNote.isEmpty {
+ Text(audio.routeNote).font(.callout).foregroundStyle(.secondary)
+ .fixedSize(horizontal: false, vertical: true)
}
- .buttonStyle(.bordered)
- .accessibilityIdentifier("openSoundSettings")
+ }
+ .onChange(of: audio.currentInput) { _, value in microphone = value }
+ .onChange(of: audio.currentOutput) { _, value in speaker = value }
+ .onChange(of: microphone) { _, value in
+ guard let value, value != audio.currentInput else { return }
+ do {
+ try audio.use(input: value)
+ call.noteDevice("")
+ } catch { call.noteDevice(error.localizedDescription) }
+ microphone = audio.currentInput
+ }
+ .onChange(of: speaker) { _, value in
+ guard let value, value != audio.currentOutput else { return }
+ do {
+ try audio.use(output: value)
+ call.noteDevice("")
+ } catch { call.noteDevice(error.localizedDescription) }
+ speaker = audio.currentOutput
}
}
#endif
@@ -142,6 +163,9 @@ import SwiftUI
_ label: String, selection: Binding<String?>, options: [(String, String)], identifier: String
) -> some View {
Picker(label, selection: selection) {
+ 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/MacAudioDevices.swift b/native/apple/Sources/CallsKit/MacAudioDevices.swift
new file mode 100644
index 0000000..69efe6f
--- /dev/null
+++ b/native/apple/Sources/CallsKit/MacAudioDevices.swift
@@ -0,0 +1,85 @@
+#if os(macOS)
+ import CoreAudio
+ import Foundation
+
+ /// CoreAudio enumeration is read-only. UIDs identify devices across reconnects.
+ enum MacAudioDevices {
+ static func list(input: Bool) -> [AudioDevice] {
+ var address = AudioObjectPropertyAddress(
+ mSelector: kAudioHardwarePropertyDevices, mScope: kAudioObjectPropertyScopeGlobal,
+ mElement: kAudioObjectPropertyElementMain)
+ var size: UInt32 = 0
+ guard
+ AudioObjectGetPropertyDataSize(
+ AudioObjectID(kAudioObjectSystemObject), &address, 0, nil, &size) == noErr
+ else { return [] }
+ var ids = [AudioDeviceID](repeating: 0, count: Int(size) / MemoryLayout<AudioDeviceID>.size)
+ guard
+ AudioObjectGetPropertyData(
+ AudioObjectID(kAudioObjectSystemObject), &address, 0, nil, &size, &ids) == noErr
+ else { return [] }
+ return ids.compactMap { id in
+ var streams = AudioObjectPropertyAddress(
+ mSelector: kAudioDevicePropertyStreams,
+ mScope: input ? kAudioDevicePropertyScopeInput : kAudioDevicePropertyScopeOutput,
+ mElement: kAudioObjectPropertyElementMain)
+ var bytes: UInt32 = 0
+ guard AudioObjectGetPropertyDataSize(id, &streams, 0, nil, &bytes) == noErr, bytes > 0,
+ let uid = string(id, selector: kAudioDevicePropertyDeviceUID),
+ let name = string(id, selector: kAudioObjectPropertyName)
+ else { return nil }
+ return AudioDevice(id: uid, name: name, selectable: true)
+ }.sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending }
+ }
+
+ static func resolve(_ uid: String?, input: Bool) -> AudioDeviceID {
+ var id: AudioDeviceID = 0
+ var size = UInt32(MemoryLayout<AudioDeviceID>.size)
+ var address = AudioObjectPropertyAddress(
+ mSelector: input
+ ? kAudioHardwarePropertyDefaultInputDevice : kAudioHardwarePropertyDefaultOutputDevice,
+ mScope: kAudioObjectPropertyScopeGlobal, mElement: kAudioObjectPropertyElementMain)
+ if let uid, !uid.isEmpty {
+ var value = uid as CFString
+ address.mSelector = kAudioHardwarePropertyTranslateUIDToDevice
+ withUnsafePointer(to: &value) { pointer in
+ _ = AudioObjectGetPropertyData(
+ AudioObjectID(kAudioObjectSystemObject), &address,
+ UInt32(MemoryLayout<CFString>.size), pointer, &size, &id)
+ }
+ } else {
+ _ = AudioObjectGetPropertyData(
+ AudioObjectID(kAudioObjectSystemObject), &address, 0, nil, &size, &id)
+ }
+ return id
+ }
+
+ static func sampleRate(_ id: AudioDeviceID) -> Double {
+ var rate = 0.0
+ var size = UInt32(MemoryLayout<Double>.size)
+ var address = AudioObjectPropertyAddress(
+ mSelector: kAudioDevicePropertyNominalSampleRate,
+ mScope: kAudioObjectPropertyScopeGlobal, mElement: kAudioObjectPropertyElementMain)
+ _ = AudioObjectGetPropertyData(id, &address, 0, nil, &size, &rate)
+ return rate
+ }
+
+ static func name(_ id: AudioDeviceID) -> String {
+ string(id, selector: kAudioObjectPropertyName) ?? "Unavailable"
+ }
+
+ private static func string(_ id: AudioDeviceID, selector: AudioObjectPropertySelector)
+ -> String?
+ {
+ var value: Unmanaged<CFString>?
+ var size = UInt32(MemoryLayout<Unmanaged<CFString>?>.size)
+ var address = AudioObjectPropertyAddress(
+ mSelector: selector,
+ mScope: kAudioObjectPropertyScopeGlobal, mElement: kAudioObjectPropertyElementMain)
+ guard AudioObjectGetPropertyData(id, &address, 0, nil, &size, &value) == noErr else {
+ return nil
+ }
+ return value?.takeRetainedValue() as String?
+ }
+ }
+#endif
diff --git a/native/apple/Sources/CallsKit/MacCallView.swift b/native/apple/Sources/CallsKit/MacCallView.swift
index 93a201c..8af2242 100644
--- a/native/apple/Sources/CallsKit/MacCallView.swift
+++ b/native/apple/Sources/CallsKit/MacCallView.swift
@@ -176,7 +176,7 @@
.help(call.cameraOff ? "Turn camera on" : "Turn camera off")
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 46e73ca..9abdc09 100644
--- a/native/apple/Sources/CallsKit/Media.swift
+++ b/native/apple/Sources/CallsKit/Media.swift
@@ -22,7 +22,8 @@ import WebRTC
#else
factory = RTCPeerConnectionFactory(
encoderFactory: RTCDefaultVideoEncoderFactory(),
- decoderFactory: RTCDefaultVideoDecoderFactory())
+ decoderFactory: RTCDefaultVideoDecoderFactory(),
+ audioDevice: call.audio.device)
#endif
}
func prepare(capture supplied: VideoCapture?) async throws {
@@ -105,10 +106,6 @@ import WebRTC
/// 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.
static let audioHealthFields = [
"concealedSamples", "totalSamplesReceived",
"insertedSamplesForDeceleration", "removedSamplesForAcceleration",
diff --git a/native/apple/Tests/CallsKitTests/AudioHealthTests.swift b/native/apple/Tests/CallsKitTests/AudioHealthTests.swift
index de58c53..2599ac6 100644
--- a/native/apple/Tests/CallsKitTests/AudioHealthTests.swift
+++ b/native/apple/Tests/CallsKitTests/AudioHealthTests.swift
@@ -2,9 +2,7 @@ import XCTest
@testable import CallsKit
-/// AudioHealth is the one part of the device work with real logic in it,
-/// and the one that has to be right: it is what a person reads when a
-/// call sounds wrong, and on macOS it is the only instrument there is.
+/// The audio readout distinguishes loss from clock drift during a call.
final class AudioHealthTests: XCTestCase {
func testNothingReceivedIsSaidPlainlyAndNeverDividedBy() {
let health = AudioHealth(concealed: 0, total: 0, inserted: 0, removed: 0, jitterBufferMs: 0)
@@ -74,8 +72,10 @@ final class AudioHealthTests: XCTestCase {
XCTAssertEqual(health.jitterBufferMs, 120)
for field in MediaEngine.audioHealthFields {
XCTAssertTrue(
- ["concealedSamples", "totalSamplesReceived", "insertedSamplesForDeceleration",
- "removedSamplesForAcceleration"].contains(field),
+ [
+ "concealedSamples", "totalSamplesReceived", "insertedSamplesForDeceleration",
+ "removedSamplesForAcceleration",
+ ].contains(field),
"MediaEngine gathers \(field), which AudioHealth does not read")
}
}
@@ -90,24 +90,22 @@ final class AudioHealthTests: XCTestCase {
}
}
-/// AudioRoutes on macOS is read-only, and everything it reports comes
-/// from CoreAudio rather than from WebRTC — which has no audio device API
-/// in its macOS slice at all. These run against the real machine, because
-/// the thing worth testing is that the CoreAudio calls are right.
+/// Exercise real CoreAudio enumeration without opening devices.
final class AudioRoutesTests: XCTestCase {
- @MainActor func testMacReportsTheSystemDevicesAndOffersNoChoice() throws {
+ @MainActor func testMacEnumeratesSelectableDevices() throws {
#if os(macOS)
let routes = AudioRoutes()
- XCTAssertFalse(
- routes.selectable,
- "macOS cannot select an audio device for WebRTC; the UI must not offer a picker")
+ XCTAssertTrue(routes.selectable)
// A machine with no input at all is possible, so this asserts the
// shape rather than the presence: whatever is reported must be
// named, because an unnamed device tells a person nothing and the
// whole point of the Mac panel is saying what you are connected to.
- for device in routes.inputs {
+ for device in routes.inputs + routes.outputs {
XCTAssertFalse(device.name.isEmpty, "a reported device must have a name")
- XCTAssertFalse(device.selectable)
+ XCTAssertTrue(device.selectable)
+ let id = MacAudioDevices.resolve(device.id, input: true)
+ XCTAssertNotEqual(id, 0, "a listed UID must resolve to a usable hardware ID")
+ XCTAssertEqual(MacAudioDevices.name(id), device.name)
}
if !routes.outputName.isEmpty {
XCTAssertFalse(routes.outputName.hasPrefix("<"), "output name should be a real name")
diff --git a/native/apple/Tests/CallsKitTests/AudioRoutesTests.swift b/native/apple/Tests/CallsKitTests/AudioRoutesTests.swift
new file mode 100644
index 0000000..1740515
--- /dev/null
+++ b/native/apple/Tests/CallsKitTests/AudioRoutesTests.swift
@@ -0,0 +1,110 @@
+#if os(macOS)
+ import XCTest
+ import CallsAudio
+ @testable import CallsKit
+
+ @MainActor final class CallAudioSelectionTests: XCTestCase {
+ private final class Hardware {
+ var inputs = ["built-in", "usb"]
+ var outputDefault: UInt32 = 3
+ var changes: [(UInt32, UInt32)] = []
+ var rate = 48000.0
+ var refuse = false
+ var resets = 0
+ func adapter() -> AudioHardware {
+ var hardware = AudioHardware(device: CallsAudioDevice())
+ hardware.list = { input in
+ (input ? self.inputs : ["speaker", "headset"]).map {
+ AudioDevice(id: $0, name: $0, selectable: true)
+ }
+ }
+ hardware.resolve = { uid, input in
+ if uid == nil || uid == "" { return input ? 1 : self.outputDefault }
+ return ["built-in": 1, "usb": 2, "speaker": 3, "headset": 4][uid!] ?? 0
+ }
+ hardware.sampleRate = { _ in self.rate }
+ hardware.name = { "Device \($0)" }
+ hardware.select = { input, output in
+ if self.refuse { throw NSError(domain: "test", code: 1) }
+ self.changes.append((input, output))
+ }
+ hardware.reset = { self.resets += 1 }
+ return hardware
+ }
+ }
+
+ func testSelectionsAreIndependentAndResetForNextCall() throws {
+ let hardware = Hardware()
+ let routes = AudioRoutes(hardware: hardware.adapter())
+ let other = AudioRoutes(hardware: Hardware().adapter())
+ routes.begin()
+ try routes.use(input: "usb")
+ try routes.use(output: "headset")
+ XCTAssertEqual(hardware.changes.last?.0, 2)
+ XCTAssertEqual(hardware.changes.last?.1, 4)
+ XCTAssertEqual(other.currentInput, "")
+ XCTAssertEqual(other.currentOutput, "")
+ routes.reset()
+ XCTAssertEqual(routes.currentInput, "")
+ XCTAssertEqual(routes.currentOutput, "")
+ XCTAssertEqual(hardware.resets, 1)
+ XCTAssertEqual(hardware.changes.count, 2, "Leaving must not open devices")
+ }
+
+ func testRefusedSwitchKeepsPreviousSelection() throws {
+ let hardware = Hardware()
+ let routes = AudioRoutes(hardware: hardware.adapter())
+ routes.begin()
+ try routes.use(input: "usb")
+ hardware.refuse = true
+ XCTAssertThrowsError(try routes.use(input: "built-in"))
+ XCTAssertEqual(routes.currentInput, "usb")
+ XCTAssertThrowsError(try routes.use(output: "missing"))
+ XCTAssertEqual(routes.currentOutput, "")
+ }
+
+ func testDisconnectedInputFallsBackWithoutChangingSelectedOutput() throws {
+ let hardware = Hardware()
+ let routes = AudioRoutes(hardware: hardware.adapter())
+ routes.begin()
+ try routes.use(input: "usb")
+ try routes.use(output: "headset")
+ hardware.inputs = ["built-in"]
+ routes.refresh()
+ XCTAssertEqual(routes.currentInput, "")
+ XCTAssertEqual(routes.currentOutput, "headset")
+ XCTAssertEqual(hardware.changes.last?.0, 1)
+ XCTAssertEqual(hardware.changes.last?.1, 4)
+ XCTAssertFalse(routes.routeNote.isEmpty)
+ }
+
+ func testSampleRateChangeReopensTheActiveRoute() throws {
+ let hardware = Hardware()
+ let routes = AudioRoutes(hardware: hardware.adapter())
+ routes.begin()
+ try routes.use(input: "usb")
+ hardware.rate = 44100
+ routes.refresh()
+ XCTAssertEqual(hardware.changes.count, 2)
+ XCTAssertEqual(routes.currentInput, "usb")
+ routes.refresh()
+ XCTAssertEqual(hardware.changes.count, 2)
+ }
+
+ func testSystemDefaultFollowsChangesButExplicitChoiceDoesNot() throws {
+ let hardware = Hardware()
+ let routes = AudioRoutes(hardware: hardware.adapter())
+ routes.begin()
+ hardware.outputDefault = 4
+ routes.refresh()
+ XCTAssertEqual(hardware.changes.last?.1, 4)
+ XCTAssertEqual(routes.currentOutput, "")
+ try routes.use(output: "speaker")
+ let count = hardware.changes.count
+ hardware.outputDefault = 3
+ routes.refresh()
+ XCTAssertEqual(hardware.changes.count, count)
+ XCTAssertEqual(routes.currentOutput, "speaker")
+ }
+ }
+#endif
diff --git a/native/scripts/bundle-mac.py b/native/scripts/bundle-mac.py
index 94c30b7..2e4bb8e 100644
--- a/native/scripts/bundle-mac.py
+++ b/native/scripts/bundle-mac.py
@@ -18,7 +18,8 @@ shutil.copy2(products / 'CallsMac', bundle / 'MacOS/CallsMac')
subprocess.run(['swift', str(root / 'scripts/generate-mac-icon.swift')], check=True)
icon = root / 'apple/Sources/CallsMac/Resources/MacIcon.png'
shutil.copy2(icon, bundle / 'Resources/MacIcon.png')
-shutil.copy2(root / 'LICENSE.lucide', bundle / 'Resources/LICENSE.lucide')
+for notice in ('LICENSE.lucide', 'LICENSE.webrtc', 'PATENTS.webrtc'):
+ shutil.copy2(root / notice, bundle / 'Resources' / notice)
# Finder reads the ICNS before the app launches; the PNG also supplies the
# runtime Dock icon. Generate each required representation from the master.
iconset = root / 'build/AppIcon.iconset'
@@ -40,6 +41,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 e104eec..32c56a9 100644
--- a/test/browser/native.mjs
+++ b/test/browser/native.mjs
@@ -33,6 +33,7 @@ try {
let invitationReady = false;
let invitationRefused = false;
let invitationSnapshotSaved = false;
+ let audioSwitched = null;
let camera = null;
let cameraRefused = false;
let exited = false;
@@ -47,6 +48,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 {}
@@ -93,6 +95,18 @@ try {
});
await wait(async () => await browserAudio() > 0, 'browser receives native audio');
console.log('PASS browser decoded native video and received native audio');
+ // Reopen both HAL routes during the live call, then refuse an unavailable
+ // input. Both operations must leave media and peer connections intact.
+ 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