Add per-call camera and audio device selection on Mac
22 files changed,
+1174
−169
native/LICENSE.webrtc+29 −0native/PATENTS.webrtc+24 −0native/README.md+15 −17native/VERIFICATION.md+31 −0native/apple/Package.swift+2 −1native/apple/Sources/CallsAudio/CallsAudio.m+216 −0native/apple/Sources/CallsAudio/README.md+26 −0native/apple/Sources/CallsAudio/include/CallsAudio.h+11 −0native/apple/Sources/CallsAudio/include/RTCAudioDevice.h+335 −0native/apple/Sources/CallsInterop/Interop.swift+22 −1native/apple/Sources/CallsKit/Audio.swift+96 −64native/apple/Sources/CallsKit/AudioHardware.swift+23 −0native/apple/Sources/CallsKit/Call.swift+31 −19native/apple/Sources/CallsKit/Capture.swift+27 −11native/apple/Sources/CallsKit/DevicePanel.swift+58 −34native/apple/Sources/CallsKit/MacAudioDevices.swift+85 −0native/apple/Sources/CallsKit/MacCallView.swift+1 −1native/apple/Sources/CallsKit/Media.swift+2 −5native/apple/Tests/CallsKitTests/AudioHealthTests.swift+13 −15native/apple/Tests/CallsKitTests/AudioRoutesTests.swift+110 −0native/scripts/bundle-mac.py+3 −1test/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 |