Merge branch 'feat/livekit-webrtc-audio' into integrate/native-2026-09-23
Moves the native apps to LiveKit's WebRTC build, adds the Mac audio device picker, RNNoise on iPhone and DeepFilterNet 3 on Mac.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
33 files changed,
+1959
−164
Makefile+4 −1native/LICENSE.deepfilternet+22 −0native/LICENSE.rnnoise+32 −0native/README.md+114 −17native/VERIFICATION.md+40 −0native/apple/Package.resolved+4 −4native/apple/Package.swift+12 −3native/apple/Sources/CallsInterop/Interop.swift+1 −1native/apple/Sources/CallsInterop/SyntheticVideo.swift+1 −1native/apple/Sources/CallsInterop/SyntheticVoice.swift+25 −0native/apple/Sources/CallsKit/Audio.swift+83 −72native/apple/Sources/CallsKit/Call.swift+37 −6native/apple/Sources/CallsKit/CallAudio.swift+357 −0native/apple/Sources/CallsKit/Capture.swift+1 −1native/apple/Sources/CallsKit/DevicePanel.swift+53 −22native/apple/Sources/CallsKit/MacVideoTile.swift+1 −1native/apple/Sources/CallsKit/Media.swift+8 −9native/apple/Sources/CallsKit/NoiseSuppression.swift+319 −0native/apple/Sources/CallsKit/WebRTCNames.swift+213 −0native/apple/Sources/CallsMac/CallsMac.swift+1 −1native/apple/Tests/CallsKitTests/AudioHealthTests.swift+24 −20native/apple/Tests/CallsKitTests/Fixtures/speech-48k.wavbinarynative/apple/Tests/CallsKitTests/NoiseSuppressionTests.swift+425 −0native/ios/Sources/CallsPhone.swift+1 −1native/ios/Tests/CallsPhoneTests.swift+1 −1native/ios/Tests/SilentAudioDevice.swift+2 −1native/ios/project.yml+2 −0native/scripts/build-deepfilter.sh+71 −0native/scripts/build-iphone.sh+2 −0native/scripts/build-mac.sh+1 −1native/scripts/build-rnnoise.sh+87 −0native/scripts/bundle-mac.py+6 −1test/browser/native.mjs+9 −0
diff --git a/Makefile b/Makefile| index a15a497..dbeab73 100644 |
| --- a/Makefile |
| +++ b/Makefile |
| @@ -166,7 +166,10 @@ native-test: |
| CGO_ENABLED=1 go test -race ./callscore |
| native-browser: native-mac |
| - CGO_ENABLED=0 go test -tags browser -count=1 -timeout 3m -run 'TestNative(MacCallsBrowser|MacChat|BrowserAuthentication)' -v ./internal/callstest/ |
| + # The gate runs CallsInterop, which native-mac does not build; without |
| + # this it runs whatever binary a previous build left behind. |
| + swift build --package-path native/apple --product CallsInterop |
| + CALLS_DEEPFILTER_MODEL=$(CURDIR)/native/build/DeepFilterNet3_onnx.tar.gz CGO_ENABLED=0 go test -tags browser -count=1 -timeout 3m -run 'TestNative(MacCallsBrowser|MacChat|BrowserAuthentication)' -v ./internal/callstest/ |
| .PHONY: iphone iphone-device iphone-browser |
| iphone: |
diff --git a/native/LICENSE.deepfilternet b/native/LICENSE.deepfilternet| new file mode 100644 |
| index 0000000..0344137 |
| --- /dev/null |
| +++ b/native/LICENSE.deepfilternet |
| @@ -0,0 +1,22 @@ |
| +DeepFilterNet (https://github.com/Rikorose/DeepFilterNet), used under the MIT |
| +licence (it is dual-licensed MIT OR Apache-2.0). |
| + |
| +The MIT License (MIT) |
| +Copyright (c) 2021 Hendrik Schröter |
| + |
| +Permission is hereby granted, free of charge, to any person obtaining a copy of |
| +this software and associated documentation files (the "Software"), to deal in |
| +the Software without restriction, including without limitation the rights to |
| +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of |
| +the Software, and to permit persons to whom the Software is furnished to do so, |
| +subject to the following conditions: |
| + |
| +The above copyright notice and this permission notice shall be included in all |
| +copies or substantial portions of the Software. |
| + |
| +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR |
| +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS |
| +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR |
| +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER |
| +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN |
| +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. |
diff --git a/native/LICENSE.rnnoise b/native/LICENSE.rnnoise| new file mode 100644 |
| index 0000000..59d7d09 |
| --- /dev/null |
| +++ b/native/LICENSE.rnnoise |
| @@ -0,0 +1,32 @@ |
| +Copyright (c) 2007-2017, 2024 Jean-Marc Valin |
| +Copyright (c) 2023 Amazon |
| +Copyright (c) 2017, Mozilla |
| +Copyright (c) 2005-2017, Xiph.Org Foundation |
| +Copyright (c) 2003-2004, Mark Borgerding |
| + |
| +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 the Xiph.Org Foundation 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 FOUNDATION |
| +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/README.md b/native/README.md| index 5f6a204..1a456ec 100644 |
| --- a/native/README.md |
| +++ b/native/README.md |
| @@ -37,20 +37,96 @@ connection already holds does not change and nothing renegotiates — the |
| native counterpart of the browser's `replaceTrack`. Switches are serialised; |
| the last pick wins. |
| -Audio is where the platforms part, and not by choice. WebRTC's iOS slices |
| -ship `RTCAudioDevice`, `RTCAudioSession` and three more audio headers; the |
| -macOS slice ships two, `RTCAudioSource` and `RTCAudioTrack`. There is no |
| -supported way to enumerate or select an audio device for WebRTC on macOS |
| -through this framework, and no way to supply a custom audio device module |
| -either, because the protocol is not in the slice. (It *is* in the Mac |
| -Catalyst slice, so this is a packaging boundary rather than a platform |
| -limit — but Catalyst would be a poor trade for a real AppKit app.) |
| - |
| -So on iPhone the microphone is a picker, and on Mac the panel reports which |
| -devices macOS has chosen — read from CoreAudio, which is what WebRTC's |
| -audio device module follows — and offers Sound settings. Naming them is |
| -worth doing on its own: the first thing someone needs when a call sounds |
| -wrong is to know what they are connected to. |
| +The microphone is a picker on both platforms, and on Mac so is the speaker. |
| +On iPhone it is an audio session route. On Mac it is a device on the call's own |
| +WebRTC audio device module — LiveKit's AVAudioEngine module, which lists input |
| +and output devices (the system default among them, following macOS) and |
| +switches mid-call without renegotiating. A choice there moves this call and |
| +leaves the Mac's system setting alone. Before the move to LiveKit's build the |
| +Mac could only report what macOS had chosen: that build's macOS slice had no |
| +audio device API at all. |
| + |
| +Apple's voice processing is **not** allowed on Mac |
| +(`setPlatformVoiceProcessingAllowed(false)`). Left allowed, the engine module |
| +resolves echo cancellation, noise suppression and gain to Apple's coupled |
| +voice-processing unit; with it off, WebRTC's own software processing runs, as |
| +it always has on Mac. One consequence: macOS offers its **Mic Mode** menu |
| +(Voice Isolation and the rest) only to apps on voice processing, so it is not |
| +offered to Calls on Mac. The iPhone still runs on the voice-processing unit. |
| + |
| +The native/browser gate cannot open a microphone (a test process has no |
| +permission to), so `CallsInterop` gives the engine module a synthetic voice |
| +through manual rendering mode, as it gives the capturer synthetic frames; no |
| +device is opened on either side of it. |
| + |
| +**Reduce background noise** (in the device panel, on by default, remembered) |
| +replaces WebRTC's classic noise suppressor with a neural one, for the noises |
| +people actually hear: typing, wind, a room's echo. WebRTC's capture |
| +processing runs high-pass filter, echo canceller, noise suppressor and gain |
| +control, then hands each 10 ms frame to a capture post-processing hook |
| +(`RTCDefaultAudioProcessingModule`); the suppressor runs there, after the |
| +echo canceller has removed the far side. `ProcessingPlan` holds the rules: |
| + |
| +- WebRTC's noise suppressor is off while ours runs, and ours never runs |
| + beside Apple's Voice Isolation (it stands aside when that is the active |
| + mic mode). |
| +- WebRTC's gain control runs *before* the hook, so it would lift the room |
| + with a quiet voice and hand the suppressor more noise to remove. It is off |
| + while ours runs; `SpeechGain` lifts the voice after the noise is gone |
| + instead — only upward, at most 12 dB, learning only from frames the model |
| + calls speech, about 1 dB a second, with a peak limit. |
| +- The models run at 48 kHz only. At any other capture rate (a Bluetooth |
| + headset's 16 kHz) frames pass through untouched and WebRTC's own |
| + suppressor and gain come back on the Mac. |
| +- Off means no suppression at all; WebRTC's gain control comes back. |
| +- On iPhone Apple's echo canceller and its noise suppressor are one switch |
| + in the voice-processing unit, and the phone has always run both; that |
| + pairing stays. Apple's gain control is separate and comes off while ours |
| + runs. |
| + |
| +The audio readout says which suppressor is running and at what share of a |
| +core, and what WebRTC reports as running for echo cancellation, noise |
| +suppression and gain — what it reports, not what was asked. WebRTC skips the |
| +hook while the microphone is muted, and the readout says so. |
| + |
| +**On Mac the suppressor is DeepFilterNet 3**; on iPhone it is RNNoise. |
| + |
| +- **DeepFilterNet 3** (Rikorose/DeepFilterNet's libDF, MIT or Apache-2.0; |
| + notice in `LICENSE.deepfilternet`, bundled in the Mac app) is markedly |
| + better on non-stationary noise — typing, wind, voices in the next room. It |
| + takes 480-sample hops at 48 kHz in ±1 floats, so samples are scaled by |
| + 1/32768 on the way in and back on the way out, and adds about 30 ms of |
| + latency. `scripts/build-deepfilter.sh` builds `native/build/DeepFilter.xcframework` |
| + (static; `aarch64-apple-darwin`, `aarch64-apple-ios`, `aarch64-apple-ios-sim`) |
| + with a pinned rustup toolchain, cbindgen for the header, and the C API |
| + (`--features capi`), and copies the standard model |
| + (`DeepFilterNet3_onnx.tar.gz`, 8 MB) beside it; the Mac bundle carries the |
| + model. Needs rustup (https://rustup.rs). The library is linked on Mac only. |
| + The model takes about 0.3 s to load, so a call starts on RNNoise and moves |
| + to DeepFilterNet when it has loaded off the main thread; a missing model |
| + leaves it on RNNoise. `CALLS_DEEPFILTER_MODEL` points at a model file |
| + outside a bundle (the tests and the interop harness). |
| +- **RNNoise** (xiph, BSD-3; notice in `LICENSE.rnnoise`, bundled in both apps): |
| + 480-sample frames at 48 kHz in WebRTC's own ±32768 scale. Its model weights |
| + arrive as a generated C file of 78 MB, so it is not a source target: |
| + `scripts/build-rnnoise.sh` fetches the pinned commit and the checksummed |
| + model and builds `native/build/RNNoise.xcframework` (static, Mac, iPhone |
| + and simulator). |
| +- Both are limited to 30 dB of attenuation: unlimited, a room goes to digital |
| + silence between words, which is heard as the line dropping. DeepFilterNet |
| + has the limit built in; RNNoise gets it by mixing the untouched input back |
| + in at -30 dB. |
| +- Measured on an M1 Max (fixture test, isolated): RNNoise 0.69–0.76 ms and |
| + DeepFilterNet 3 1.16–1.23 ms per 10 ms frame, i.e. about 7% and 12% of one |
| + core. In the native/browser gate, as wall-clock share of the audio thread |
| + with Chromium busy on the same machine: RNNoise median 12.9%, DeepFilterNet |
| + median 15.1% (13–27%). DeepFilterNet has not been measured on an iPhone, |
| + which is why the phone stays on RNNoise; the xcframework already carries the |
| + iOS slices, so trying it is a Package.swift condition and a bundled model. |
| + |
| +None of these libraries is committed: `build-mac.sh` and `build-iphone.sh` |
| +run all three scripts (bridge, RNNoise, DeepFilter), each cached against its |
| +pins; run them yourself before `swift test`. |
| Choosing a Bluetooth microphone on iPhone puts the call on the hands-free |
| profile, which is narrowband in both directions. That used to happen |
| @@ -128,8 +204,14 @@ The iPhone target, device builds and Safari tests are described in |
| Go Mobile's cgo requirement is isolated in `native/go.mod` and the native build |
| script. The existing server continues to build with `CGO_ENABLED=0`. WebRTC is |
| -pinned to stasel/WebRTC 152.0.0; SwiftPM verifies its published artifact checksum. |
| -Go Mobile is also pinned. The bridge includes Mac, iPhone and iOS Simulator |
| +LiveKit's build of Google WebRTC, `livekit/webrtc-xcframework` pinned exactly to |
| +150.7871.02 (M150); SwiftPM verifies its published artifact checksum. It replaced |
| +stasel/WebRTC 152 because it carries what that build does not: an audio device |
| +module on macOS (device lists and selection) and a capture post-processing hook |
| +for noise suppression. Its Objective-C symbols are prefixed `LK`; CallsKit's |
| +`WebRTCNames.swift` aliases the old `RTC…` names once, so code and open branches |
| +keep using them and change only their import (`import LiveKitWebRTC`). |
| +Go Mobile is also pinned; so are RNNoise and DeepFilterNet (see above). The bridge includes Mac, iPhone and iOS Simulator |
| slices. Generated frameworks, Xcode projects and binaries are ignored. |
| ## Verification |
| @@ -143,13 +225,28 @@ make native-browser |
| CGO_ENABLED=0 go test -tags browser -run TestNativeBrowserAuthentication ./internal/callstest |
| ``` |
| +`swift test` needs `native/scripts/build-bridge.sh`, |
| +`native/scripts/build-rnnoise.sh` and `native/scripts/build-deepfilter.sh` to |
| +have run. Its suppressor tests feed |
| +`Tests/CallsKitTests/Fixtures/speech-48k.wav` (macOS's own speech |
| +synthesiser, normalised to -23 dBFS, a second of silence ahead) mixed with |
| +seeded hiss and key clicks through each real suppressor, and require the |
| +noise-only lead-in to fall by at least 10 dB, the speech level to hold within |
| +6 dB, and 80% of voiced frames to survive. The frame adapter, the gain stage |
| +and the processing plan have unit tests of their own. |
| + |
| The native/browser gate requires the same Playwright/Chromium setup as |
| `make browser`, and working UDP. It creates two real magic-link sessions and a |
| meeting on an isolated server, starts Chromium and the separate native test |
| executable, then checks guest admission, actual incoming/outgoing media, clean departure and continuity through |
| ten-second SSE renewals, bidirectional chat and the native sender's accepted-message |
| history. Test speaker playback is disabled in both clients; audio checks use RTP |
| -counters, not audible output. It never needs production credentials or hardware |
| +counters, not audible output. The native side's microphone is a synthetic |
| +voice through the audio device module's manual rendering mode, so its capture |
| +passes through WebRTC's real processing: the gate asserts the noise suppressor |
| +processed frames, and that WebRTC reports its own suppressor and gain off and |
| +its echo canceller on. `make native-browser` builds `CallsInterop` itself; |
| +it used to run whichever binary an earlier build had left. It never needs production credentials or hardware |
| camera access. The Go Home test separately exercises a session minted through |
| Home assertions and explicit identity linking, including resume and meeting end. |
diff --git a/native/VERIFICATION.md b/native/VERIFICATION.md| index b33c1bb..3ebb747 100644 |
| --- a/native/VERIFICATION.md |
| +++ b/native/VERIFICATION.md |
| @@ -1,3 +1,43 @@ |
| +# LiveKit WebRTC and neural noise reduction — 2026-09-23 |
| + |
| +Branch: `feat/livekit-webrtc-audio`, based on `origin/main` at `3846ca4`. Not |
| +merged, not deployed. Machine: M1 Max, macOS 27, Xcode 26.6. |
| + |
| +| Check | Result | |
| +| --- | --- | |
| +| `./native/scripts/build-bridge.sh` | Passed | |
| +| `native/scripts/build-rnnoise.sh`, `build-deepfilter.sh` | Built both xcframeworks from their pins (DeepFilter: Rust 1.92.0 via rustup, cbindgen 0.29.2) | |
| +| `swift test` (native/apple) | 48 tests, 0 failures, including the suppressor fixture tests | |
| +| `make native-test` (uncached, `GOFLAGS=-count=1`) | Passed | |
| +| `make native-mac` | Built; ad hoc signature verifies; bundle carries LiveKitWebRTC.framework, the DeepFilterNet model and the RNNoise/DeepFilterNet notices; app launches | |
| +| `make iphone` + `build-for-testing` (simulator) | Build and test build succeeded; RNNoise linked, DeepFilter not | |
| +| `make native-browser` | Passed (member, guest, chat); asserts DeepFilterNet 3 processed the synthetic microphone and WebRTC reports its suppressor and gain off, echo cancellation on | |
| +| `CGO_ENABLED=0 make browser` | 33 of 34 passed; `switching` (web camera switch) failed once under load from a concurrent simulator run and passed alone. This branch does not touch the web client | |
| +| `make iphone-browser` | Failed, and fails identically on a clean `origin/main` worktree (both peers stuck `reconnecting`, 0 frames); the Safari UI test flaked differently on each run. Environmental, not this branch; the first run's media test did pass on this branch | |
| +| `GOTOOLCHAIN=go1.26.5 make ci` | Passed | |
| + |
| +Fixture (synthesised speech at -23 dBFS with seeded hiss and key clicks, each |
| +suppressor alone): RNNoise takes the noise-only lead-in from -41 to -71 dBFS |
| +and DeepFilterNet 3 to -69 dBFS (both at their 30 dB limit); speech holds at |
| +-24.5 → -24.6 dBFS; 99% of voiced frames kept by each. Per 10 ms frame: |
| +RNNoise 0.69–0.81 ms, DeepFilterNet 1.16–1.23 ms (about 7–8% and 12% of one |
| +core). DeepFilterNet's model loads in 0.26–0.35 s. In the native/browser |
| +gate, as wall-clock share of the audio thread with Chromium on the same |
| +machine: DeepFilterNet median 15.1–15.3% (13–27%), RNNoise median 12.9%. |
| + |
| +Needs a real call or device: echo with speakers (software AEC3 on Mac, now on |
| +the AVAudioEngine module), typing, wind and room echo against both models; |
| +that SpeechGain lifts a quiet talker without audible pumping; switching Mac |
| +microphone and speaker mid-call (only one of each on the test machine); |
| +Bluetooth at 16 kHz handing back to WebRTC's suppressor; Voice Isolation on |
| +iPhone standing ours aside; whether iPhone accepts the platform-mode options |
| +(the readout says if not); DeepFilterNet's cost on an iPhone. The Mac audio |
| +bug (growling) is not addressed and the module change may move it either way. |
| +A test process cannot get microphone permission, so Apple voice processing's |
| +live state on Mac and the Mic Mode menu could not be observed. |
| + |
| +--- |
| + |
| # Native completion verification — 2026-09-12 |
| Branch: `codex/iphone-and-mirrored-preview`, based on `86ed9f3`. |
diff --git a/native/apple/Package.resolved b/native/apple/Package.resolved| index d797920..bc609ee 100644 |
| --- a/native/apple/Package.resolved |
| +++ b/native/apple/Package.resolved |
| @@ -18,12 +18,12 @@ |
| } |
| }, |
| { |
| - "identity" : "webrtc", |
| + "identity" : "webrtc-xcframework", |
| "kind" : "remoteSourceControl", |
| - "location" : "https://github.com/stasel/WebRTC.git", |
| + "location" : "https://github.com/livekit/webrtc-xcframework.git", |
| "state" : { |
| - "revision" : "1d04692697cb642bfebf6ad2dd99fe52649c3d6d", |
| - "version" : "152.0.0" |
| + "revision" : "c73deceaddd9c07293871ae28f24d4326cc86d62", |
| + "version" : "150.7871.2" |
| } |
| } |
| ], |
diff --git a/native/apple/Package.swift b/native/apple/Package.swift| index 522f2fd..7f4f9ec 100644 |
| --- a/native/apple/Package.swift |
| +++ b/native/apple/Package.swift |
| @@ -15,14 +15,23 @@ let package = Package( |
| ], |
| dependencies: [ |
| sharedNative, |
| - .package(url: "https://github.com/stasel/WebRTC.git", exact: "152.0.0") |
| + .package(url: "https://github.com/livekit/webrtc-xcframework.git", exact: "150.7871.02") |
| ], |
| targets: [ |
| .binaryTarget(name: "CallsBridge", path: "../build/CallsBridge.xcframework"), |
| + // Built by scripts/build-rnnoise.sh, as CallsBridge is by build-bridge.sh. |
| + .binaryTarget(name: "RNNoise", path: "../build/RNNoise.xcframework"), |
| + // Built by scripts/build-deepfilter.sh. Linked on Mac only: the iPhone |
| + // runs RNNoise, and should not carry a model runtime it never uses. |
| + .binaryTarget(name: "DeepFilter", path: "../build/DeepFilter.xcframework"), |
| .target( |
| - name: "CallsKit", dependencies: ["CallsBridge", .product(name: "WebRTC", package: "WebRTC")]), |
| + name: "CallsKit", |
| + dependencies: [ |
| + "CallsBridge", "RNNoise", .target(name: "DeepFilter", condition: .when(platforms: [.macOS])), |
| + .product(name: "LiveKitWebRTC", package: "webrtc-xcframework"), |
| + ]), |
| .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"]), |
| + .testTarget(name: "CallsKitTests", dependencies: ["CallsKit"], resources: [.copy("Fixtures")]), |
| ] |
| ) |
diff --git a/native/apple/Sources/CallsInterop/Interop.swift b/native/apple/Sources/CallsInterop/Interop.swift| index c358db5..be0ff5b 100644 |
| --- a/native/apple/Sources/CallsInterop/Interop.swift |
| +++ b/native/apple/Sources/CallsInterop/Interop.swift |
| @@ -22,7 +22,7 @@ struct Input: Decodable { |
| let input = try? JSONDecoder().decode(Input.self, from: Data(line.utf8)), |
| let url = URL(string: input.origin), url.scheme == "http", url.host == "127.0.0.1" |
| else { exit(2) } |
| - let call = Call(playAudio: false) |
| + let call = Call(playAudio: false, audioInput: SyntheticVoice()) |
| let preview = input.chatSnapshot.map { ChatPreview(call: call, path: $0) } |
| do { |
| try await call.join( |
diff --git a/native/apple/Sources/CallsInterop/SyntheticVideo.swift b/native/apple/Sources/CallsInterop/SyntheticVideo.swift| index 2eba113..a28290d 100644 |
| --- a/native/apple/Sources/CallsInterop/SyntheticVideo.swift |
| +++ b/native/apple/Sources/CallsInterop/SyntheticVideo.swift |
| @@ -1,7 +1,7 @@ |
| import CoreVideo |
| import Foundation |
| import CallsKit |
| -import WebRTC |
| +import LiveKitWebRTC |
| @MainActor final class SyntheticVideo: VideoCapture { |
| private var source: RTCVideoSource? |
diff --git a/native/apple/Sources/CallsInterop/SyntheticVoice.swift b/native/apple/Sources/CallsInterop/SyntheticVoice.swift| new file mode 100644 |
| index 0000000..230057f |
| --- /dev/null |
| +++ b/native/apple/Sources/CallsInterop/SyntheticVoice.swift |
| @@ -0,0 +1,25 @@ |
| +import CallsKit |
| +import Foundation |
| + |
| +/// The harness's microphone: a voiced, syllable-shaped tone over steady |
| +/// noise, so the far side receives audio with speech-like structure and |
| +/// the capture processing has something to work on. Real speech it is |
| +/// not; the fixture test in CallsKitTests is where suppression is judged. |
| +final class SyntheticVoice: AudioInputSource, @unchecked Sendable { |
| + private var t = 0.0 |
| + private var seed: UInt32 = 0x1234_5678 |
| + |
| + func render(_ samples: UnsafeMutablePointer<Float>, count: Int, sampleRate: Double) { |
| + for i in 0..<count { |
| + // Four syllables a second, a 140 Hz voice with two harmonics. |
| + let envelope = max(0, sin(2 * .pi * 2 * t)) |
| + let voice = |
| + envelope |
| + * (sin(2 * .pi * 140 * t) + 0.5 * sin(2 * .pi * 280 * t) + 0.25 * sin(2 * .pi * 420 * t)) |
| + seed = seed &* 1_664_525 &+ 1_013_904_223 |
| + let noise = Double(Int32(bitPattern: seed)) / Double(Int32.max) |
| + samples[i] = Float(0.2 * voice + 0.02 * noise) |
| + t += 1 / sampleRate |
| + } |
| + } |
| +} |
diff --git a/native/apple/Sources/CallsKit/Audio.swift b/native/apple/Sources/CallsKit/Audio.swift| index 495036d..773d887 100644 |
| --- a/native/apple/Sources/CallsKit/Audio.swift |
| +++ b/native/apple/Sources/CallsKit/Audio.swift |
| @@ -1,17 +1,9 @@ |
| import AVFoundation |
| import Foundation |
| -#if os(macOS) |
| - import CoreAudio |
| -#endif |
| +import LiveKitWebRTC |
| -#if os(iOS) |
| - import 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. |
| +/// One audio device the person can be on. |
| public struct AudioDevice: Identifiable, Hashable, Sendable { |
| public let id: String |
| public let name: String |
| @@ -23,39 +15,32 @@ public struct AudioDevice: Identifiable, Hashable, Sendable { |
| } |
| } |
| -/// The audio devices a call is on, and — where the platform allows it — |
| -/// the ability to change them. |
| -/// |
| -/// The asymmetry here is not a design choice. WebRTC's macOS slice ships |
| -/// two audio headers, RTCAudioSource and RTCAudioTrack; the iOS slices |
| -/// ship five, including RTCAudioDevice and RTCAudioSession. There is no |
| -/// supported way to enumerate or select an audio device for WebRTC on |
| -/// macOS through this framework, and no way to supply a custom audio |
| -/// device module either, because the protocol that would be implemented |
| -/// is not in the slice. (It *is* in the Mac Catalyst slice, so this is a |
| -/// packaging boundary rather than a platform impossibility — but a |
| -/// Catalyst build would be a poor trade for a real AppKit app.) |
| +/// The audio devices a call is on, and the ability to change them. |
| /// |
| -/// So on Mac this reports what macOS has chosen, read straight from |
| -/// CoreAudio, and offers the Sound settings pane to change it. Naming the |
| -/// device is worth doing on its own: the first thing a person needs when |
| -/// a call sounds wrong is to know what they are actually connected to, |
| -/// and until now the app could not tell them. |
| +/// On iPhone the microphone is a route on the audio session. On Mac it is |
| +/// a device on WebRTC's audio device module: the AVAudioEngine module in |
| +/// LiveKit's WebRTC build lists input and output devices and switches |
| +/// between them mid-call, which the build this app used before could not |
| +/// do at all (its macOS slice had no audio device API). Until a call has |
| +/// made that module, the Mac lists are empty. |
| @MainActor public final class AudioRoutes: ObservableObject { |
| @Published public private(set) var inputs: [AudioDevice] = [] |
| @Published public private(set) var currentInput: String? |
| @Published public private(set) var outputName = "" |
| + /// Speakers, where they can be chosen (Mac). Empty on iPhone, where the |
| + /// output follows the route and is only named. |
| + @Published public private(set) var outputs: [AudioDevice] = [] |
| + @Published public private(set) var currentOutput: String? |
| /// True where inputs can actually be chosen. The UI shows a picker when |
| /// it is set and a plain line of text when it is not, rather than a |
| /// disabled control that looks like something is broken. |
| - public let selectable: Bool |
| + public let selectable = true |
| + |
| + #if os(macOS) |
| + private weak var module: RTCAudioDeviceModule? |
| + #endif |
| public init() { |
| - #if os(iOS) |
| - selectable = true |
| - #else |
| - selectable = false |
| - #endif |
| refresh() |
| } |
| @@ -68,12 +53,74 @@ public struct AudioDevice: Identifiable, Hashable, Sendable { |
| currentInput = session.currentRoute.inputs.first?.uid |
| outputName = session.currentRoute.outputs.first?.portName ?? "" |
| #else |
| - inputs = macDevice(input: true).map { [$0] } ?? [] |
| - currentInput = inputs.first?.id |
| - outputName = macDevice(input: false)?.name ?? "" |
| + guard let module else { |
| + inputs = [] |
| + outputs = [] |
| + currentInput = nil |
| + currentOutput = nil |
| + outputName = "" |
| + return |
| + } |
| + inputs = module.inputDevices.map(AudioRoutes.device) |
| + outputs = module.outputDevices.map(AudioRoutes.device) |
| + // Nothing chosen yet reads back as an empty device, which is the |
| + // system default in effect; the picker shows it as that entry. |
| + currentInput = AudioRoutes.chosen(module.inputDevice, in: module.inputDevices) |
| + currentOutput = AudioRoutes.chosen(module.outputDevice, in: module.outputDevices) |
| + outputName = outputs.first { $0.id == currentOutput }?.name ?? "" |
| #endif |
| } |
| + #if os(macOS) |
| + func attach(_ module: RTCAudioDeviceModule) { |
| + self.module = module |
| + refresh() |
| + } |
| + |
| + /// The module lists the system default as a device of its own, which |
| + /// follows whatever macOS is set to. Said as such, so choosing it is |
| + /// understood as "whatever the Mac uses" rather than one microphone. |
| + nonisolated static func device(_ io: RTCIODevice) -> AudioDevice { |
| + AudioDevice( |
| + id: io.deviceId, name: io.isDefault ? "System default (\(io.name))" : io.name, |
| + selectable: true) |
| + } |
| + |
| + nonisolated static func chosen(_ device: RTCIODevice, in list: [RTCIODevice]) -> String? { |
| + if !device.deviceId.isEmpty { return device.deviceId } |
| + return list.first(where: \.isDefault)?.deviceId |
| + } |
| + |
| + /// Chooses the microphone on Mac, mid-call if one is up. The engine is |
| + /// reconfigured underneath the running track; nothing renegotiates. |
| + public func use(input id: String) throws { |
| + guard let module, let device = module.inputDevices.first(where: { $0.deviceId == id }) |
| + else { throw AudioRoutes.gone("microphone") } |
| + module.inputDevice = device |
| + refresh() |
| + guard currentInput == id else { throw AudioRoutes.refused("microphone") } |
| + } |
| + |
| + public func use(output id: String) throws { |
| + guard let module, let device = module.outputDevices.first(where: { $0.deviceId == id }) |
| + else { throw AudioRoutes.gone("speaker") } |
| + module.outputDevice = device |
| + refresh() |
| + guard currentOutput == id else { throw AudioRoutes.refused("speaker") } |
| + } |
| + |
| + private static func gone(_ what: String) -> NSError { |
| + NSError( |
| + domain: "Calls", code: 5, |
| + userInfo: [NSLocalizedDescriptionKey: "That \(what) is no longer available."]) |
| + } |
| + private static func refused(_ what: String) -> NSError { |
| + NSError( |
| + domain: "Calls", code: 6, |
| + userInfo: [NSLocalizedDescriptionKey: "The call could not switch to that \(what)."]) |
| + } |
| + #endif |
| + |
| #if os(iOS) |
| /// Chooses the microphone, and decides the Bluetooth question with it. |
| /// |
| @@ -129,40 +176,4 @@ public struct AudioDevice: Identifiable, Hashable, Sendable { |
| : [.defaultToSpeaker, .allowBluetoothA2DP] |
| } |
| #endif |
| - |
| - #if os(macOS) |
| - /// The system's default input or output device, by name. CoreAudio is |
| - /// the only way to ask: the WebRTC slice cannot say, and this is |
| - /// precisely the device it will be using, because its audio device |
| - /// module follows the system default. |
| - private func macDevice(input: Bool) -> AudioDevice? { |
| - var id = AudioDeviceID(0) |
| - var size = UInt32(MemoryLayout<AudioDeviceID>.size) |
| - var address = AudioObjectPropertyAddress( |
| - mSelector: input |
| - ? kAudioHardwarePropertyDefaultInputDevice : kAudioHardwarePropertyDefaultOutputDevice, |
| - mScope: kAudioObjectPropertyScopeGlobal, |
| - mElement: kAudioObjectPropertyElementMain) |
| - guard |
| - AudioObjectGetPropertyData( |
| - AudioObjectID(kAudioObjectSystemObject), &address, 0, nil, &size, &id) == noErr, |
| - id != kAudioObjectUnknown |
| - else { return nil } |
| - // Unmanaged, not CFString: CoreAudio writes a retained reference into |
| - // this buffer, and pointing at a managed CFString both trips ARC's |
| - // "object reference in a raw buffer" warning and leaves the +1 |
| - // unbalanced. takeRetainedValue consumes it exactly once. |
| - var name: Unmanaged<CFString>? |
| - var nameSize = UInt32(MemoryLayout<Unmanaged<CFString>?>.size) |
| - var nameAddress = AudioObjectPropertyAddress( |
| - mSelector: kAudioObjectPropertyName, |
| - mScope: kAudioObjectPropertyScopeGlobal, |
| - mElement: kAudioObjectPropertyElementMain) |
| - guard |
| - AudioObjectGetPropertyData(id, &nameAddress, 0, nil, &nameSize, &name) == noErr, |
| - let value = name?.takeRetainedValue() |
| - else { return nil } |
| - return AudioDevice(id: String(id), name: value as String, selectable: false) |
| - } |
| - #endif |
| } |
diff --git a/native/apple/Sources/CallsKit/Call.swift b/native/apple/Sources/CallsKit/Call.swift| index 9235300..2a010db 100644 |
| --- a/native/apple/Sources/CallsKit/Call.swift |
| +++ b/native/apple/Sources/CallsKit/Call.swift |
| @@ -2,7 +2,7 @@ import Combine |
| import Foundation |
| // Go serializes this API with a mutex; Objective-C bindings carry no Sendable annotation. |
| @preconcurrency import CallsBridge |
| -import WebRTC |
| +import LiveKitWebRTC |
| // The Go callback only enqueues. The main actor owns the interface and media; |
| // an epoch prevents callbacks from a closed room reaching a later call. |
| @@ -52,6 +52,18 @@ private final class Events: NSObject, BridgeListenerProtocol { |
| @Published public private(set) var currentCamera: String? |
| /// The routes this platform can report, and on iPhone choose between. |
| public let audio = AudioRoutes() |
| + /// "Reduce background noise": the neural suppressor in place of WebRTC's |
| + /// own. On by default, and remembered. |
| + @Published public var reduceNoise: Bool = UserDefaults.standard.object(forKey: Call.reduceNoiseKey) as? Bool ?? true { |
| + didSet { |
| + UserDefaults.standard.set(reduceNoise, forKey: Call.reduceNoiseKey) |
| + media.audio.reduceNoise = reduceNoise |
| + audioProcessing = media.audio.report() |
| + } |
| + } |
| + static let reduceNoiseKey = "reduceBackgroundNoise" |
| + /// Which processing is running on the microphone, for the readout. |
| + @Published public private(set) var audioProcessing: ProcessingReport? |
| private var cameraWork: Task<Void, Never>? |
| private var cameraSwitches = 0 |
| private let knocks = KnockWatch() |
| @@ -71,7 +83,12 @@ private final class Events: NSObject, BridgeListenerProtocol { |
| self.playAudio = playAudio |
| } |
| #else |
| - public init(playAudio: Bool = true) { self.playAudio = playAudio } |
| + let audioInput: AudioInputSource? |
| + /// `audioInput` stands in for the microphone (the interop harness). |
| + public init(playAudio: Bool = true, audioInput: AudioInputSource? = nil) { |
| + self.playAudio = playAudio |
| + self.audioInput = audioInput |
| + } |
| #endif |
| public func join(origin: String, room: String, cookies: String, capture: VideoCapture? = nil) |
| async throws |
| @@ -100,6 +117,8 @@ private final class Events: NSObject, BridgeListenerProtocol { |
| localTrack = media.localVideo |
| currentCamera = media.currentCamera |
| audio.refresh() |
| + media.audio.reduceNoise = reduceNoise |
| + audioProcessing = media.audio.report() |
| 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 |
| @@ -123,6 +142,7 @@ private final class Events: NSObject, BridgeListenerProtocol { |
| loadingInvitation = false |
| deviceNote = "" |
| audioHealth = nil |
| + audioProcessing = nil |
| currentCamera = nil |
| knocks.reset() |
| cameraWork?.cancel() |
| @@ -195,10 +215,9 @@ private final class Events: NSObject, BridgeListenerProtocol { |
| /// This exists because of a call on 2026-09-17 that came back as an |
| /// unintelligible growl, and because nothing in this app recorded |
| /// anything about it — no log, no counters, no artefact. A reproduction |
| - /// would have produced nothing either. On macOS it is also the *only* |
| - /// instrument available, since the framework's macOS slice has no audio |
| - /// device API and a bad device cannot be ruled out by switching away |
| - /// from it. |
| + /// would have produced nothing either. (Until the move to LiveKit's |
| + /// WebRTC build it was also the only instrument on macOS, which had no |
| + /// way to switch away from a suspect device.) |
| /// |
| /// Five seconds because this is a readout a person glances at when |
| /// something sounds wrong, not a time series. |
| @@ -207,6 +226,8 @@ private final class Events: NSObject, BridgeListenerProtocol { |
| try? await Task.sleep(nanoseconds: 5_000_000_000) |
| guard !Task.isCancelled, bridge != nil else { return } |
| audioHealth = AudioHealth(await media.stats()) |
| + media.audio.refresh() |
| + audioProcessing = media.audio.report() |
| } |
| } |
| public func retryJoin() { send(type: "join", to: "", gen: 0, payload: "") } |
| @@ -335,6 +356,16 @@ private final class Events: NSObject, BridgeListenerProtocol { |
| values["remoteTracks"] = UInt64(remoteTracks.count) |
| values["invalidEvents"] = invalidEvents |
| values["rosterPeers"] = UInt64(state?.peers.count ?? 0) |
| + values["noiseFramesProcessed"] = media.audio.denoiser.framesProcessed |
| + values["noiseSuppressor"] = media.audio.denoiser.engineName == "RNNoise" ? 1 : 2 |
| + if case .running(_, let cpu) = media.audio.denoiser.state { |
| + values["noiseCpuPermille"] = UInt64((cpu * 1000).rounded()) |
| + } |
| + // What WebRTC reports running, not what was asked of it. |
| + let running = media.audio.report() |
| + values["webrtcEchoCancellation"] = running.echoCancellation == "off" ? 0 : 1 |
| + values["webrtcNoiseSuppression"] = running.noiseSuppression == "off" ? 0 : 1 |
| + values["webrtcGainControl"] = running.gainControl.hasPrefix("Calls") || running.gainControl == "off" ? 0 : 1 |
| return values |
| } |
| } |
diff --git a/native/apple/Sources/CallsKit/CallAudio.swift b/native/apple/Sources/CallsKit/CallAudio.swift| new file mode 100644 |
| index 0000000..cf7ade5 |
| --- /dev/null |
| +++ b/native/apple/Sources/CallsKit/CallAudio.swift |
| @@ -0,0 +1,357 @@ |
| +import AVFoundation |
| +import Foundation |
| +import LiveKitWebRTC |
| + |
| +/// Something other than a microphone to capture from: the interop |
| +/// harness's synthetic voice, as SyntheticVideo is its camera. Called on |
| +/// the audio engine's render thread with mono float samples in -1...1. |
| +/// On Mac only, where the engine module can run without any device. |
| +public protocol AudioInputSource: AnyObject, Sendable { |
| + func render(_ samples: UnsafeMutablePointer<Float>, count: Int, sampleRate: Double) |
| +} |
| + |
| +/// Which of WebRTC's processing stages run on the microphone, decided in |
| +/// one place so the rule can be read and tested apart from WebRTC. |
| +/// |
| +/// The rule the plan holds: our suppressor never runs beside another one. |
| +/// Not beside WebRTC's own noise suppressor, and not beside Apple's Voice |
| +/// Isolation. And WebRTC's gain control, which runs *before* our |
| +/// suppressor, is off whenever ours is on; SpeechGain lifts the voice |
| +/// after the noise has gone instead. |
| +public struct ProcessingPlan: Equatable, Sendable { |
| + public enum Where: Equatable, Sendable { case off, software, platform } |
| + /// Our neural suppressor, in the capture post-processing hook. |
| + public var suppressor: Bool |
| + public var echoCancellation: Where |
| + public var noiseSuppression: Where |
| + public var gainControl: Where |
| + /// WebRTC's high-pass filter, always: it is cheap and only takes out |
| + /// rumble below the voice. |
| + public var highPass = true |
| + |
| + /// - Parameters: |
| + /// - iPhone: the phone runs on Apple's voice-processing unit, whose |
| + /// echo canceller and noise suppressor are one switch; the Mac runs |
| + /// WebRTC's software processing. |
| + /// - reduceNoise: the person's toggle. |
| + /// - rate: the capture rate WebRTC reported, or nil before it has. |
| + /// - voiceIsolation: Apple's Voice Isolation is the active mic mode. |
| + public static func plan(iPhone: Bool, reduceNoise: Bool, rate: Int?, voiceIsolation: Bool) |
| + -> ProcessingPlan |
| + { |
| + let rateOK = rate.map { $0 == CaptureDenoiser.supportedRate } ?? true |
| + let ours = reduceNoise && rateOK && !voiceIsolation |
| + if iPhone { |
| + // Apple's echo canceller cannot be had without Apple's suppressor |
| + // beside it; that pairing is what the phone has always run. Its gain |
| + // control is separate, and is what comes off. |
| + return ProcessingPlan( |
| + suppressor: ours, echoCancellation: .platform, noiseSuppression: .platform, |
| + gainControl: ours ? .off : .platform) |
| + } |
| + let classicNS = reduceNoise && !rateOK |
| + return ProcessingPlan( |
| + suppressor: ours, echoCancellation: .software, |
| + noiseSuppression: classicNS ? .software : .off, |
| + gainControl: ours ? .off : .software) |
| + } |
| +} |
| + |
| +/// What the audio readout says about processing. |
| +public struct ProcessingReport: Equatable, Sendable { |
| + public var suppression: SuppressionState |
| + public var echoCancellation: String |
| + public var noiseSuppression: String |
| + public var gainControl: String |
| + /// Set when WebRTC refused the options asked of it. |
| + public var refused: String? |
| + |
| + /// One or two plain sentences. |
| + public var summary: String { |
| + var lines: [String] = [] |
| + switch suppression { |
| + case .off: lines.append("Background noise reduction is off.") |
| + case .running(let engine, let cpu): |
| + lines.append( |
| + "Reducing background noise with \(engine) (\(String(format: "%.1f", cpu * 100))% of real time on the audio thread).") |
| + case .idle(let engine): lines.append("Reducing background noise with \(engine) when you speak.") |
| + case .unsupportedRate(let rate): |
| + lines.append( |
| + "The microphone runs at \(rate / 1000) kHz, which the noise reducer cannot; WebRTC's own is standing in.") |
| + case .standingAside: |
| + lines.append("Voice Isolation is on, so Calls leaves noise reduction to it.") |
| + } |
| + lines.append( |
| + "Echo cancellation: \(echoCancellation). Noise suppression: \(noiseSuppression). Gain: \(gainControl).") |
| + if let refused { lines.append("WebRTC refused the requested processing: \(refused)") } |
| + return lines.joined(separator: " ") |
| + } |
| +} |
| + |
| +/// How a call's audio is built: which audio device module WebRTC runs on, |
| +/// what processing runs on the microphone, and — on Mac — what the app is |
| +/// told about the devices it can use. |
| +/// |
| +/// Kept out of Media.swift so that file only changes where the factory is |
| +/// made. |
| +@MainActor final class CallAudio { |
| + let factory: RTCPeerConnectionFactory |
| + /// Nil for the iPhone tests' silent device, which takes the constructor |
| + /// that has no processing module. |
| + let processing: RTCDefaultAudioProcessingModule? |
| + let denoiser: CaptureDenoiser |
| + private weak var track: RTCAudioTrack? |
| + private var rate: Int? |
| + private var voiceIsolation = false |
| + private var refused: String? |
| + private(set) var plan: ProcessingPlan |
| + var reduceNoise: Bool { |
| + didSet { if reduceNoise != oldValue { apply() } } |
| + } |
| + |
| + #if os(macOS) |
| + private let devices: EngineWatch |
| + #endif |
| + |
| + /// `audioDevice` is the iPhone tests' silent device. It takes the old |
| + /// constructor, which has no audio device module to speak of. |
| + /// |
| + /// `input`, on Mac, replaces the microphone and the speaker both: the |
| + /// engine runs in manual rendering mode, touching no device at all. |
| + init( |
| + routes: AudioRoutes, audioDevice: RTCAudioDevice?, input: AudioInputSource? = nil, |
| + reduceNoise: Bool = true |
| + ) { |
| + let encoder = RTCDefaultVideoEncoderFactory() |
| + let decoder = RTCDefaultVideoDecoderFactory() |
| + self.reduceNoise = reduceNoise |
| + denoiser = CaptureDenoiser(suppressor: CallAudio.makeSuppressor()) |
| + #if os(iOS) |
| + plan = ProcessingPlan.plan(iPhone: true, reduceNoise: reduceNoise, rate: nil, voiceIsolation: false) |
| + if let audioDevice { |
| + processing = nil |
| + factory = RTCPeerConnectionFactory( |
| + encoderFactory: encoder, decoderFactory: decoder, audioDevice: audioDevice) |
| + return |
| + } |
| + let processing = RTCDefaultAudioProcessingModule( |
| + config: nil, capturePostProcessingDelegate: nil, renderPreProcessingDelegate: nil) |
| + self.processing = processing |
| + // The platform default on iPhone is the voice-processing I/O unit it |
| + // has always been. |
| + factory = RTCPeerConnectionFactory( |
| + audioDeviceModuleType: .platformDefault, bypassVoiceProcessing: false, |
| + encoderFactory: encoder, decoderFactory: decoder, audioProcessingModule: processing) |
| + #else |
| + plan = ProcessingPlan.plan(iPhone: false, reduceNoise: reduceNoise, rate: nil, voiceIsolation: false) |
| + let processing = RTCDefaultAudioProcessingModule( |
| + config: nil, capturePostProcessingDelegate: nil, renderPreProcessingDelegate: nil) |
| + self.processing = processing |
| + // AVAudioEngine rather than the CoreAudio module: it is the one with a |
| + // device list and a way to choose from it. |
| + factory = RTCPeerConnectionFactory( |
| + audioDeviceModuleType: .audioEngine, bypassVoiceProcessing: false, |
| + encoderFactory: encoder, decoderFactory: decoder, audioProcessingModule: processing) |
| + let module = factory.audioDeviceModule |
| + // Apple's voice processing stays off on Mac. Left allowed, the engine |
| + // module resolves echo cancellation, noise suppression and gain to |
| + // Apple's coupled voice-processing unit instead of WebRTC's own — |
| + // a different pipeline from the one the Mac app has always had, and |
| + // one whose noise suppression cannot be separated from its echo |
| + // canceller. WebRTC's software processing is what ran before this |
| + // module, and is what runs now. |
| + _ = module.setPlatformVoiceProcessingAllowed(false) |
| + devices = EngineWatch(routes: routes, input: input) |
| + module.observer = devices |
| + if input != nil { |
| + _ = module.setManualRenderingMode(true) |
| + } else { |
| + routes.attach(module) |
| + } |
| + #endif |
| + // Set after the factory exists: handed to the module's initialiser, the |
| + // delegate is never called (seen in the spike, M150). The property is |
| + // weak; `denoiser` is what keeps it alive. |
| + processing.capturePostProcessingDelegate = denoiser |
| + CallAudio.upgrade(denoiser) |
| + denoiser.enabled = plan.suppressor |
| + denoiser.rateChanged = { [weak self] rate in |
| + DispatchQueue.main.async { |
| + MainActor.assumeIsolated { |
| + guard let self, self.rate != rate else { return } |
| + self.rate = rate |
| + self.apply() |
| + } |
| + } |
| + } |
| + } |
| + |
| + /// The suppressor a call starts on. See `upgrade`. |
| + static func makeSuppressor() -> NoiseSuppressor { RNNoiseSuppressor() } |
| + |
| + /// DeepFilterNet 3 on Mac, where the app carries its model: loaded off |
| + /// the main thread, then swapped in. RNNoise otherwise, and always on |
| + /// iPhone (DeepFilterNet's cost there has not been measured on a phone). |
| + private static func upgrade(_ denoiser: CaptureDenoiser) { |
| + #if canImport(DeepFilter) |
| + guard let model = DeepFilterSuppressor.model else { return } |
| + DispatchQueue.global(qos: .userInitiated).async { |
| + if let deep = DeepFilterSuppressor(model: model) { denoiser.replace(with: deep) } |
| + } |
| + #endif |
| + } |
| + |
| + /// The microphone track, made here so its processing options are set |
| + /// before it is ever sent. |
| + func makeTrack() -> RTCAudioTrack { |
| + let track = factory.audioTrack(withTrackId: "microphone") |
| + self.track = track |
| + apply() |
| + return track |
| + } |
| + |
| + /// Re-reads Apple's mic mode and reapplies the plan if anything moved. |
| + /// Called on the audio-health tick. |
| + func refresh() { |
| + let isolating = AVCaptureDevice.activeMicrophoneMode == .voiceIsolation |
| + if isolating != voiceIsolation { |
| + voiceIsolation = isolating |
| + apply() |
| + } |
| + } |
| + |
| + private func apply() { |
| + #if os(iOS) |
| + let iPhone = true |
| + #else |
| + let iPhone = false |
| + #endif |
| + plan = ProcessingPlan.plan( |
| + iPhone: iPhone, reduceNoise: reduceNoise, rate: rate, voiceIsolation: voiceIsolation) |
| + denoiser.enabled = reduceNoise |
| + denoiser.standAside = voiceIsolation |
| + guard let track, processing != nil else { return } |
| + let result = track.setAudioProcessingOptions(CallAudio.options(plan)) |
| + refused = result.isSuccess ? nil : result.message |
| + // Platform modes can be refused where Apple's unit cannot give them |
| + // (a simulator, a route without it). Then let WebRTC choose where each |
| + // enabled stage runs; what was off stays off, so the plan's rule — |
| + // never two suppressors, never gain before ours — still holds. |
| + if !result.isSuccess { |
| + let fallback = track.setAudioProcessingOptions(CallAudio.options(plan, automatic: true)) |
| + if !fallback.isSuccess { refused = fallback.message } |
| + } |
| + } |
| + |
| + static func options(_ plan: ProcessingPlan, automatic: Bool = false) -> RTCAudioProcessingOptions { |
| + func component(_ place: ProcessingPlan.Where) -> RTCAudioProcessingComponentOptions { |
| + switch place { |
| + case .off: return RTCAudioProcessingComponentOptions(enabled: false) |
| + case .software: |
| + return RTCAudioProcessingComponentOptions(enabled: true, mode: automatic ? .automatic : .software) |
| + case .platform: |
| + return RTCAudioProcessingComponentOptions(enabled: true, mode: automatic ? .automatic : .platform) |
| + } |
| + } |
| + return RTCAudioProcessingOptions( |
| + echoCancellationOptions: component(plan.echoCancellation), |
| + noiseSuppressionOptions: component(plan.noiseSuppression), |
| + autoGainControlOptions: component(plan.gainControl), |
| + highPassFilterOptions: component(plan.highPass ? .software : .off)) |
| + } |
| + |
| + /// What is actually running, as WebRTC reports it — not what was asked. |
| + func report() -> ProcessingReport { |
| + let state = factory.audioProcessingState |
| + func says(_ component: RTCAudioProcessingComponentState) -> String { |
| + switch component.effective { |
| + case .disabled: return "off" |
| + case .software: return "WebRTC" |
| + case .platform: return "Apple" |
| + case .softwareAndPlatform: return "WebRTC and Apple" |
| + default: return "unknown" |
| + } |
| + } |
| + var gain = says(state.autoGainControl) |
| + if plan.suppressor, gain == "off" { gain = "Calls, after noise reduction" } |
| + return ProcessingReport( |
| + suppression: denoiser.state, echoCancellation: says(state.echoCancellation), |
| + noiseSuppression: says(state.noiseSuppression), gainControl: gain, refused: refused) |
| + } |
| +} |
| + |
| +#if os(macOS) |
| + /// The module's observer. The device-list callback keeps the pickers |
| + /// current; configuring the input is where a synthetic source is wired |
| + /// in. The rest of the protocol is the engine lifecycle, left alone |
| + /// (every one of those answers 0, "carry on"). |
| + private final class EngineWatch: NSObject, RTCAudioDeviceModuleDelegate, @unchecked Sendable { |
| + weak var routes: AudioRoutes? |
| + let input: AudioInputSource? |
| + private var node: AVAudioSourceNode? |
| + init(routes: AudioRoutes, input: AudioInputSource?) { |
| + self.routes = routes |
| + self.input = input |
| + } |
| + |
| + func audioDeviceModuleDidUpdateDevices(_ audioDeviceModule: RTCAudioDeviceModule) { |
| + DispatchQueue.main.async { [weak self] in |
| + MainActor.assumeIsolated { self?.routes?.refresh() } |
| + } |
| + } |
| + |
| + func audioDeviceModule( |
| + _ audioDeviceModule: RTCAudioDeviceModule, |
| + didReceiveSpeechActivityEvent speechActivityEvent: RTCSpeechActivityEvent |
| + ) {} |
| + func audioDeviceModule(_ audioDeviceModule: RTCAudioDeviceModule, didCreateEngine engine: AVAudioEngine) |
| + -> Int |
| + { 0 } |
| + func audioDeviceModule( |
| + _ audioDeviceModule: RTCAudioDeviceModule, willStartEngine engine: AVAudioEngine, |
| + isPlayoutEnabled: Bool, isRecordingEnabled: Bool |
| + ) -> Int { 0 } |
| + func audioDeviceModule( |
| + _ audioDeviceModule: RTCAudioDeviceModule, didStopEngine engine: AVAudioEngine, |
| + isPlayoutEnabled: Bool, isRecordingEnabled: Bool |
| + ) -> Int { 0 } |
| + func audioDeviceModule( |
| + _ audioDeviceModule: RTCAudioDeviceModule, didDisableEngine engine: AVAudioEngine, |
| + isPlayoutEnabled: Bool, isRecordingEnabled: Bool |
| + ) -> Int { 0 } |
| + func audioDeviceModule( |
| + _ audioDeviceModule: RTCAudioDeviceModule, willReleaseEngine engine: AVAudioEngine |
| + ) -> Int { 0 } |
| + func audioDeviceModule( |
| + _ audioDeviceModule: RTCAudioDeviceModule, engine: AVAudioEngine, |
| + configureInputFromSource source: AVAudioNode?, toDestination destination: AVAudioNode, |
| + format: AVAudioFormat, context: [AnyHashable: Any] |
| + ) -> Int { |
| + guard let input, |
| + let mono = AVAudioFormat(standardFormatWithSampleRate: format.sampleRate, channels: 1) |
| + else { return 0 } |
| + if let node { engine.detach(node) } |
| + let rate = format.sampleRate |
| + let node = AVAudioSourceNode(format: mono) { _, _, frames, buffers in |
| + for buffer in UnsafeMutableAudioBufferListPointer(buffers) { |
| + guard let data = buffer.mData else { continue } |
| + input.render(data.assumingMemoryBound(to: Float.self), count: Int(frames), sampleRate: rate) |
| + } |
| + return noErr |
| + } |
| + self.node = node |
| + engine.attach(node) |
| + let mixer = |
| + engine.isInManualRenderingMode |
| + ? engine.mainMixerNode |
| + : (context[kLKRTCAudioEngineInputMixerNodeKey] as? AVAudioMixerNode) ?? engine.mainMixerNode |
| + engine.connect(node, to: mixer, format: mono) |
| + return 0 |
| + } |
| + func audioDeviceModule( |
| + _ audioDeviceModule: RTCAudioDeviceModule, engine: AVAudioEngine, |
| + configureOutputFromSource source: AVAudioNode, toDestination destination: AVAudioNode?, |
| + format: AVAudioFormat, context: [AnyHashable: Any] |
| + ) -> Int { 0 } |
| + } |
| +#endif |
diff --git a/native/apple/Sources/CallsKit/Capture.swift b/native/apple/Sources/CallsKit/Capture.swift| index 8f4e51f..f902446 100644 |
| --- a/native/apple/Sources/CallsKit/Capture.swift |
| +++ b/native/apple/Sources/CallsKit/Capture.swift |
| @@ -1,6 +1,6 @@ |
| import AVFoundation |
| import Foundation |
| -import WebRTC |
| +import LiveKitWebRTC |
| /// One camera the person can choose, named the way a menu wants it. |
| /// |
diff --git a/native/apple/Sources/CallsKit/DevicePanel.swift b/native/apple/Sources/CallsKit/DevicePanel.swift| index ab3c609..5324ff7 100644 |
| --- a/native/apple/Sources/CallsKit/DevicePanel.swift |
| +++ b/native/apple/Sources/CallsKit/DevicePanel.swift |
| @@ -1,17 +1,12 @@ |
| import SwiftUI |
| -#if os(macOS) |
| - import AppKit |
| -#endif |
| - |
| -/// The camera, microphone and speaker a call is on — chosen where the |
| -/// platform allows it, reported where it does not. |
| +/// The camera, microphone and speaker a call is on, and a choice of each |
| +/// where there is more than one. |
| /// |
| /// One view for both apps, because the thing a person wants here is the |
| -/// same on a Mac and an iPhone even though what they can do about it is |
| -/// not. On iPhone every row is a choice. On Mac the camera is a choice |
| -/// and audio is a statement of fact with a way out to Sound settings, |
| -/// because WebRTC's macOS slice carries no audio device API at all. |
| +/// same on a Mac and an iPhone. On iPhone the microphone is an audio |
| +/// session route and the speaker follows it; on Mac both are devices on |
| +/// the call's own audio device module. |
| /// |
| /// The audio readout at the bottom is the reason this panel opens at |
| /// all, most of the time: someone has noticed the call sounds wrong. It |
| @@ -55,6 +50,8 @@ import SwiftUI |
| .accessibilityLabel("Device problem: \(call.deviceNote)") |
| } |
| + noiseToggle |
| + |
| Divider() |
| audioReadout |
| } |
| @@ -98,24 +95,52 @@ import SwiftUI |
| #endif |
| #if os(macOS) |
| + /// Microphone and speaker on the call's own audio device module, so a |
| + /// choice here moves this call and leaves the Mac's system setting |
| + /// alone. |
| private var macAudio: some View { |
| - VStack(alignment: .leading, spacing: 8) { |
| - if let input = audio.inputs.first { row("Microphone", input.name) } |
| - if !audio.outputName.isEmpty { row("Speaker", audio.outputName) } |
| - Text("Calls uses whichever microphone and speaker macOS is set to.") |
| - .font(.footnote).foregroundStyle(.secondary) |
| - .fixedSize(horizontal: false, vertical: true) |
| - Button("Open Sound settings", systemImage: "gearshape") { |
| - if let url = URL(string: "x-apple.systempreferences:com.apple.Sound-Settings.extension") { |
| - NSWorkspace.shared.open(url) |
| - } |
| + VStack(alignment: .leading, spacing: 12) { |
| + if audio.inputs.count > 1 { |
| + picker( |
| + "Microphone", |
| + selection: Binding( |
| + get: { audio.currentInput }, |
| + set: { id in |
| + guard let id, id != audio.currentInput else { return } |
| + do { try audio.use(input: id) } catch { call.noteDevice(error.localizedDescription) } |
| + }), |
| + options: audio.inputs.map { ($0.id, $0.name) }, identifier: "microphonePicker") |
| + } else if let only = audio.inputs.first { |
| + row("Microphone", only.name) |
| + } |
| + if audio.outputs.count > 1 { |
| + picker( |
| + "Speaker", |
| + selection: Binding( |
| + get: { audio.currentOutput }, |
| + set: { id in |
| + guard let id, id != audio.currentOutput else { return } |
| + do { try audio.use(output: id) } catch { call.noteDevice(error.localizedDescription) } |
| + }), |
| + options: audio.outputs.map { ($0.id, $0.name) }, identifier: "speakerPicker") |
| + } else if let only = audio.outputs.first { |
| + row("Speaker", only.name) |
| } |
| - .buttonStyle(.bordered) |
| - .accessibilityIdentifier("openSoundSettings") |
| } |
| } |
| #endif |
| + /// Said as what it does for the far side, since that is who hears it. |
| + private var noiseToggle: some View { |
| + VStack(alignment: .leading, spacing: 4) { |
| + Toggle("Reduce background noise", isOn: $call.reduceNoise) |
| + .accessibilityIdentifier("reduceNoise") |
| + Text("Takes out typing, wind and room noise before others hear you.") |
| + .font(.footnote).foregroundStyle(.secondary) |
| + .fixedSize(horizontal: false, vertical: true) |
| + } |
| + } |
| + |
| private var audioReadout: some View { |
| VStack(alignment: .leading, spacing: 4) { |
| Text("Audio health").font(.headline) |
| @@ -128,6 +153,12 @@ import SwiftUI |
| Text("Measuring…").font(.callout).foregroundStyle(.secondary) |
| .accessibilityIdentifier("audioHealth") |
| } |
| + if let processing = call.audioProcessing { |
| + Text(processing.summary) |
| + .font(.callout).foregroundStyle(.secondary) |
| + .fixedSize(horizontal: false, vertical: true) |
| + .accessibilityIdentifier("audioProcessing") |
| + } |
| } |
| } |
diff --git a/native/apple/Sources/CallsKit/MacVideoTile.swift b/native/apple/Sources/CallsKit/MacVideoTile.swift| index 061b5de..b1429ff 100644 |
| --- a/native/apple/Sources/CallsKit/MacVideoTile.swift |
| +++ b/native/apple/Sources/CallsKit/MacVideoTile.swift |
| @@ -1,7 +1,7 @@ |
| #if os(macOS) |
| import AppKit |
| import SwiftUI |
| - import WebRTC |
| + import LiveKitWebRTC |
| struct MacVideoTile: View { |
| let track: RTCVideoTrack? |
diff --git a/native/apple/Sources/CallsKit/Media.swift b/native/apple/Sources/CallsKit/Media.swift| index 46e73ca..6d72d83 100644 |
| --- a/native/apple/Sources/CallsKit/Media.swift |
| +++ b/native/apple/Sources/CallsKit/Media.swift |
| @@ -1,7 +1,7 @@ |
| import AVFoundation |
| import CoreVideo |
| import Foundation |
| -import WebRTC |
| +import LiveKitWebRTC |
| @MainActor final class MediaEngine { |
| weak var call: Call? |
| @@ -11,25 +11,24 @@ import WebRTC |
| private var capture: VideoCapture? |
| private var peers: [String: MediaPeer] = [:] |
| + /// The audio device module and processing behind the factory. See CallAudio. |
| + let audio: CallAudio |
| + |
| init(call: Call) { |
| self.call = call |
| RTCInitializeSSL() |
| #if os(iOS) |
| - factory = RTCPeerConnectionFactory( |
| - encoderFactory: RTCDefaultVideoEncoderFactory(), |
| - decoderFactory: RTCDefaultVideoDecoderFactory(), |
| - audioDevice: call.customAudioDevice) |
| + audio = CallAudio(routes: call.audio, audioDevice: call.customAudioDevice) |
| #else |
| - factory = RTCPeerConnectionFactory( |
| - encoderFactory: RTCDefaultVideoEncoderFactory(), |
| - decoderFactory: RTCDefaultVideoDecoderFactory()) |
| + audio = CallAudio(routes: call.audio, audioDevice: nil, input: call.audioInput) |
| #endif |
| + factory = audio.factory |
| } |
| func prepare(capture supplied: VideoCapture?) async throws { |
| close() |
| let source = factory.videoSource() |
| localVideo = factory.videoTrack(with: source, trackId: "camera") |
| - localAudio = factory.audioTrack(withTrackId: "microphone") |
| + localAudio = audio.makeTrack() |
| let capture = supplied ?? CameraCapture() |
| self.capture = capture |
| try await capture.start(source: source) |
diff --git a/native/apple/Sources/CallsKit/NoiseSuppression.swift b/native/apple/Sources/CallsKit/NoiseSuppression.swift| new file mode 100644 |
| index 0000000..8cdb00e |
| --- /dev/null |
| +++ b/native/apple/Sources/CallsKit/NoiseSuppression.swift |
| @@ -0,0 +1,319 @@ |
| +import Foundation |
| +import LiveKitWebRTC |
| +import RNNoise |
| +import os |
| + |
| +#if canImport(DeepFilter) |
| + import DeepFilter |
| +#endif |
| + |
| +// Noise suppression for the microphone, run inside WebRTC's capture |
| +// processing. |
| +// |
| +// WebRTC's audio processing runs high-pass filter → echo canceller → |
| +// noise suppressor → gain control, then hands each 10 ms frame to a |
| +// "capture post-processing" delegate before encoding. Its own noise |
| +// suppressor is the classic statistical one: good on steady hiss, poor on |
| +// wind, typing and a room's echo, which are what people actually hear. |
| +// So WebRTC's is switched off, and a neural suppressor runs in the |
| +// delegate instead, after the echo canceller has taken out the far side. |
| +// |
| +// The delegate is skipped by WebRTC while the microphone is muted (its |
| +// output is unused), and runs on WebRTC's audio thread: nothing here may |
| +// block, allocate per frame, or wait for the main actor. |
| + |
| +/// A mono, frame-at-a-time neural noise suppressor. |
| +/// |
| +/// `process` works in place on one frame of `frameLength` samples in |
| +/// WebRTC's float scale — ±32768 for full scale, int16's range as floats — |
| +/// and returns how sure it is that the frame is speech, 0 to 1. Each |
| +/// implementation owns its own conversion to whatever scale its model |
| +/// wants. |
| +public protocol NoiseSuppressor: AnyObject { |
| + /// Shown in the audio readout. |
| + var name: String { get } |
| + /// Samples per frame at 48 kHz, the only rate either model runs at. |
| + var frameLength: Int { get } |
| + func process(_ frame: UnsafeMutablePointer<Float>) -> Float |
| +} |
| + |
| +/// How far either suppressor may take noise down, in dB. Unlimited, a |
| +/// room goes to digital silence between words, which a listener hears as |
| +/// the line dropping, and a model can hollow out a voice with the noise |
| +/// around it. 30 dB takes a room to near silence and leaves speech its own |
| +/// texture. |
| +public let noiseAttenuationLimitDb: Float = 30 |
| + |
| +/// xiph's RNNoise: a small recurrent network, BSD-3, 480-sample frames at |
| +/// 48 kHz, and — unusually — in WebRTC's own ±32768 scale already, so the |
| +/// samples go in and come out untouched by any conversion. |
| +/// |
| +/// RNNoise has no attenuation limit of its own, so one is made the usual |
| +/// way: the untouched input is mixed back in at the limit. |
| +public final class RNNoiseSuppressor: NoiseSuppressor { |
| + public let name = "RNNoise" |
| + public let frameLength: Int |
| + private let state: OpaquePointer |
| + private let scratch: UnsafeMutablePointer<Float> |
| + private let floor = pow(10, -noiseAttenuationLimitDb / 20) |
| + |
| + public init() { |
| + state = rnnoise_create(nil) |
| + frameLength = Int(rnnoise_get_frame_size()) |
| + scratch = .allocate(capacity: frameLength) |
| + } |
| + deinit { |
| + rnnoise_destroy(state) |
| + scratch.deallocate() |
| + } |
| + public func process(_ frame: UnsafeMutablePointer<Float>) -> Float { |
| + scratch.update(from: frame, count: frameLength) |
| + let speech = rnnoise_process_frame(state, frame, scratch) |
| + for i in 0..<frameLength { frame[i] += floor * scratch[i] } |
| + return speech |
| + } |
| +} |
| + |
| +#if canImport(DeepFilter) |
| + /// DeepFilterNet 3 (Rikorose/DeepFilterNet's libDF, MIT or Apache-2.0): |
| + /// a larger model than RNNoise, markedly better on non-stationary noise |
| + /// (typing, wind, voices in the next room). 480-sample hop at 48 kHz, |
| + /// mono, in ±1 floats — so samples are scaled by 1/32768 on the way in |
| + /// and back on the way out. It adds about 30 ms of latency (two frames |
| + /// of lookahead and the overlap of its transform). |
| + public final class DeepFilterSuppressor: NoiseSuppressor { |
| + public let name = "DeepFilterNet 3" |
| + public let frameLength: Int |
| + private let state: OpaquePointer |
| + private let input: UnsafeMutablePointer<Float> |
| + private let output: UnsafeMutablePointer<Float> |
| + |
| + /// The model, where the app carries one: `CALLS_DEEPFILTER_MODEL` |
| + /// first (tests and the interop harness), then the bundle. |
| + public static var model: URL? { |
| + if let path = ProcessInfo.processInfo.environment["CALLS_DEEPFILTER_MODEL"] { |
| + return URL(fileURLWithPath: path) |
| + } |
| + return Bundle.main.url(forResource: "DeepFilterNet3_onnx", withExtension: "tar.gz") |
| + } |
| + |
| + /// Nil when there is no model file. libDF aborts the process on a |
| + /// model it cannot read, so the file is checked here first. |
| + public init?(model: URL, attenuationLimit: Float = noiseAttenuationLimitDb) { |
| + guard FileManager.default.isReadableFile(atPath: model.path) else { return nil } |
| + guard let state = df_create(model.path, attenuationLimit, nil) else { return nil } |
| + self.state = state |
| + frameLength = Int(df_get_frame_length(state)) |
| + input = .allocate(capacity: frameLength) |
| + output = .allocate(capacity: frameLength) |
| + } |
| + deinit { |
| + df_free(state) |
| + input.deallocate() |
| + output.deallocate() |
| + } |
| + |
| + /// Returns the model's local SNR estimate mapped to 0...1: at or below |
| + /// 5 dB reads as noise, at or above 15 dB as speech. |
| + public func process(_ frame: UnsafeMutablePointer<Float>) -> Float { |
| + for i in 0..<frameLength { input[i] = frame[i] / 32768 } |
| + let snr = df_process_frame(state, input, output) |
| + for i in 0..<frameLength { frame[i] = output[i] * 32768 } |
| + return min(max((snr - 5) / 10, 0), 1) |
| + } |
| + } |
| +#endif |
| + |
| +/// A gentle gain after the suppressor, instead of WebRTC's gain control. |
| +/// |
| +/// WebRTC's gain control runs *before* the capture post-processing hook, |
| +/// so left on it would raise the room along with a quiet voice and then |
| +/// hand the suppressor louder noise to remove — the pumping a denoised |
| +/// call is known for. It is switched off while a suppressor runs, and this |
| +/// takes its place, after the noise is gone. |
| +/// |
| +/// Conservative on purpose: it only ever lifts (0 to +12 dB), it only |
| +/// learns from frames the suppressor calls speech, it rises slowly |
| +/// (about 1 dB a second of speech) and falls fast, and a per-frame peak |
| +/// limit keeps it from ever clipping. A voice already at a sensible level |
| +/// is left alone. |
| +public struct SpeechGain { |
| + /// Speech level aimed for, dBFS RMS. -26 is the usual target for |
| + /// conversational speech (ITU-T P.56 practice). |
| + public static let targetDb: Float = -26 |
| + public static let maxGainDb: Float = 12 |
| + public static let voiced: Float = 0.6 |
| + public private(set) var gainDb: Float = 0 |
| + |
| + public init() {} |
| + |
| + public mutating func apply(_ frame: UnsafeMutablePointer<Float>, count: Int, speech: Float) { |
| + var energy: Float = 0 |
| + var peak: Float = 0 |
| + for i in 0..<count { |
| + energy += frame[i] * frame[i] |
| + peak = max(peak, abs(frame[i])) |
| + } |
| + let rms = (energy / Float(max(count, 1))).squareRoot() / 32768 |
| + if speech >= SpeechGain.voiced, rms > 0.001 { // above -60 dBFS |
| + let levelDb = 20 * log10(rms) |
| + let wanted = min(max(SpeechGain.targetDb - levelDb, 0), SpeechGain.maxGainDb) |
| + // Up by 0.01 dB a frame (1 dB/s), down by up to 1 dB a frame. |
| + gainDb += min(max(wanted - gainDb, -1), 0.01) |
| + } |
| + guard gainDb > 0.01 else { return } |
| + var gain = pow(10, gainDb / 20) |
| + let limit: Float = 0.9 * 32768 |
| + if peak * gain > limit { gain = max(1, limit / peak) } |
| + for i in 0..<count { frame[i] *= gain } |
| + } |
| +} |
| + |
| +/// What the suppressor is doing, for the audio readout. |
| +public enum SuppressionState: Equatable, Sendable { |
| + /// Toggle off, or nothing has asked yet. |
| + case off |
| + /// Processing frames; `cpu` is the share of real time spent in it on |
| + /// the audio thread, 0 to 1 — wall clock, so preemption counts too. |
| + case running(engine: String, cpu: Double) |
| + /// Initialised and waiting — muted (WebRTC skips processing then), or |
| + /// no audio has flowed yet. |
| + case idle(engine: String) |
| + /// The capture runs at a rate the models do not (a Bluetooth headset's |
| + /// 16 kHz, say): frames pass through untouched. |
| + case unsupportedRate(Int) |
| + /// Apple's Voice Isolation is on; two suppressors in a row would only |
| + /// take more of the voice with them. |
| + case standingAside |
| +} |
| + |
| +/// The frame adapter between WebRTC's capture hook and a suppressor. |
| +/// |
| +/// WebRTC hands over 10 ms at a time, 480 samples at 48 kHz, which is one |
| +/// frame of either model; a buffer that is a whole number of frames is |
| +/// processed frame by frame, and anything else passes through untouched. |
| +/// So does every rate but 48 kHz, and every channel count but one. |
| +/// |
| +/// The suppressor is made once and kept across WebRTC's |
| +/// initialise/release cycles, which happen whenever the processing is |
| +/// reconfigured: a model can take a moment to load, and the audio thread |
| +/// is no place to do it. `release` stops processing; it does not free. |
| +public final class CaptureDenoiser: NSObject, RTCAudioCustomProcessingDelegate, @unchecked Sendable { |
| + public static let supportedRate = 48_000 |
| + |
| + private struct State { |
| + var enabled = true |
| + var standAside = false |
| + var rate = 0 |
| + var channels = 0 |
| + var initialised = false |
| + var frames: UInt64 = 0 |
| + var busySeconds: Double = 0 |
| + var lastFrame: Double = -.infinity |
| + var cpu: Double = 0 |
| + } |
| + private let lock = OSAllocatedUnfairLock(initialState: State()) |
| + /// Read under the lock once per buffer, so it can be replaced mid-call. |
| + private var current: NoiseSuppressor |
| + private var gain = SpeechGain() |
| + private let clock: () -> Double |
| + /// Called (on the audio thread) when WebRTC reinitialises at a rate the |
| + /// suppressor can or cannot take, so the processing options can follow. |
| + public var rateChanged: (@Sendable (Int) -> Void)? |
| + |
| + public init(suppressor: NoiseSuppressor, clock: @escaping () -> Double = CaptureDenoiser.now) { |
| + current = suppressor |
| + self.clock = clock |
| + } |
| + |
| + /// Swaps the model, between buffers. A call starts on RNNoise and moves |
| + /// to DeepFilterNet once its model has loaded off the main thread |
| + /// (a third of a second on an M1 Max; the audio thread is no place for |
| + /// it, and neither is joining). |
| + public func replace(with suppressor: NoiseSuppressor) { |
| + lock.withLock { _ in current = suppressor } |
| + } |
| + |
| + private var suppressor: NoiseSuppressor { lock.withLock { _ in current } } |
| + |
| + public static func now() -> Double { ProcessInfo.processInfo.systemUptime } |
| + |
| + public var engineName: String { suppressor.name } |
| + |
| + /// The toggle. Off passes frames through untouched. |
| + public var enabled: Bool { |
| + get { lock.withLock { $0.enabled } } |
| + set { lock.withLock { $0.enabled = newValue } } |
| + } |
| + /// Set while Apple's Voice Isolation is doing the job. |
| + public var standAside: Bool { |
| + get { lock.withLock { $0.standAside } } |
| + set { lock.withLock { $0.standAside = newValue } } |
| + } |
| + public var framesProcessed: UInt64 { lock.withLock { $0.frames } } |
| + |
| + public var state: SuppressionState { |
| + let now = clock() |
| + return lock.withLock { s in |
| + if !s.enabled { return .off } |
| + if s.standAside { return .standingAside } |
| + if s.initialised, s.rate != CaptureDenoiser.supportedRate { return .unsupportedRate(s.rate) } |
| + if s.initialised, now - s.lastFrame < 1 { return .running(engine: current.name, cpu: s.cpu) } |
| + return .idle(engine: current.name) |
| + } |
| + } |
| + |
| + // MARK: RTCAudioCustomProcessingDelegate |
| + |
| + public func audioProcessingInitialize(sampleRate sampleRateHz: Int, channels: Int) { |
| + let changed = lock.withLock { s -> Bool in |
| + let changed = !s.initialised || s.rate != sampleRateHz |
| + s.rate = sampleRateHz |
| + s.channels = channels |
| + s.initialised = true |
| + return changed |
| + } |
| + if changed { rateChanged?(sampleRateHz) } |
| + } |
| + |
| + public func audioProcessingProcess(audioBuffer: RTCAudioBuffer) { |
| + guard audioBuffer.channels == 1 else { return } |
| + process(audioBuffer.rawBuffer(forChannel: 0), frames: audioBuffer.frames) |
| + } |
| + |
| + public func audioProcessingRelease() { |
| + lock.withLock { $0.initialised = false } |
| + } |
| + |
| + // MARK: The adapter itself, apart from WebRTC's buffer type |
| + |
| + /// Processes one buffer in place. Public for the tests, which cannot |
| + /// make an RTCAudioBuffer. |
| + public func process(_ samples: UnsafeMutablePointer<Float>, frames: Int) { |
| + let (run, suppressor) = lock.withLock { s in |
| + ( |
| + s.enabled && !s.standAside && s.initialised && s.channels == 1 |
| + && s.rate == CaptureDenoiser.supportedRate, current |
| + ) |
| + } |
| + let length = suppressor.frameLength |
| + guard run, frames > 0, frames % length == 0 else { return } |
| + let started = clock() |
| + var offset = 0 |
| + while offset < frames { |
| + let frame = samples + offset |
| + let speech = suppressor.process(frame) |
| + gain.apply(frame, count: length, speech: speech) |
| + offset += length |
| + } |
| + let finished = clock() |
| + let audio = Double(frames) / Double(CaptureDenoiser.supportedRate) |
| + lock.withLock { s in |
| + s.frames += UInt64(frames / length) |
| + s.lastFrame = finished |
| + // A smoothed share of one core: time spent against audio covered. |
| + let share = (finished - started) / audio |
| + s.cpu = s.frames <= UInt64(frames / length) ? share : s.cpu * 0.98 + share * 0.02 |
| + } |
| + } |
| +} |
diff --git a/native/apple/Sources/CallsKit/WebRTCNames.swift b/native/apple/Sources/CallsKit/WebRTCNames.swift| new file mode 100644 |
| index 0000000..5361243 |
| --- /dev/null |
| +++ b/native/apple/Sources/CallsKit/WebRTCNames.swift |
| @@ -0,0 +1,213 @@ |
| +// The WebRTC names this app has always used, kept. |
| +// |
| +// Calls moved from stasel/WebRTC to LiveKit's build of the same Google |
| +// WebRTC (see native/README.md for why: the audio device module on macOS |
| +// and the audio processing hook). LiveKit's build prefixes every |
| +// Objective-C symbol with LK, so that it can sit beside another WebRTC in |
| +// one process — `LKRTCPeerConnection` rather than `RTCPeerConnection`. |
| +// |
| +// Rather than rename every use, and turn every open branch that touches |
| +// media into a conflict, the old names are aliased here, once. A file that |
| +// used WebRTC changes one line, its import. The list is generated from the |
| +// framework's own headers (every class, protocol and enum it declares), so |
| +// a name a future change reaches for is already here. |
| +// |
| +// Public, because CallsMac, CallsInterop and the iPhone app use these |
| +// names too and all of them import CallsKit. |
| + |
| +import LiveKitWebRTC |
| + |
| +public typealias RTCAudioBuffer = LKRTCAudioBuffer |
| +public typealias RTCAudioCustomProcessingDelegate = LKRTCAudioCustomProcessingDelegate |
| +public typealias RTCAudioDevice = LKRTCAudioDevice |
| +public typealias RTCAudioDeviceDelegate = LKRTCAudioDeviceDelegate |
| +public typealias RTCAudioDeviceModule = LKRTCAudioDeviceModule |
| +public typealias RTCAudioDeviceModuleDelegate = LKRTCAudioDeviceModuleDelegate |
| +public typealias RTCAudioDeviceModuleType = LKRTCAudioDeviceModuleType |
| +public typealias RTCAudioDuckingLevel = LKRTCAudioDuckingLevel |
| +public typealias RTCAudioEngineAvailability = LKRTCAudioEngineAvailability |
| +public typealias RTCAudioEngineMuteMode = LKRTCAudioEngineMuteMode |
| +public typealias RTCAudioEngineState = LKRTCAudioEngineState |
| +public typealias RTCAudioProcessingComponentOptions = LKRTCAudioProcessingComponentOptions |
| +public typealias RTCAudioProcessingComponentState = LKRTCAudioProcessingComponentState |
| +public typealias RTCAudioProcessingConfig = LKRTCAudioProcessingConfig |
| +public typealias RTCAudioProcessingImplementation = LKRTCAudioProcessingImplementation |
| +public typealias RTCAudioProcessingMode = LKRTCAudioProcessingMode |
| +public typealias RTCAudioProcessingModule = LKRTCAudioProcessingModule |
| +public typealias RTCAudioProcessingOptions = LKRTCAudioProcessingOptions |
| +public typealias RTCAudioProcessingOptionsResult = LKRTCAudioProcessingOptionsResult |
| +public typealias RTCAudioProcessingOptionsResultCode = LKRTCAudioProcessingOptionsResultCode |
| +public typealias RTCAudioProcessingState = LKRTCAudioProcessingState |
| +public typealias RTCAudioRenderer = LKRTCAudioRenderer |
| +public typealias RTCAudioSource = LKRTCAudioSource |
| +public typealias RTCAudioTrack = LKRTCAudioTrack |
| +public typealias RTCBundlePolicy = LKRTCBundlePolicy |
| +public typealias RTCCVPixelBuffer = LKRTCCVPixelBuffer |
| +public typealias RTCCallbackLogger = LKRTCCallbackLogger |
| +public typealias RTCCameraVideoCapturer = LKRTCCameraVideoCapturer |
| +public typealias RTCCandidateNetworkPolicy = LKRTCCandidateNetworkPolicy |
| +public typealias RTCCertificate = LKRTCCertificate |
| +public typealias RTCCodecSpecificInfo = LKRTCCodecSpecificInfo |
| +public typealias RTCCodecSpecificInfoH264 = LKRTCCodecSpecificInfoH264 |
| +public typealias RTCCodecSpecificInfoH265 = LKRTCCodecSpecificInfoH265 |
| +public typealias RTCConfiguration = LKRTCConfiguration |
| +public typealias RTCContinualGatheringPolicy = LKRTCContinualGatheringPolicy |
| +public typealias RTCCryptoOptions = LKRTCCryptoOptions |
| +public typealias RTCCryptorAlgorithm = LKRTCCryptorAlgorithm |
| +public typealias RTCDataBuffer = LKRTCDataBuffer |
| +public typealias RTCDataChannel = LKRTCDataChannel |
| +public typealias RTCDataChannelConfiguration = LKRTCDataChannelConfiguration |
| +public typealias RTCDataChannelDelegate = LKRTCDataChannelDelegate |
| +public typealias RTCDataChannelState = LKRTCDataChannelState |
| +public typealias RTCDataPacketCryptor = LKRTCDataPacketCryptor |
| +public typealias RTCDefaultAudioProcessingModule = LKRTCDefaultAudioProcessingModule |
| +public typealias RTCDefaultVideoDecoderFactory = LKRTCDefaultVideoDecoderFactory |
| +public typealias RTCDefaultVideoEncoderFactory = LKRTCDefaultVideoEncoderFactory |
| +public typealias RTCDegradationPreference = LKRTCDegradationPreference |
| +public typealias RTCDispatcher = LKRTCDispatcher |
| +public typealias RTCDispatcherQueueType = LKRTCDispatcherQueueType |
| +public typealias RTCDtmfSender = LKRTCDtmfSender |
| +public typealias RTCEncodedImage = LKRTCEncodedImage |
| +public typealias RTCEncryptedPacket = LKRTCEncryptedPacket |
| +public typealias RTCEncryptionKeyType = LKRTCEncryptionKeyType |
| +public typealias RTCFileLogger = LKRTCFileLogger |
| +public typealias RTCFileLoggerRotationType = LKRTCFileLoggerRotationType |
| +public typealias RTCFileLoggerSeverity = LKRTCFileLoggerSeverity |
| +public typealias RTCFrameCryptor = LKRTCFrameCryptor |
| +public typealias RTCFrameCryptorDelegate = LKRTCFrameCryptorDelegate |
| +public typealias RTCFrameCryptorKeyProvider = LKRTCFrameCryptorKeyProvider |
| +public typealias RTCFrameCryptorState = LKRTCFrameCryptorState |
| +public typealias RTCFrameType = LKRTCFrameType |
| +public typealias RTCH264Level = LKRTCH264Level |
| +public typealias RTCH264PacketizationMode = LKRTCH264PacketizationMode |
| +public typealias RTCH264Profile = LKRTCH264Profile |
| +public typealias RTCH264ProfileLevelId = LKRTCH264ProfileLevelId |
| +public typealias RTCI420Buffer = LKRTCI420Buffer |
| +public typealias RTCIODevice = LKRTCIODevice |
| +public typealias RTCIODeviceType = LKRTCIODeviceType |
| +public typealias RTCIceCandidate = LKRTCIceCandidate |
| +public typealias RTCIceCandidateErrorEvent = LKRTCIceCandidateErrorEvent |
| +public typealias RTCIceConnectionState = LKRTCIceConnectionState |
| +public typealias RTCIceGatheringState = LKRTCIceGatheringState |
| +public typealias RTCIceServer = LKRTCIceServer |
| +public typealias RTCIceTransportPolicy = LKRTCIceTransportPolicy |
| +public typealias RTCKeyDerivationAlgorithm = LKRTCKeyDerivationAlgorithm |
| +public typealias RTCLegacyStatsReport = LKRTCLegacyStatsReport |
| +public typealias RTCLoggingSeverity = LKRTCLoggingSeverity |
| +public typealias RTCMTLVideoView = LKRTCMTLVideoView |
| +public typealias RTCMediaConstraints = LKRTCMediaConstraints |
| +public typealias RTCMediaSource = LKRTCMediaSource |
| +public typealias RTCMediaStream = LKRTCMediaStream |
| +public typealias RTCMediaStreamTrack = LKRTCMediaStreamTrack |
| +public typealias RTCMediaStreamTrackState = LKRTCMediaStreamTrackState |
| +public typealias RTCMetricsSampleInfo = LKRTCMetricsSampleInfo |
| +public typealias RTCMutableI420Buffer = LKRTCMutableI420Buffer |
| +public typealias RTCMutableYUVPlanarBuffer = LKRTCMutableYUVPlanarBuffer |
| +public typealias RTCPeerConnection = LKRTCPeerConnection |
| +public typealias RTCPeerConnectionDelegate = LKRTCPeerConnectionDelegate |
| +public typealias RTCPeerConnectionFactory = LKRTCPeerConnectionFactory |
| +public typealias RTCPeerConnectionFactoryOptions = LKRTCPeerConnectionFactoryOptions |
| +public typealias RTCPeerConnectionState = LKRTCPeerConnectionState |
| +public typealias RTCPlatformAudioProcessingComponentState = LKRTCPlatformAudioProcessingComponentState |
| +public typealias RTCPlatformAudioProcessingState = LKRTCPlatformAudioProcessingState |
| +public typealias RTCPlatformAudioProcessingTopology = LKRTCPlatformAudioProcessingTopology |
| +public typealias RTCPriority = LKRTCPriority |
| +public typealias RTCRtcpMuxPolicy = LKRTCRtcpMuxPolicy |
| +public typealias RTCRtcpParameters = LKRTCRtcpParameters |
| +public typealias RTCRtpCapabilities = LKRTCRtpCapabilities |
| +public typealias RTCRtpCodecCapability = LKRTCRtpCodecCapability |
| +public typealias RTCRtpCodecParameters = LKRTCRtpCodecParameters |
| +public typealias RTCRtpEncodingParameters = LKRTCRtpEncodingParameters |
| +public typealias RTCRtpHeaderExtension = LKRTCRtpHeaderExtension |
| +public typealias RTCRtpHeaderExtensionCapability = LKRTCRtpHeaderExtensionCapability |
| +public typealias RTCRtpMediaType = LKRTCRtpMediaType |
| +public typealias RTCRtpParameters = LKRTCRtpParameters |
| +public typealias RTCRtpReceiver = LKRTCRtpReceiver |
| +public typealias RTCRtpReceiverDelegate = LKRTCRtpReceiverDelegate |
| +public typealias RTCRtpSender = LKRTCRtpSender |
| +public typealias RTCRtpSource = LKRTCRtpSource |
| +public typealias RTCRtpTransceiver = LKRTCRtpTransceiver |
| +public typealias RTCRtpTransceiverDirection = LKRTCRtpTransceiverDirection |
| +public typealias RTCRtpTransceiverInit = LKRTCRtpTransceiverInit |
| +public typealias RTCSSLCertificateVerifier = LKRTCSSLCertificateVerifier |
| +public typealias RTCSdpSemantics = LKRTCSdpSemantics |
| +public typealias RTCSdpType = LKRTCSdpType |
| +public typealias RTCSessionDescription = LKRTCSessionDescription |
| +public typealias RTCSignalingState = LKRTCSignalingState |
| +public typealias RTCSourceState = LKRTCSourceState |
| +public typealias RTCSpeechActivityEvent = LKRTCSpeechActivityEvent |
| +public typealias RTCStatistics = LKRTCStatistics |
| +public typealias RTCStatisticsReport = LKRTCStatisticsReport |
| +public typealias RTCStatsOutputLevel = LKRTCStatsOutputLevel |
| +public typealias RTCTcpCandidatePolicy = LKRTCTcpCandidatePolicy |
| +public typealias RTCTlsCertPolicy = LKRTCTlsCertPolicy |
| +public typealias RTCVideoCapturer = LKRTCVideoCapturer |
| +public typealias RTCVideoCapturerDelegate = LKRTCVideoCapturerDelegate |
| +public typealias RTCVideoCodecInfo = LKRTCVideoCodecInfo |
| +public typealias RTCVideoCodecMode = LKRTCVideoCodecMode |
| +public typealias RTCVideoContentType = LKRTCVideoContentType |
| +public typealias RTCVideoDecoder = LKRTCVideoDecoder |
| +public typealias RTCVideoDecoderAV1 = LKRTCVideoDecoderAV1 |
| +public typealias RTCVideoDecoderFactory = LKRTCVideoDecoderFactory |
| +public typealias RTCVideoDecoderFactoryH264 = LKRTCVideoDecoderFactoryH264 |
| +public typealias RTCVideoDecoderH264 = LKRTCVideoDecoderH264 |
| +public typealias RTCVideoDecoderH265 = LKRTCVideoDecoderH265 |
| +public typealias RTCVideoDecoderVP8 = LKRTCVideoDecoderVP8 |
| +public typealias RTCVideoDecoderVP9 = LKRTCVideoDecoderVP9 |
| +public typealias RTCVideoEncoder = LKRTCVideoEncoder |
| +public typealias RTCVideoEncoderAV1 = LKRTCVideoEncoderAV1 |
| +public typealias RTCVideoEncoderCodecSupport = LKRTCVideoEncoderCodecSupport |
| +public typealias RTCVideoEncoderFactory = LKRTCVideoEncoderFactory |
| +public typealias RTCVideoEncoderFactoryH264 = LKRTCVideoEncoderFactoryH264 |
| +public typealias RTCVideoEncoderFactorySimulcast = LKRTCVideoEncoderFactorySimulcast |
| +public typealias RTCVideoEncoderH264 = LKRTCVideoEncoderH264 |
| +public typealias RTCVideoEncoderH265 = LKRTCVideoEncoderH265 |
| +public typealias RTCVideoEncoderQpThresholds = LKRTCVideoEncoderQpThresholds |
| +public typealias RTCVideoEncoderSelector = LKRTCVideoEncoderSelector |
| +public typealias RTCVideoEncoderSettings = LKRTCVideoEncoderSettings |
| +public typealias RTCVideoEncoderSimulcast = LKRTCVideoEncoderSimulcast |
| +public typealias RTCVideoEncoderVP8 = LKRTCVideoEncoderVP8 |
| +public typealias RTCVideoEncoderVP9 = LKRTCVideoEncoderVP9 |
| +public typealias RTCVideoFrame = LKRTCVideoFrame |
| +public typealias RTCVideoFrameBuffer = LKRTCVideoFrameBuffer |
| +public typealias RTCVideoRenderer = LKRTCVideoRenderer |
| +public typealias RTCVideoRotation = LKRTCVideoRotation |
| +public typealias RTCVideoSource = LKRTCVideoSource |
| +public typealias RTCVideoTrack = LKRTCVideoTrack |
| +public typealias RTCVideoViewDelegate = LKRTCVideoViewDelegate |
| +public typealias RTCVideoViewShading = LKRTCVideoViewShading |
| +public typealias RTCYUVHelper = LKRTCYUVHelper |
| +public typealias RTCYUVPlanarBuffer = LKRTCYUVPlanarBuffer |
| + |
| +#if os(macOS) |
| + public typealias RTCDesktopCapturer = LKRTCDesktopCapturer |
| + public typealias RTCDesktopCapturerDelegate = LKRTCDesktopCapturerDelegate |
| + public typealias RTCDesktopMediaList = LKRTCDesktopMediaList |
| + public typealias RTCDesktopMediaListDelegate = LKRTCDesktopMediaListDelegate |
| + public typealias RTCDesktopSource = LKRTCDesktopSource |
| + public typealias RTCDesktopSourceType = LKRTCDesktopSourceType |
| + // LiveKit's macOS slice declares RTCMTLNSVideoView but does not build |
| + // it; its RTCMTLVideoView is the NSView renderer on macOS instead, with |
| + // the same delegate and init(frame:). The old name points at that. |
| + public typealias RTCMTLNSVideoView = LKRTCMTLVideoView |
| +#endif |
| + |
| +#if os(iOS) |
| + public typealias RTCAudioSession = LKRTCAudioSession |
| + public typealias RTCAudioSessionActivationDelegate = LKRTCAudioSessionActivationDelegate |
| + public typealias RTCAudioSessionConfiguration = LKRTCAudioSessionConfiguration |
| + public typealias RTCAudioSessionDelegate = LKRTCAudioSessionDelegate |
| + public typealias RTCCameraPreviewView = LKRTCCameraPreviewView |
| + public typealias RTCEAGLVideoView = LKRTCEAGLVideoView |
| + public typealias RTCFileVideoCapturer = LKRTCFileVideoCapturer |
| + public typealias RTCNetworkMonitor = LKRTCNetworkMonitor |
| +#endif |
| + |
| +@discardableResult public func RTCInitializeSSL() -> Bool { LKRTCInitializeSSL() } |
| +@discardableResult public func RTCCleanupSSL() -> Bool { LKRTCCleanupSSL() } |
| + |
| +public let kRTCMediaStreamTrackKindAudio = kLKRTCMediaStreamTrackKindAudio |
| +public let kRTCMediaStreamTrackKindVideo = kLKRTCMediaStreamTrackKindVideo |
| +public let kRTCMediaConstraintsIceRestart = kLKRTCMediaConstraintsIceRestart |
| +public let kRTCMediaConstraintsValueTrue = kLKRTCMediaConstraintsValueTrue |
| +public let kRTCMediaConstraintsValueFalse = kLKRTCMediaConstraintsValueFalse |
diff --git a/native/apple/Sources/CallsMac/CallsMac.swift b/native/apple/Sources/CallsMac/CallsMac.swift| index 1f4daf9..f3434d6 100644 |
| --- a/native/apple/Sources/CallsMac/CallsMac.swift |
| +++ b/native/apple/Sources/CallsMac/CallsMac.swift |
| @@ -5,7 +5,7 @@ import AppKit |
| @preconcurrency import CallsBridge |
| import CallsKit |
| import SwiftUI |
| -import WebRTC |
| +import LiveKitWebRTC |
| @main struct CallsMacApp: App { |
| #if os(macOS) |
diff --git a/native/apple/Tests/CallsKitTests/AudioHealthTests.swift b/native/apple/Tests/CallsKitTests/AudioHealthTests.swift| index de58c53..74651c2 100644 |
| --- a/native/apple/Tests/CallsKitTests/AudioHealthTests.swift |
| +++ b/native/apple/Tests/CallsKitTests/AudioHealthTests.swift |
| @@ -90,32 +90,36 @@ final class AudioHealthTests: XCTestCase { |
| } |
| } |
| -/// AudioRoutes on macOS is read-only, and everything it reports comes |
| -/// from CoreAudio rather than from WebRTC — which has no audio device API |
| -/// in its macOS slice at all. These run against the real machine, because |
| -/// the thing worth testing is that the CoreAudio calls are right. |
| +/// AudioRoutes on macOS reads and chooses devices on the call's own |
| +/// WebRTC audio device module. These run against the real machine and a |
| +/// real module (no recording is started), because the thing worth testing |
| +/// is that the module's lists come through named and choosable. |
| final class AudioRoutesTests: XCTestCase { |
| - @MainActor func testMacReportsTheSystemDevicesAndOffersNoChoice() throws { |
| + @MainActor func testMacListsTheModuleDevicesAndCanChoose() throws { |
| #if os(macOS) |
| let routes = AudioRoutes() |
| - XCTAssertFalse( |
| - routes.selectable, |
| - "macOS cannot select an audio device for WebRTC; the UI must not offer a picker") |
| + XCTAssertTrue(routes.inputs.isEmpty, "no module yet, so nothing to list") |
| + XCTAssertThrowsError(try routes.use(input: "anything")) |
| + |
| + let audio = CallAudio(routes: routes, audioDevice: nil) |
| + _ = audio |
| + XCTAssertTrue(routes.selectable) |
| // A machine with no input at all is possible, so this asserts the |
| - // shape rather than the presence: whatever is reported must be |
| - // named, because an unnamed device tells a person nothing and the |
| - // whole point of the Mac panel is saying what you are connected to. |
| - for device in routes.inputs { |
| - XCTAssertFalse(device.name.isEmpty, "a reported device must have a name") |
| - XCTAssertFalse(device.selectable) |
| + // shape rather than the presence. |
| + for device in routes.inputs + routes.outputs { |
| + XCTAssertFalse(device.name.isEmpty, "a listed device must have a name") |
| + XCTAssertTrue(device.selectable) |
| + } |
| + if let current = routes.currentInput, !routes.inputs.isEmpty { |
| + XCTAssertTrue(routes.inputs.contains { $0.id == current }, "the current input is one listed") |
| + } |
| + XCTAssertThrowsError(try routes.use(input: "no-such-device")) { error in |
| + XCTAssertEqual((error as NSError).code, 5) |
| } |
| - if !routes.outputName.isEmpty { |
| - XCTAssertFalse(routes.outputName.hasPrefix("<"), "output name should be a real name") |
| + if let first = routes.inputs.first { |
| + XCTAssertNoThrow(try routes.use(input: first.id)) |
| + XCTAssertEqual(routes.currentInput, first.id) |
| } |
| - // Reading twice must give the same answer and must not crash on the |
| - // retained CFString CoreAudio hands back. |
| - let again = AudioRoutes() |
| - XCTAssertEqual(again.outputName, routes.outputName) |
| #else |
| throw XCTSkip("macOS only") |
| #endif |
diff --git a/native/apple/Tests/CallsKitTests/Fixtures/speech-48k.wav b/native/apple/Tests/CallsKitTests/Fixtures/speech-48k.wav| new file mode 100644 |
| index 0000000..962e67c |
| Binary files /dev/null and b/native/apple/Tests/CallsKitTests/Fixtures/speech-48k.wav differ |
diff --git a/native/apple/Tests/CallsKitTests/NoiseSuppressionTests.swift b/native/apple/Tests/CallsKitTests/NoiseSuppressionTests.swift| new file mode 100644 |
| index 0000000..8389351 |
| --- /dev/null |
| +++ b/native/apple/Tests/CallsKitTests/NoiseSuppressionTests.swift |
| @@ -0,0 +1,425 @@ |
| +import Foundation |
| +import XCTest |
| + |
| +@testable import CallsKit |
| + |
| +/// Stands in for a model: records what it was handed and halves it, so a |
| +/// test can see both that a frame reached it and that the result was |
| +/// written back in place. |
| +private final class FakeSuppressor: NoiseSuppressor { |
| + let name = "Fake" |
| + let frameLength = 480 |
| + var calls = 0 |
| + var seen: [Float] = [] |
| + func process(_ frame: UnsafeMutablePointer<Float>) -> Float { |
| + calls += 1 |
| + if seen.isEmpty { seen = Array(UnsafeBufferPointer(start: frame, count: frameLength)) } |
| + for i in 0..<frameLength { frame[i] *= 0.5 } |
| + return 0 |
| + } |
| +} |
| + |
| +private final class Clock: @unchecked Sendable { |
| + var now = 100.0 |
| +} |
| + |
| +/// The frame adapter: what reaches the suppressor, when, and when nothing |
| +/// does. |
| +final class CaptureDenoiserTests: XCTestCase { |
| + private func adapter() -> (CaptureDenoiser, FakeSuppressor, Clock) { |
| + let fake = FakeSuppressor() |
| + let clock = Clock() |
| + return (CaptureDenoiser(suppressor: fake, clock: { clock.now }), fake, clock) |
| + } |
| + |
| + private func run(_ denoiser: CaptureDenoiser, count: Int, value: Float = 1000) -> [Float] { |
| + var samples = [Float](repeating: value, count: count) |
| + samples.withUnsafeMutableBufferPointer { denoiser.process($0.baseAddress!, frames: count) } |
| + return samples |
| + } |
| + |
| + func testTenMillisecondsAt48kIsOneFrameAndIsProcessedInPlace() { |
| + let (denoiser, fake, _) = adapter() |
| + denoiser.audioProcessingInitialize(sampleRate: 48_000, channels: 1) |
| + let out = run(denoiser, count: 480) |
| + XCTAssertEqual(fake.calls, 1) |
| + XCTAssertEqual(out, [Float](repeating: 500, count: 480), "the result is written back in place") |
| + XCTAssertEqual(denoiser.framesProcessed, 1) |
| + } |
| + |
| + func testSamplesReachTheSuppressorInWebRTCScaleUnconverted() { |
| + let (denoiser, fake, _) = adapter() |
| + denoiser.audioProcessingInitialize(sampleRate: 48_000, channels: 1) |
| + _ = run(denoiser, count: 480, value: 32_000) |
| + XCTAssertEqual(fake.seen.first, 32_000, "the adapter does not rescale; each suppressor owns its scale") |
| + } |
| + |
| + func testAWholeNumberOfFramesIsSplitIntoFrames() { |
| + let (denoiser, fake, _) = adapter() |
| + denoiser.audioProcessingInitialize(sampleRate: 48_000, channels: 1) |
| + _ = run(denoiser, count: 960) |
| + XCTAssertEqual(fake.calls, 2) |
| + } |
| + |
| + func testAPartFrameIsPassedThroughUntouched() { |
| + let (denoiser, fake, _) = adapter() |
| + denoiser.audioProcessingInitialize(sampleRate: 48_000, channels: 1) |
| + let out = run(denoiser, count: 441) |
| + XCTAssertEqual(fake.calls, 0) |
| + XCTAssertEqual(out, [Float](repeating: 1000, count: 441)) |
| + } |
| + |
| + func testOtherRatesPassThroughAndSaySo() { |
| + for rate in [8_000, 16_000, 32_000, 44_100] { |
| + let (denoiser, fake, _) = adapter() |
| + denoiser.audioProcessingInitialize(sampleRate: rate, channels: 1) |
| + let out = run(denoiser, count: rate / 100) |
| + XCTAssertEqual(fake.calls, 0, "\(rate) Hz") |
| + XCTAssertEqual(out, [Float](repeating: 1000, count: rate / 100)) |
| + XCTAssertEqual(denoiser.state, .unsupportedRate(rate)) |
| + } |
| + } |
| + |
| + func testMoreThanOneChannelPassesThrough() { |
| + let (denoiser, fake, _) = adapter() |
| + denoiser.audioProcessingInitialize(sampleRate: 48_000, channels: 2) |
| + _ = run(denoiser, count: 480) |
| + XCTAssertEqual(fake.calls, 0) |
| + } |
| + |
| + func testNothingIsProcessedBeforeInitialiseOrAfterRelease() { |
| + let (denoiser, fake, _) = adapter() |
| + _ = run(denoiser, count: 480) |
| + XCTAssertEqual(fake.calls, 0, "before initialise") |
| + denoiser.audioProcessingInitialize(sampleRate: 48_000, channels: 1) |
| + _ = run(denoiser, count: 480) |
| + denoiser.audioProcessingRelease() |
| + _ = run(denoiser, count: 480) |
| + XCTAssertEqual(fake.calls, 1, "after release") |
| + // Released is not freed: WebRTC reinitialises whenever processing is |
| + // reconfigured, and the same suppressor carries on. |
| + denoiser.audioProcessingInitialize(sampleRate: 48_000, channels: 1) |
| + _ = run(denoiser, count: 480) |
| + XCTAssertEqual(fake.calls, 2) |
| + } |
| + |
| + func testRateChangesAreReportedOnceEach() { |
| + let (denoiser, _, _) = adapter() |
| + let seen = LockedRates() |
| + denoiser.rateChanged = { seen.append($0) } |
| + denoiser.audioProcessingInitialize(sampleRate: 48_000, channels: 1) |
| + denoiser.audioProcessingInitialize(sampleRate: 48_000, channels: 1) |
| + denoiser.audioProcessingInitialize(sampleRate: 16_000, channels: 1) |
| + XCTAssertEqual(seen.rates, [48_000, 16_000]) |
| + } |
| + |
| + func testTheToggleAndVoiceIsolationBothPassThrough() { |
| + let (denoiser, fake, _) = adapter() |
| + denoiser.audioProcessingInitialize(sampleRate: 48_000, channels: 1) |
| + denoiser.enabled = false |
| + XCTAssertEqual(run(denoiser, count: 480).first, 1000) |
| + XCTAssertEqual(denoiser.state, .off) |
| + denoiser.enabled = true |
| + denoiser.standAside = true |
| + XCTAssertEqual(run(denoiser, count: 480).first, 1000) |
| + XCTAssertEqual(denoiser.state, .standingAside) |
| + XCTAssertEqual(fake.calls, 0) |
| + } |
| + |
| + /// A call starts on RNNoise and moves to DeepFilterNet once its model |
| + /// has loaded; the next buffer goes to the new model. |
| + func testTheModelCanBeReplacedBetweenBuffers() { |
| + let (denoiser, first, _) = adapter() |
| + denoiser.audioProcessingInitialize(sampleRate: 48_000, channels: 1) |
| + _ = run(denoiser, count: 480) |
| + let second = FakeSuppressor() |
| + denoiser.replace(with: second) |
| + _ = run(denoiser, count: 960) |
| + XCTAssertEqual(first.calls, 1) |
| + XCTAssertEqual(second.calls, 2) |
| + XCTAssertEqual(denoiser.framesProcessed, 3) |
| + } |
| + |
| + /// WebRTC stops calling the hook while the microphone is muted. The |
| + /// readout must then stop claiming the suppressor is running. |
| + func testMutedReadsAsIdleOnceFramesStop() { |
| + let (denoiser, _, clock) = adapter() |
| + denoiser.audioProcessingInitialize(sampleRate: 48_000, channels: 1) |
| + XCTAssertEqual(denoiser.state, .idle(engine: "Fake")) |
| + _ = run(denoiser, count: 480) |
| + guard case .running(let engine, _) = denoiser.state else { |
| + return XCTFail("expected running, got \(denoiser.state)") |
| + } |
| + XCTAssertEqual(engine, "Fake") |
| + clock.now += 2 |
| + XCTAssertEqual(denoiser.state, .idle(engine: "Fake")) |
| + } |
| +} |
| + |
| +private final class LockedRates: @unchecked Sendable { |
| + private let lock = NSLock() |
| + private var values: [Int] = [] |
| + func append(_ rate: Int) { lock.withLock { values.append(rate) } } |
| + var rates: [Int] { lock.withLock { values } } |
| +} |
| + |
| +final class SpeechGainTests: XCTestCase { |
| + private func frame(_ amplitude: Float) -> [Float] { |
| + (0..<480).map { amplitude * sin(Float($0) * 2 * .pi * 200 / 48_000) } |
| + } |
| + |
| + func testQuietSpeechIsLiftedSlowlyAndNoMoreThanTwelveDecibels() { |
| + var gain = SpeechGain() |
| + for _ in 0..<5_000 { |
| + var f = frame(100) // about -53 dBFS |
| + f.withUnsafeMutableBufferPointer { gain.apply($0.baseAddress!, count: 480, speech: 1) } |
| + } |
| + XCTAssertEqual(gain.gainDb, SpeechGain.maxGainDb, accuracy: 0.01) |
| + var one = SpeechGain() |
| + var f = frame(100) |
| + f.withUnsafeMutableBufferPointer { one.apply($0.baseAddress!, count: 480, speech: 1) } |
| + XCTAssertLessThanOrEqual(one.gainDb, 0.011, "one frame moves it by at most 0.01 dB") |
| + } |
| + |
| + func testNoiseIsNeverLiftedAndNothingIsAttenuated() { |
| + var gain = SpeechGain() |
| + for _ in 0..<1_000 { |
| + var f = frame(100) |
| + f.withUnsafeMutableBufferPointer { gain.apply($0.baseAddress!, count: 480, speech: 0.1) } |
| + } |
| + XCTAssertEqual(gain.gainDb, 0) |
| + var loud = SpeechGain() |
| + var f = frame(30_000) |
| + let before = f |
| + f.withUnsafeMutableBufferPointer { loud.apply($0.baseAddress!, count: 480, speech: 1) } |
| + XCTAssertEqual(f, before, "a loud voice is left alone") |
| + } |
| + |
| + func testTheLiftNeverClips() { |
| + var gain = SpeechGain() |
| + for _ in 0..<5_000 { |
| + var f = frame(100) |
| + f.withUnsafeMutableBufferPointer { gain.apply($0.baseAddress!, count: 480, speech: 1) } |
| + } |
| + var burst = frame(29_000) |
| + burst.withUnsafeMutableBufferPointer { gain.apply($0.baseAddress!, count: 480, speech: 1) } |
| + XCTAssertLessThanOrEqual(burst.map(abs).max()!, 0.9 * 32_768 + 1) |
| + } |
| +} |
| + |
| +final class ProcessingPlanTests: XCTestCase { |
| + func testMacRunsOursInPlaceOfWebRTCsSuppressorAndGain() { |
| + let plan = ProcessingPlan.plan(iPhone: false, reduceNoise: true, rate: 48_000, voiceIsolation: false) |
| + XCTAssertTrue(plan.suppressor) |
| + XCTAssertEqual(plan.noiseSuppression, .off) |
| + XCTAssertEqual(plan.gainControl, .off, "WebRTC's gain runs before ours and would pump the noise") |
| + XCTAssertEqual(plan.echoCancellation, .software) |
| + XCTAssertTrue(plan.highPass) |
| + } |
| + |
| + func testAnUnsupportedRateHandsBackToWebRTCsSuppressor() { |
| + let plan = ProcessingPlan.plan(iPhone: false, reduceNoise: true, rate: 16_000, voiceIsolation: false) |
| + XCTAssertFalse(plan.suppressor) |
| + XCTAssertEqual(plan.noiseSuppression, .software) |
| + XCTAssertEqual(plan.gainControl, .software) |
| + } |
| + |
| + func testOffMeansNoSuppressionButWebRTCsGain() { |
| + let plan = ProcessingPlan.plan(iPhone: false, reduceNoise: false, rate: 48_000, voiceIsolation: false) |
| + XCTAssertFalse(plan.suppressor) |
| + XCTAssertEqual(plan.noiseSuppression, .off) |
| + XCTAssertEqual(plan.gainControl, .software) |
| + } |
| + |
| + func testVoiceIsolationIsNeverStackedWith() { |
| + for iPhone in [false, true] { |
| + let plan = ProcessingPlan.plan(iPhone: iPhone, reduceNoise: true, rate: 48_000, voiceIsolation: true) |
| + XCTAssertFalse(plan.suppressor) |
| + } |
| + } |
| + |
| + func testIPhoneKeepsApplesCoupledEchoCancellerAndDropsItsGainForOurs() { |
| + let plan = ProcessingPlan.plan(iPhone: true, reduceNoise: true, rate: 48_000, voiceIsolation: false) |
| + XCTAssertTrue(plan.suppressor) |
| + XCTAssertEqual(plan.echoCancellation, .platform) |
| + XCTAssertEqual(plan.noiseSuppression, .platform) |
| + XCTAssertEqual(plan.gainControl, .off) |
| + } |
| + |
| + /// The rule, across every combination: ours never runs beside WebRTC's |
| + /// software suppressor, and never behind WebRTC's gain control. |
| + func testOursNeverStacksWithWebRTCsSuppressorOrFollowsItsGain() { |
| + for iPhone in [false, true] { |
| + for reduce in [false, true] { |
| + for rate in [nil, 8_000, 16_000, 48_000] as [Int?] { |
| + for isolation in [false, true] { |
| + let plan = ProcessingPlan.plan( |
| + iPhone: iPhone, reduceNoise: reduce, rate: rate, voiceIsolation: isolation) |
| + if plan.suppressor { |
| + XCTAssertNotEqual(plan.noiseSuppression, .software) |
| + XCTAssertEqual(plan.gainControl, .off) |
| + } |
| + } |
| + } |
| + } |
| + } |
| + } |
| +} |
| + |
| +/// Real speech (macOS's own synthesiser, normalised to -23 dBFS) with |
| +/// noise mixed in, through each real suppressor. |
| +/// |
| +/// Two claims, measured separately because a suppressor can satisfy |
| +/// either by failing the other: the noise-only lead-in is made much |
| +/// quieter, and the speech is not. |
| +final class SuppressorFixtureTests: XCTestCase { |
| + static let rate = 48_000 |
| + |
| + static func speech() throws -> [Float] { |
| + let url = try XCTUnwrap(Bundle.module.url(forResource: "Fixtures/speech-48k", withExtension: "wav")) |
| + let data = try Data(contentsOf: url) |
| + // A canonical 44-byte header, mono 16-bit at 48 kHz, as written. |
| + XCTAssertEqual(data.count > 44, true) |
| + return data.dropFirst(44).withUnsafeBytes { raw in |
| + raw.bindMemory(to: Int16.self).map { Float(Int16(littleEndian: $0)) } |
| + } |
| + } |
| + |
| + /// Broadband hiss (a fan, a room) at about -35 dBFS, plus a key click |
| + /// every 180 ms. Seeded, so every run hears the same room. |
| + static func noise(count: Int) -> [Float] { |
| + var seed: UInt32 = 0x2545_F491 |
| + var low: Float = 0 |
| + var out = [Float](repeating: 0, count: count) |
| + for i in 0..<count { |
| + seed = seed &* 1_664_525 &+ 1_013_904_223 |
| + let white = Float(Int32(bitPattern: seed)) / Float(Int32.max) |
| + low = 0.7 * low + 0.3 * white |
| + var sample = low * 900 |
| + let sinceClick = i % (rate * 18 / 100) |
| + if sinceClick < 240 { |
| + sample += 6_000 * white * exp(-Float(sinceClick) / 40) |
| + } |
| + out[i] = sample |
| + } |
| + return out |
| + } |
| + |
| + static func db(_ samples: ArraySlice<Float>) -> Double { |
| + let energy = samples.reduce(0.0) { $0 + Double($1) * Double($1) } / Double(max(samples.count, 1)) |
| + return 10 * log10(energy / (32_768.0 * 32_768.0) + 1e-12) |
| + } |
| + |
| + struct Result { |
| + let noiseBefore: Double |
| + let noiseAfter: Double |
| + let speechClean: Double |
| + let speechAfter: Double |
| + let voicedKept: Double |
| + let msPerFrame: Double |
| + } |
| + |
| + static func measure(_ suppressor: NoiseSuppressor) throws -> Result { |
| + let clean = try speech() |
| + let noise = noise(count: clean.count) |
| + var noisy = zip(clean, noise).map { $0 + $1 } |
| + let length = suppressor.frameLength |
| + let frames = noisy.count / length |
| + let started = ProcessInfo.processInfo.systemUptime |
| + noisy.withUnsafeMutableBufferPointer { buffer in |
| + for f in 0..<frames { _ = suppressor.process(buffer.baseAddress! + f * length) } |
| + } |
| + let elapsed = ProcessInfo.processInfo.systemUptime - started |
| + let out = noisy |
| + // The lead-in is noise alone; skip the first 300 ms while the model |
| + // settles. |
| + let lead = (rate * 3 / 10)..<rate |
| + let speechRange = rate..<(frames * length - rate / 2) |
| + // Per-frame: of the frames where the clean voice is clearly present, |
| + // how many came out within 10 dB of it. A model may delay its output |
| + // (DeepFilterNet by three frames), so the best of 0 to 4 frames late |
| + // is taken. |
| + var voiced = 0 |
| + var best = 0 |
| + for lag in 0...4 { |
| + var count = 0 |
| + var kept = 0 |
| + for f in stride(from: speechRange.lowerBound, to: speechRange.upperBound - 5 * length, by: length) { |
| + let reference = db(clean[f..<(f + length)]) |
| + guard reference > -40 else { continue } |
| + count += 1 |
| + let shifted = f + lag * length |
| + if db(out[shifted..<(shifted + length)]) > reference - 10 { kept += 1 } |
| + } |
| + voiced = count |
| + best = max(best, kept) |
| + } |
| + let kept = best |
| + return Result( |
| + noiseBefore: db(noise[lead]), noiseAfter: db(out[lead]), |
| + speechClean: db(clean[speechRange]), speechAfter: db(out[speechRange]), |
| + voicedKept: Double(kept) / Double(max(voiced, 1)), |
| + msPerFrame: elapsed * 1000 / Double(frames)) |
| + } |
| + |
| + static func check(_ result: Result, name: String, minimumReduction: Double) { |
| + print( |
| + "\(name): noise \(String(format: "%.1f", result.noiseBefore)) -> \(String(format: "%.1f", result.noiseAfter)) dBFS;" |
| + + " speech \(String(format: "%.1f", result.speechClean)) -> \(String(format: "%.1f", result.speechAfter)) dBFS;" |
| + + " voiced frames kept \(Int(result.voicedKept * 100))%;" |
| + + " \(String(format: "%.3f", result.msPerFrame)) ms per 10 ms frame") |
| + XCTAssertLessThan( |
| + result.noiseAfter, result.noiseBefore - minimumReduction, |
| + "\(name) should take at least \(minimumReduction) dB off noise alone") |
| + XCTAssertGreaterThan( |
| + result.speechAfter, result.speechClean - 6, "\(name) must not take the speech with it") |
| + XCTAssertGreaterThan(result.voicedKept, 0.8, "\(name) must keep the voiced frames") |
| + } |
| + |
| + func testRNNoiseQuietsNoiseAndKeepsSpeech() throws { |
| + let result = try Self.measure(RNNoiseSuppressor()) |
| + Self.check(result, name: "RNNoise", minimumReduction: 10) |
| + XCTAssertGreaterThan(result.noiseAfter, result.noiseBefore - 40, "the 30 dB limit holds") |
| + } |
| + |
| + #if canImport(DeepFilter) |
| + /// The model is a build product (scripts/build-deepfilter.sh), found |
| + /// beside the xcframework. |
| + static var deepFilterModel: URL { |
| + URL(fileURLWithPath: #filePath) |
| + .deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent() |
| + .deletingLastPathComponent().appendingPathComponent("build/DeepFilterNet3_onnx.tar.gz") |
| + } |
| + |
| + func testDeepFilterQuietsNoiseAndKeepsSpeech() throws { |
| + let started = ProcessInfo.processInfo.systemUptime |
| + let deep = try XCTUnwrap(DeepFilterSuppressor(model: Self.deepFilterModel), "run build-deepfilter.sh") |
| + print("DeepFilterNet 3 model loaded in \(Int((ProcessInfo.processInfo.systemUptime - started) * 1000)) ms") |
| + XCTAssertEqual(deep.frameLength, 480) |
| + let result = try Self.measure(deep) |
| + Self.check(result, name: "DeepFilterNet 3", minimumReduction: 10) |
| + XCTAssertGreaterThan(result.noiseAfter, result.noiseBefore - 40, "the 30 dB limit holds") |
| + } |
| + |
| + /// libDF wants ±1; WebRTC hands over ±32768. A full-scale voice must |
| + /// come back at full scale, not 32768 times louder or quieter. |
| + func testDeepFilterScalesInAndOut() throws { |
| + let deep = try XCTUnwrap(DeepFilterSuppressor(model: Self.deepFilterModel)) |
| + let clean = try Self.speech() |
| + var loudest: Float = 0 |
| + var inputLoudest: Float = 0 |
| + var samples = Array(clean.prefix(480 * 300)) |
| + inputLoudest = samples.map(abs).max()! |
| + samples.withUnsafeMutableBufferPointer { buffer in |
| + for f in 0..<300 { _ = deep.process(buffer.baseAddress! + f * 480) } |
| + } |
| + loudest = samples.map(abs).max()! |
| + XCTAssertGreaterThan(loudest, inputLoudest / 4, "output is in WebRTC's scale") |
| + XCTAssertLessThan(loudest, inputLoudest * 4, "output is in WebRTC's scale") |
| + } |
| + |
| + func testAMissingModelIsNoSuppressorRatherThanACrash() { |
| + XCTAssertNil(DeepFilterSuppressor(model: URL(fileURLWithPath: "/nonexistent/model.tar.gz"))) |
| + } |
| + #endif |
| +} |
diff --git a/native/ios/Sources/CallsPhone.swift b/native/ios/Sources/CallsPhone.swift| index 6bad8fd..6613f09 100644 |
| --- a/native/ios/Sources/CallsPhone.swift |
| +++ b/native/ios/Sources/CallsPhone.swift |
| @@ -1,6 +1,6 @@ |
| import CallsKit |
| import SwiftUI |
| -import WebRTC |
| +import LiveKitWebRTC |
| /// The delegate exists for one reason: Apple hands a device token to |
| /// the application delegate and nowhere else, so a pure SwiftUI app has |
diff --git a/native/ios/Tests/CallsPhoneTests.swift b/native/ios/Tests/CallsPhoneTests.swift| index 2e9e28a..b9bece0 100644 |
| --- a/native/ios/Tests/CallsPhoneTests.swift |
| +++ b/native/ios/Tests/CallsPhoneTests.swift |
| @@ -1,5 +1,5 @@ |
| import CallsKit |
| -import WebRTC |
| +import LiveKitWebRTC |
| import XCTest |
| final class CallsPhoneTests: XCTestCase { |
diff --git a/native/ios/Tests/SilentAudioDevice.swift b/native/ios/Tests/SilentAudioDevice.swift| index 0ef363b..a0538d2 100644 |
| --- a/native/ios/Tests/SilentAudioDevice.swift |
| +++ b/native/ios/Tests/SilentAudioDevice.swift |
| @@ -1,5 +1,6 @@ |
| +import CallsKit |
| import Foundation |
| -import WebRTC |
| +import LiveKitWebRTC |
| // Test-only audio device. No samples are captured or rendered. WebRTC invokes |
| // this protocol serially on its audio thread, as specified by RTCAudioDevice. |
diff --git a/native/ios/project.yml b/native/ios/project.yml| index 185319d..7b6766a 100644 |
| --- a/native/ios/project.yml |
| +++ b/native/ios/project.yml |
| @@ -21,6 +21,8 @@ targets: |
| - Assets.xcassets |
| - path: ../LICENSE.lucide |
| buildPhase: resources |
| + - path: ../LICENSE.rnnoise |
| + buildPhase: resources |
| info: |
| path: ../build/ios-info.plist |
| properties: |
diff --git a/native/scripts/build-deepfilter.sh b/native/scripts/build-deepfilter.sh| new file mode 100755 |
| index 0000000..59e190e |
| --- /dev/null |
| +++ b/native/scripts/build-deepfilter.sh |
| @@ -0,0 +1,71 @@ |
| +#!/bin/sh |
| +# Builds native/build/DeepFilter.xcframework and copies the DeepFilterNet 3 |
| +# model beside it: Rikorose/DeepFilterNet's libDF (MIT or Apache-2.0) with |
| +# its C API, as a static library for Mac, iPhone and the iPhone simulator |
| +# (Apple silicon). |
| +# |
| +# Built rather than committed, as CallsBridge and RNNoise are. Needs rustup |
| +# (https://rustup.rs); the toolchain, its targets and cbindgen are pinned |
| +# and installed on first run. |
| +set -eu |
| +cd "$(dirname "$0")/.." |
| + |
| +commit=d375b2d8309e0935d165700c91da9de862a99c31 |
| +toolchain=1.92.0 |
| +cbindgen_version=0.29.2 |
| +out=build/DeepFilter.xcframework |
| +model=build/DeepFilterNet3_onnx.tar.gz |
| +stamp="$out/.built-$commit-$toolchain" |
| +if [ -f "$stamp" ] && [ -f "$model" ]; then exit 0; fi |
| + |
| +PATH="$HOME/.cargo/bin:$PATH" |
| +command -v rustup >/dev/null || { |
| + echo "build-deepfilter: rustup is required (https://rustup.rs)" >&2 |
| + exit 1 |
| +} |
| +targets="aarch64-apple-darwin aarch64-apple-ios aarch64-apple-ios-sim" |
| +# shellcheck disable=SC2086 |
| +rustup toolchain install "$toolchain" --profile minimal $(printf ' -t %s' $targets) >/dev/null |
| +if [ "$(cbindgen --version 2>/dev/null)" != "cbindgen $cbindgen_version" ]; then |
| + cargo "+$toolchain" install cbindgen --version "$cbindgen_version" --locked |
| +fi |
| + |
| +work=build/deepfilter |
| +mkdir -p "$work" |
| +if [ ! -d "$work/src/.git" ]; then |
| + rm -rf "$work/src" |
| + git clone --quiet https://github.com/Rikorose/DeepFilterNet.git "$work/src" |
| +fi |
| +git -C "$work/src" -c advice.detachedHead=false checkout --quiet --force "$commit" |
| + |
| +# Deployment targets matching the apps, so the linker does not warn that |
| +# the library was built for newer systems than they run on. |
| +export MACOSX_DEPLOYMENT_TARGET=14.0 IPHONEOS_DEPLOYMENT_TARGET=17.0 |
| +for target in $targets; do |
| + cargo "+$toolchain" build --manifest-path "$work/src/Cargo.toml" -p deep_filter --lib \ |
| + --release --features capi --target "$target" --target-dir "$work/target" |
| +done |
| + |
| +headers="$work/headers" |
| +rm -rf "$headers" |
| +mkdir -p "$headers/DeepFilter" |
| +cbindgen --config "$work/src/cbindgen.toml" --crate deep_filter \ |
| + --output "$headers/DeepFilter/deep_filter.h" "$work/src/libDF" |
| +cat >"$headers/DeepFilter/module.modulemap" <<'EOF' |
| +module DeepFilter { |
| + header "deep_filter.h" |
| + link "df" |
| + export * |
| +} |
| +EOF |
| + |
| +rm -rf "$out" |
| +xcodebuild -create-xcframework \ |
| + -library "$work/target/aarch64-apple-darwin/release/libdf.a" -headers "$headers" \ |
| + -library "$work/target/aarch64-apple-ios/release/libdf.a" -headers "$headers" \ |
| + -library "$work/target/aarch64-apple-ios-sim/release/libdf.a" -headers "$headers" \ |
| + -output "$out" >/dev/null |
| +cp "$work/src/models/DeepFilterNet3_onnx.tar.gz" "$model" |
| +cp "$work/src/LICENSE-MIT" "$out/LICENSE-MIT" |
| +touch "$stamp" |
| +printf 'Built %s/%s\n' "$PWD" "$out" |
diff --git a/native/scripts/build-iphone.sh b/native/scripts/build-iphone.sh| index 3de8608..3910137 100755 |
| --- a/native/scripts/build-iphone.sh |
| +++ b/native/scripts/build-iphone.sh |
| @@ -2,6 +2,8 @@ |
| set -eu |
| cd "$(dirname "$0")/.." |
| ./scripts/build-bridge.sh |
| +./scripts/build-rnnoise.sh |
| +./scripts/build-deepfilter.sh |
| xcodegen generate --spec ios/project.yml |
| case "${1:-simulator}" in |
| simulator) |
diff --git a/native/scripts/build-mac.sh b/native/scripts/build-mac.sh| index 12a3634..1a8b436 100755 |
| --- a/native/scripts/build-mac.sh |
| +++ b/native/scripts/build-mac.sh |
| @@ -1,7 +1,7 @@ |
| #!/bin/sh |
| set -eu |
| cd "$(dirname "$0")/.." |
| -if [ "${OFICINA_SKIP_PREPARE:-0}" != 1 ]; then ./scripts/build-bridge.sh; fi |
| +if [ "${OFICINA_SKIP_PREPARE:-0}" != 1 ]; then ./scripts/build-bridge.sh; ./scripts/build-rnnoise.sh; ./scripts/build-deepfilter.sh; fi |
| configuration="${OFICINA_BUILD_CONFIGURATION:-debug}" |
| swift build --package-path apple --configuration "$configuration" --product CallsMac |
| python3 scripts/bundle-mac.py "$configuration" |
diff --git a/native/scripts/build-rnnoise.sh b/native/scripts/build-rnnoise.sh| new file mode 100755 |
| index 0000000..91657f5 |
| --- /dev/null |
| +++ b/native/scripts/build-rnnoise.sh |
| @@ -0,0 +1,87 @@ |
| +#!/bin/sh |
| +# Builds native/build/RNNoise.xcframework: xiph's RNNoise (BSD-3) as a |
| +# static library for Mac, iPhone and the iPhone simulator. |
| +# |
| +# Built rather than committed, as CallsBridge is: the model's weights come |
| +# as a generated C file of 78 MB (rnnoise_data.c), which is no more source |
| +# than a framework binary is. Both the code and the model are pinned — the |
| +# code to a commit, the model to its SHA-256, which is also its name. |
| +set -eu |
| +cd "$(dirname "$0")/.." |
| + |
| +commit=70f1d256acd4b34a572f999a05c87bf00b67730d |
| +model=0a8755f8e2d834eff6a54714ecc7d75f9932e845df35f8b59bc52a7cfe6e8b37 |
| +out=build/RNNoise.xcframework |
| +stamp="$out/.built-$commit-$model" |
| +if [ -f "$stamp" ]; then exit 0; fi |
| + |
| +work=build/rnnoise |
| +mkdir -p "$work" |
| +if [ ! -d "$work/src/.git" ]; then |
| + rm -rf "$work/src" |
| + git clone --quiet https://gitlab.xiph.org/xiph/rnnoise.git "$work/src" |
| +fi |
| +git -C "$work/src" -c advice.detachedHead=false checkout --quiet "$commit" |
| + |
| +tarball="$work/rnnoise_data-$model.tar.gz" |
| +if [ ! -f "$tarball" ]; then |
| + curl --fail --silent --show-error --location -o "$tarball.part" \ |
| + "https://media.xiph.org/rnnoise/models/rnnoise_data-$model.tar.gz" |
| + mv "$tarball.part" "$tarball" |
| +fi |
| +echo "$model $tarball" | shasum -a 256 -c --status || { |
| + echo "build-rnnoise: model checksum mismatch; delete $tarball and retry" >&2 |
| + exit 1 |
| +} |
| +tar -xzf "$tarball" -C "$work/src" src/rnnoise_data.c src/rnnoise_data.h |
| + |
| +sources="denoise.c rnn.c pitch.c kiss_fft.c celt_lpc.c nnet.c nnet_default.c parse_lpcnet_weights.c rnnoise_tables.c rnnoise_data.c" |
| + |
| +# One static library per platform, fat across its architectures. |
| +build_slice() { # name sdk "triple triple..." |
| + name=$1 sdk=$2 triples=$3 |
| + root=$(xcrun --sdk "$sdk" --show-sdk-path) |
| + libs="" |
| + for triple in $triples; do |
| + objdir="$work/obj/$name/$triple" |
| + mkdir -p "$objdir" |
| + for f in $sources; do |
| + clang -c -O3 -target "$triple" -isysroot "$root" -fvisibility=hidden -DRNNOISE_BUILD \ |
| + -I"$work/src/include" -I"$work/src/src" "$work/src/src/$f" -o "$objdir/${f%.c}.o" & |
| + done |
| + wait |
| + libtool -static -o "$objdir/librnnoise.a" "$objdir"/*.o |
| + libs="$libs $objdir/librnnoise.a" |
| + done |
| + mkdir -p "$work/lib/$name" |
| + # shellcheck disable=SC2086 |
| + lipo -create $libs -output "$work/lib/$name/librnnoise.a" |
| +} |
| +build_slice macos macosx "arm64-apple-macos14.0 x86_64-apple-macos14.0" |
| +build_slice ios iphoneos "arm64-apple-ios17.0" |
| +build_slice simulator iphonesimulator "arm64-apple-ios17.0-simulator x86_64-apple-ios17.0-simulator" |
| + |
| +# Headers in a subdirectory of their own: Xcode copies every static |
| +# xcframework's headers into one include directory, and two module maps at |
| +# its root collide. |
| +headers="$work/headers" |
| +rm -rf "$headers" |
| +mkdir -p "$headers/RNNoise" |
| +cp "$work/src/include/rnnoise.h" "$headers/RNNoise/" |
| +cat >"$headers/RNNoise/module.modulemap" <<'EOF' |
| +module RNNoise { |
| + header "rnnoise.h" |
| + link "rnnoise" |
| + export * |
| +} |
| +EOF |
| + |
| +rm -rf "$out" |
| +xcodebuild -create-xcframework \ |
| + -library "$work/lib/macos/librnnoise.a" -headers "$headers" \ |
| + -library "$work/lib/ios/librnnoise.a" -headers "$headers" \ |
| + -library "$work/lib/simulator/librnnoise.a" -headers "$headers" \ |
| + -output "$out" >/dev/null |
| +cp "$work/src/COPYING" "$out/COPYING" |
| +touch "$stamp" |
| +printf 'Built %s/%s\n' "$PWD" "$out" |
diff --git a/native/scripts/bundle-mac.py b/native/scripts/bundle-mac.py| index 94c30b7..8b7a1c5 100644 |
| --- a/native/scripts/bundle-mac.py |
| +++ b/native/scripts/bundle-mac.py |
| @@ -19,6 +19,11 @@ subprocess.run(['swift', str(root / 'scripts/generate-mac-icon.swift')], check=T |
| icon = root / 'apple/Sources/CallsMac/Resources/MacIcon.png' |
| shutil.copy2(icon, bundle / 'Resources/MacIcon.png') |
| shutil.copy2(root / 'LICENSE.lucide', bundle / 'Resources/LICENSE.lucide') |
| +shutil.copy2(root / 'LICENSE.rnnoise', bundle / 'Resources/LICENSE.rnnoise') |
| +shutil.copy2(root / 'LICENSE.deepfilternet', bundle / 'Resources/LICENSE.deepfilternet') |
| +# The Mac's noise suppressor model (scripts/build-deepfilter.sh). Without |
| +# it the app falls back to RNNoise. |
| +shutil.copy2(root / 'build/DeepFilterNet3_onnx.tar.gz', bundle / 'Resources/DeepFilterNet3_onnx.tar.gz') |
| # 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' |
| @@ -46,7 +51,7 @@ info = { |
| (bundle / 'Info.plist').write_bytes(plistlib.dumps(info)) |
| # SwiftPM links dynamic binary dependencies using @rpath. Embed just the macOS |
| # slice rather than shipping iOS and simulator frameworks inside the Mac app. |
| -for xc in [root / 'build/CallsBridge.xcframework', *list((root / 'apple/.build/artifacts').rglob('WebRTC.xcframework'))]: |
| +for xc in [root / 'build/CallsBridge.xcframework', *list((root / 'apple/.build/artifacts').rglob('LiveKitWebRTC.xcframework'))]: |
| manifest = plistlib.loads((xc / 'Info.plist').read_bytes()) |
| for lib in manifest['AvailableLibraries']: |
| if lib['SupportedPlatform'] != 'macos': |
diff --git a/test/browser/native.mjs b/test/browser/native.mjs| index e104eec..cee1b62 100644 |
| --- a/test/browser/native.mjs |
| +++ b/test/browser/native.mjs |
| @@ -73,6 +73,15 @@ try { |
| } |
| await wait(() => sample?.stats.framesDecoded > 15 && sample?.stats.audioBytesReceived > 0 && sample?.stats.videoBytesSent > 0 && sample?.stats.audioBytesSent > 0, 'native receives browser audio/video'); |
| console.log('PASS native decoded browser video and received audio'); |
| + // The synthetic microphone runs through WebRTC's capture processing, so |
| + // the noise suppressor in its post-processing hook must have seen frames. |
| + await wait(() => sample?.stats.noiseFramesProcessed > 100, 'native noise suppressor processes captured audio'); |
| + console.log(`PASS native noise suppressor ran on captured audio (${sample.stats.noiseSuppressor === 2 ? 'DeepFilterNet 3' : 'RNNoise'})`); |
| + // And it runs instead of WebRTC's own suppressor and gain, beside its echo canceller. |
| + assert.equal(sample.stats.webrtcNoiseSuppression, 0, "WebRTC's noise suppressor is off"); |
| + assert.equal(sample.stats.webrtcGainControl, 0, "WebRTC's gain control is off"); |
| + assert.equal(sample.stats.webrtcEchoCancellation, 1, "echo cancellation stays on"); |
| + console.log('PASS WebRTC noise suppression and gain off, echo cancellation on'); |
| // Each remote video has its own decoded frame count; local preview alone |
| // cannot satisfy this check. Read the actual room test observation contract. |
| // Remote videos exclude the muted local preview. getVideoPlaybackQuality |