oficina / meet Public

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>
Paul Campbell pushed by paul@keymail.dev 21801219906198bfe3c49cb9fe8fd82e6cd517f0 parent 3846ca4parent 8d0f8a7
33 files changed, +1959 −164
  • Makefile +4 −1
  • native/LICENSE.deepfilternet +22 −0
  • native/LICENSE.rnnoise +32 −0
  • native/README.md +114 −17
  • native/VERIFICATION.md +40 −0
  • native/apple/Package.resolved +4 −4
  • native/apple/Package.swift +12 −3
  • native/apple/Sources/CallsInterop/Interop.swift +1 −1
  • native/apple/Sources/CallsInterop/SyntheticVideo.swift +1 −1
  • native/apple/Sources/CallsInterop/SyntheticVoice.swift +25 −0
  • native/apple/Sources/CallsKit/Audio.swift +83 −72
  • native/apple/Sources/CallsKit/Call.swift +37 −6
  • native/apple/Sources/CallsKit/CallAudio.swift +357 −0
  • native/apple/Sources/CallsKit/Capture.swift +1 −1
  • native/apple/Sources/CallsKit/DevicePanel.swift +53 −22
  • native/apple/Sources/CallsKit/MacVideoTile.swift +1 −1
  • native/apple/Sources/CallsKit/Media.swift +8 −9
  • native/apple/Sources/CallsKit/NoiseSuppression.swift +319 −0
  • native/apple/Sources/CallsKit/WebRTCNames.swift +213 −0
  • native/apple/Sources/CallsMac/CallsMac.swift +1 −1
  • native/apple/Tests/CallsKitTests/AudioHealthTests.swift +24 −20
  • native/apple/Tests/CallsKitTests/Fixtures/speech-48k.wav binary
  • native/apple/Tests/CallsKitTests/NoiseSuppressionTests.swift +425 −0
  • native/ios/Sources/CallsPhone.swift +1 −1
  • native/ios/Tests/CallsPhoneTests.swift +1 −1
  • native/ios/Tests/SilentAudioDevice.swift +2 −1
  • native/ios/project.yml +2 −0
  • native/scripts/build-deepfilter.sh +71 −0
  • native/scripts/build-iphone.sh +2 −0
  • native/scripts/build-mac.sh +1 −1
  • native/scripts/build-rnnoise.sh +87 −0
  • native/scripts/bundle-mac.py +6 −1
  • test/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