Merge branch 'fix/test-audio-beachball' into main
20 files changed,
+1899
−307
Makefile+1 −1internal/callstest/native_selftest_test.go+54 −0native/README.md+49 −3native/apple/Sources/CallsInterop/AudioStartStallHarness.swift+121 −0native/apple/Sources/CallsInterop/Interop.swift+3 −0native/apple/Sources/CallsInterop/MainThreadWatchdog.swift+91 −0native/apple/Sources/CallsKit/Audio.swift+79 −22native/apple/Sources/CallsKit/AudioSelfTest.swift+108 −0native/apple/Sources/CallsKit/AudioSelfTestLoopback.swift+52 −49native/apple/Sources/CallsKit/AudioSelfTestModel.swift+153 −51native/apple/Sources/CallsKit/AudioSelfTestView.swift+16 −4native/apple/Sources/CallsKit/AudioStartProbe.swift+141 −0native/apple/Sources/CallsKit/AudioWork.swift+258 −0native/apple/Sources/CallsKit/Call.swift+39 −12native/apple/Sources/CallsKit/CallAudio.swift+330 −63native/apple/Sources/CallsKit/Media.swift+173 −98native/apple/Sources/CallsMac/CallsMac.swift+1 −0native/apple/Tests/CallsKitTests/AudioRoutesTests.swift+33 −1native/apple/Tests/CallsKitTests/AudioSelfTestTests.swift+196 −2native/ios/project.yml+1 −1
CI passed — run details
diff --git a/Makefile b/Makefile| index eb52588..f14d226 100644 |
| --- a/Makefile |
| +++ b/Makefile |
| @@ -173,7 +173,7 @@ native-browser: native-mac |
| # 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 10m -run 'TestNative(MacCallsBrowser|MacChat|MacScreenMotion|BrowserAuthentication|MacAudioSelfTest)' -v ./internal/callstest/ |
| + CALLS_DEEPFILTER_MODEL=$(CURDIR)/native/build/DeepFilterNet3_onnx.tar.gz CGO_ENABLED=0 go test -tags browser -count=1 -timeout 10m -run 'TestNative(MacCallsBrowser|MacChat|MacScreenMotion|BrowserAuthentication|MacAudioSelfTest|MacAudioStartStall)' -v ./internal/callstest/ |
| # Two apps in one call, sharing screens to each other. Separate from |
| # native-browser because two unsigned apps on one Mac do not reliably |
diff --git a/internal/callstest/native_selftest_test.go b/internal/callstest/native_selftest_test.go| index 402f50f..6586525 100644 |
| --- a/internal/callstest/native_selftest_test.go |
| +++ b/internal/callstest/native_selftest_test.go |
| @@ -69,3 +69,57 @@ func TestNativeMacAudioSelfTest(t *testing.T) { |
| } |
| } |
| } |
| + |
| +// The main thread is never held longer than 100 ms while the audio |
| +// starts: Test audio before a call (plain, with a Bluetooth headset's |
| +// profile switch simulated as a 1.5 s hold of the engine's start on |
| +// WebRTC's worker thread, and closed partway through that start), and a |
| +// call's audio as a join starts it, with the same switch. A watchdog in |
| +// CallsInterop times the main queue every 5 ms, on the real engine |
| +// module; a test process has no microphone permission, so the module |
| +// refuses at the very end, after everything that used to block. |
| +// |
| +// 0.3.8 held it 0.1–0.7 s for the plain start and about 1.5 s with the |
| +// switch (Paul's "pause, then a beachball", 2026-09-27). A control run |
| +// blocks the main thread on purpose and must be seen, so a pass means the |
| +// watchdog was looking. Five runs, five PASS lines. |
| +func TestNativeMacAudioStartStall(t *testing.T) { |
| + root, err := filepath.Abs("../..") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + binary := filepath.Join(root, "native/apple/.build/debug/CallsInterop") |
| + if _, err := os.Stat(binary); err != nil { |
| + t.Fatal("build native interop executable with swift build --package-path native/apple --product CallsInterop first") |
| + } |
| + ctx, cancel := context.WithTimeout(t.Context(), 90*time.Second) |
| + defer cancel() |
| + out, err := exec.CommandContext(ctx, binary, "--audio-start-stall").CombinedOutput() |
| + var passes, fails []string |
| + for _, line := range strings.Split(string(out), "\n") { |
| + switch { |
| + case strings.HasPrefix(line, "PASS "): |
| + passes = append(passes, line) |
| + case strings.HasPrefix(line, "FAIL "): |
| + fails = append(fails, line) |
| + } |
| + } |
| + for _, line := range append(passes, fails...) { |
| + t.Log(line) |
| + } |
| + if err != nil || len(fails) > 0 || len(passes) != 5 { |
| + t.Fatalf("audio start stall: %d passed, %d failed, exit %v\n%s", len(passes), len(fails), err, out) |
| + } |
| + for _, want := range []string{ |
| + "audio start stall control", "PASS Test audio start:", "Test audio start, Bluetooth switch simulated", |
| + "Test audio closed while its microphone starts", "Call join audio start, Bluetooth switch simulated", |
| + } { |
| + found := false |
| + for _, line := range passes { |
| + found = found || strings.Contains(line, want) |
| + } |
| + if !found { |
| + t.Errorf("no PASS line for %s", want) |
| + } |
| + } |
| +} |
diff --git a/native/README.md b/native/README.md| index a6cd8bc..459ee6f 100644 |
| --- a/native/README.md |
| +++ b/native/README.md |
| @@ -301,6 +301,35 @@ also runs the start on the real device module, with 0.3.7's start as a control |
| that must never ask for the microphone. Calls themselves were not affected: |
| their connections are connected. |
| +**Nothing in the audio start runs on the main thread** (0.3.9). Paul's |
| +0.3.8, on a MacBook Air with AirPods Pro: "after I open the Test window, |
| +there's a pause, then a beachball, then it works." A main-thread watchdog put |
| +numbers on it: building the factory and its engine module (100–135 ms) and |
| +logging the module's first device list (61–68 ms); applying the loopback's |
| +answer, which waits for the engine's playout to start (188–229 ms); and |
| +`initRecording`, which waits while the engine starts with the microphone — for |
| +a Bluetooth headset, while it moves from A2DP to its hands-free profile, one to |
| +two seconds. Every call into WebRTC's objects is a blocking hop to its own |
| +threads, and while the worker thread is starting the engine every one of them |
| +waits: the module's `isRecording`, `isPlaying` and device lists, the factory's |
| +processing state, a track's `isEnabled` (mute) and its processing options, |
| +0.9–1.2 s each with the switch simulated. So all of it runs on one serial |
| +queue (`AudioWork`), in the order asked; the main thread reads the module's |
| +state from `ModuleState`, which waits at most 5 ms and otherwise gives the last |
| +answer. The window opens at once and says **Starting microphone…** beside a |
| +spinner — **Switching Paul's AirPods Pro to microphone mode…** for a Bluetooth |
| +headset not yet at its hands-free rate, because that wait is physics, not a |
| +fault — and the meter comes alive when the microphone has started. Closing it |
| +partway cancels cleanly: the stop is queued behind the start, so what the |
| +start opens is closed (`MicStart`). A call's join builds its media the same |
| +way (`MediaEngine.make`), and its connections' descriptions, candidates, |
| +statistics, sender parameters and closes go through the same queue |
| +(`PeerCalls`). Measured by `CallsInterop --audio-start-stall` on an M1 Max, |
| +longest main-thread stall, before → after: Test audio 142–716 ms → 0–5 ms; |
| +with a 1.5 s Bluetooth switch simulated 1,536–1,657 ms → 1–7 ms; closed |
| +mid-start → 0–8 ms; a call's audio start with the switch 1,322–1,514 ms → |
| +21–43 ms. |
| + |
| The verdict names the device the microphone really is, with its connection |
| and rate ("Paul's AirPods Pro (Bluetooth, 24 kHz)"), and tells apart "no sound |
| is arriving" (no buffers at all: the microphone was asked for and nothing came |
| @@ -389,9 +418,18 @@ microphone really capturing at 16 kHz brings WebRTC's own suppressor back. |
| (`--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 |
| + The model takes about 0.3 s to load (on an M1 Max; longer on an Air), so |
| + one is loaded three seconds after launch, at utility priority, and the |
| + first Test audio or call takes it ready-made (`DeepFilterPreload`). A |
| + loaded model is about 88 MB resident, so only one is kept spare: taking it |
| + does not load another at once (a call would then hold two), a new spare is |
| + loaded when its user is done, and a spare nobody has taken for fifteen |
| + minutes is let go. Without a spare a call starts on RNNoise and moves to |
| + DeepFilterNet when one has loaded, off the main thread, as before; a model |
| + is never handed from one user to the next, because its state holds the |
| + last audio it heard and libDF has no way to clear it. A missing model |
| + leaves it on RNNoise. (RNNoise itself is made in under a millisecond and is |
| + not preloaded.) `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 |
| @@ -627,6 +665,14 @@ make native-pair |
| CGO_ENABLED=0 go test -tags browser -run TestNativeBrowserAuthentication ./internal/callstest |
| ``` |
| +`TestNativeMacAudioStartStall` (in `make native-browser`) runs |
| +`CallsInterop --audio-start-stall`: a watchdog posts to the main queue every |
| +5 ms and times the answer while Test audio starts (plainly, with a 1.5 s |
| +Bluetooth switch simulated by holding the engine's start on WebRTC's worker |
| +thread, and closed partway through) and while a call's audio starts with the |
| +same switch, all on the real engine module. Each must hold the main thread |
| +under 100 ms; a control that blocks it for 300 ms on purpose must be seen. |
| + |
| `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 |
diff --git a/native/apple/Sources/CallsInterop/AudioStartStallHarness.swift b/native/apple/Sources/CallsInterop/AudioStartStallHarness.swift| new file mode 100644 |
| index 0000000..e299163 |
| --- /dev/null |
| +++ b/native/apple/Sources/CallsInterop/AudioStartStallHarness.swift |
| @@ -0,0 +1,121 @@ |
| +@_spi(Fixtures) import CallsKit |
| +import Foundation |
| + |
| +/// `CallsInterop --audio-start-stall`: the main thread is never held |
| +/// longer than `limit` while the audio starts — Test audio before a call, |
| +/// and a call's audio as a join starts it — on the real engine module. |
| +/// |
| +/// 0.3.8 held it for a pause and then a beachball (Paul's AirPods, |
| +/// 2026-09-27): building the factory, applying the loopback's answer, and |
| +/// `initRecording` waiting out the headset's switch to its hands-free |
| +/// profile, all on the main thread. The switch is stood in for by holding |
| +/// the engine's start on WebRTC's worker thread for 1.5 s |
| +/// (AudioStartProbe.engineStartDelay), where a real one holds it. |
| +/// |
| +/// First a control: a deliberate 300 ms block of the main thread must be |
| +/// seen, so a pass means the watchdog was looking. Prints one PASS or FAIL |
| +/// line per run, with the longest stall; exits 0 only if all passed. |
| +@MainActor enum AudioStartStallHarness { |
| + static let limit = 0.1 |
| + static let switchDelay = 1.5 |
| + |
| + static func run() async -> Bool { |
| + let dog = MainThreadWatchdog() |
| + dog.start() |
| + try? await Task.sleep(for: .milliseconds(200)) |
| + var passed = await control(dog) |
| + |
| + passed = await testAudio(dog, label: "Test audio start", delay: 0) && passed |
| + passed = await testAudio(dog, label: "Test audio start, Bluetooth switch simulated", delay: switchDelay) && passed |
| + passed = await testAudio( |
| + dog, label: "Test audio closed while its microphone starts", delay: switchDelay, closeAfter: 0.3) && passed |
| + passed = await callJoin(dog) && passed |
| + _ = dog.stop() |
| + return passed |
| + } |
| + |
| + private static func control(_ dog: MainThreadWatchdog) async -> Bool { |
| + let label = "audio start stall control (the watchdog sees a 300 ms block)" |
| + dog.reset() |
| + Thread.sleep(forTimeInterval: 0.3) |
| + // The watchdog's posts answer once the main thread is free again. |
| + try? await Task.sleep(for: .milliseconds(50)) |
| + let seen = dog.longestSoFar |
| + if seen >= 0.25 { |
| + print("PASS \(label): saw \(ms(seen))") |
| + return true |
| + } |
| + print("FAIL \(label): saw only \(ms(seen))") |
| + return false |
| + } |
| + |
| + private static func testAudio( |
| + _ dog: MainThreadWatchdog, label: String, delay: TimeInterval, closeAfter: Double? = nil |
| + ) async -> Bool { |
| + AudioStartProbe.engineStartDelay = delay |
| + defer { AudioStartProbe.engineStartDelay = 0 } |
| + await settle() |
| + dog.reset() |
| + let report = await AudioStartProbe.testAudio(closeAfter: closeAfter) |
| + let stall = dog.longestSoFar |
| + var problems: [String] = [] |
| + if stall > limit { problems.append("main thread held \(ms(stall)), limit \(ms(limit))") } |
| + let firstShown = report.phases.first { $0.phase != .idle } |
| + if let firstShown, firstShown.at > limit { |
| + problems.append("the window said nothing for \(ms(firstShown.at))") |
| + } |
| + if firstShown == nil { problems.append("the window never said it was starting") } |
| + let starting = report.phases.first { if case .starting = $0.phase { return true } else { return false } } |
| + let ended = report.phases.last { $0.phase == .running || $0.phase == .failed || $0.phase == .cancelled } |
| + if closeAfter == nil { |
| + if !report.asked { problems.append("the module was never asked for the microphone") } |
| + if delay > 0, let starting, let ended, ended.at - starting.at < delay * 0.9 { |
| + problems.append( |
| + "'Starting microphone…' showed for \(ms(ended.at - starting.at)), not for the \(ms(delay)) switch") |
| + } |
| + } else if ended?.phase != .cancelled { |
| + problems.append("closing partway left the phase at \(String(describing: ended?.phase))") |
| + } |
| + if report.recordingAfter { problems.append("the microphone was left open") } |
| + let phases = report.phases.map { "\(String(describing: $0.phase)) at \(ms($0.at))" }.joined(separator: ", ") |
| + let detail = |
| + "longest main-thread stall \(ms(stall)); start took \(ms(report.startSeconds)); phases: \(phases); asked \(report.asked); problem: \(report.problem ?? "none"); \(report.detail)" |
| + if problems.isEmpty { |
| + print("PASS \(label): \(detail)") |
| + return true |
| + } |
| + print("FAIL \(label): \(problems.joined(separator: "; ")) [\(detail)]") |
| + return false |
| + } |
| + |
| + private static func callJoin(_ dog: MainThreadWatchdog) async -> Bool { |
| + let label = "Call join audio start, Bluetooth switch simulated" |
| + AudioStartProbe.engineStartDelay = switchDelay |
| + defer { AudioStartProbe.engineStartDelay = 0 } |
| + await settle() |
| + dog.reset() |
| + let report = await AudioStartProbe.callJoin(camera: Interop.camera) |
| + let stall = dog.longestSoFar |
| + var problems: [String] = [] |
| + if stall > limit { problems.append("main thread held \(ms(stall)), limit \(ms(limit))") } |
| + if !report.asked { problems.append("the module was never asked for the microphone") } |
| + if let problem = report.problem { problems.append(problem) } |
| + if report.recordingAfter { problems.append("the microphone was left open") } |
| + let detail = |
| + "longest main-thread stall \(ms(stall)); audio start took \(ms(report.startSeconds)); asked \(report.asked); \(report.detail)" |
| + if problems.isEmpty { |
| + print("PASS \(label): \(detail)") |
| + return true |
| + } |
| + print("FAIL \(label): \(problems.joined(separator: "; ")) [\(detail)]") |
| + return false |
| + } |
| + |
| + /// Lets the last run's queued stops and closes finish before the next |
| + /// is measured. |
| + private static func settle() async { |
| + try? await Task.sleep(for: .milliseconds(300)) |
| + } |
| + |
| + static func ms(_ seconds: Double) -> String { "\(Int((seconds * 1000).rounded())) ms" } |
| +} |
diff --git a/native/apple/Sources/CallsInterop/Interop.swift b/native/apple/Sources/CallsInterop/Interop.swift| index 8e341c6..06d4e4b 100644 |
| --- a/native/apple/Sources/CallsInterop/Interop.swift |
| +++ b/native/apple/Sources/CallsInterop/Interop.swift |
| @@ -36,6 +36,9 @@ struct Input: Decodable { |
| } catch { exit(1) } |
| return |
| } |
| + if CommandLine.arguments.count == 2, CommandLine.arguments[1] == "--audio-start-stall" { |
| + exit(await AudioStartStallHarness.run() ? 0 : 1) |
| + } |
| if CommandLine.arguments.count == 2, CommandLine.arguments[1] == "--audio-selftest" { |
| exit(await AudioSelfTestHarness.run() ? 0 : 1) |
| } |
diff --git a/native/apple/Sources/CallsInterop/MainThreadWatchdog.swift b/native/apple/Sources/CallsInterop/MainThreadWatchdog.swift| new file mode 100644 |
| index 0000000..5f66153 |
| --- /dev/null |
| +++ b/native/apple/Sources/CallsInterop/MainThreadWatchdog.swift |
| @@ -0,0 +1,91 @@ |
| +import Foundation |
| + |
| +/// Measures how long the main thread goes without answering. |
| +/// |
| +/// A thread of its own posts a block to the main queue every 5 ms and |
| +/// times how long each takes to run. A main thread that is free answers |
| +/// within a millisecond or two; one that is blocked answers only when it |
| +/// is free again, and the wait is the stall. Several posts can be waiting |
| +/// at once, so the longest single wait is the longest stall, to within |
| +/// the 5 ms period. |
| +/// |
| +/// It measures the main *queue*, which is what the main actor runs on and |
| +/// what a SwiftUI app's run loop drains: a beachball is this number above |
| +/// about a second. |
| +final class MainThreadWatchdog: @unchecked Sendable { |
| + struct Stall: Sendable { |
| + /// Seconds since the watchdog started. |
| + var at: Double |
| + var seconds: Double |
| + } |
| + |
| + private let lock = NSLock() |
| + private var running = false |
| + private var longest: Stall? |
| + private var stalls: [Stall] = [] |
| + private let started = CFAbsoluteTimeGetCurrent() |
| + private let period: Double |
| + /// Waits at or above this are kept, for the report. |
| + private let noteAbove: Double |
| + |
| + init(period: Double = 0.005, noteAbove: Double = 0.05) { |
| + self.period = period |
| + self.noteAbove = noteAbove |
| + } |
| + |
| + func start() { |
| + lock.lock() |
| + running = true |
| + lock.unlock() |
| + let thread = Thread { [self] in |
| + while isRunning { |
| + let posted = CFAbsoluteTimeGetCurrent() |
| + DispatchQueue.main.async { [self] in |
| + let now = CFAbsoluteTimeGetCurrent() |
| + record(Stall(at: posted - started, seconds: now - posted)) |
| + } |
| + Thread.sleep(forTimeInterval: period) |
| + } |
| + } |
| + thread.qualityOfService = .userInteractive |
| + thread.start() |
| + } |
| + |
| + private var isRunning: Bool { |
| + lock.lock() |
| + defer { lock.unlock() } |
| + return running |
| + } |
| + |
| + private func record(_ stall: Stall) { |
| + lock.lock() |
| + defer { lock.unlock() } |
| + guard running else { return } |
| + if stall.seconds > (longest?.seconds ?? -1) { longest = stall } |
| + if stall.seconds >= noteAbove { stalls.append(stall) } |
| + } |
| + |
| + /// Forgets what was measured; the next `longest` is from now. |
| + func reset() { |
| + lock.lock() |
| + longest = nil |
| + stalls = [] |
| + lock.unlock() |
| + } |
| + |
| + /// Stops posting and gives the longest wait since the last reset, and |
| + /// every wait above `noteAbove`. |
| + func stop() -> (longest: Double, stalls: [Stall]) { |
| + lock.lock() |
| + defer { lock.unlock() } |
| + running = false |
| + return (longest?.seconds ?? 0, stalls) |
| + } |
| + |
| + /// The longest wait so far, without stopping. |
| + var longestSoFar: Double { |
| + lock.lock() |
| + defer { lock.unlock() } |
| + return longest?.seconds ?? 0 |
| + } |
| +} |
diff --git a/native/apple/Sources/CallsKit/Audio.swift b/native/apple/Sources/CallsKit/Audio.swift| index 7e7bfe6..37bb2d7 100644 |
| --- a/native/apple/Sources/CallsKit/Audio.swift |
| +++ b/native/apple/Sources/CallsKit/Audio.swift |
| @@ -1,5 +1,6 @@ |
| import AVFoundation |
| import Foundation |
| +import os |
| #if os(macOS) |
| import CoreAudio |
| #endif |
| @@ -84,7 +85,7 @@ public struct AudioDevice: Identifiable, Hashable, Sendable { |
| /// Devices the engine could not start on, by UID, with why. Cleared |
| /// when the person chooses the device again or what is connected |
| /// changes. |
| - private var failed: [String: String] = [:] |
| + private(set) var failed: [String: String] = [:] |
| /// The microphone the engine was last seen running on, read back from |
| /// its unit — the truth when it is known. |
| private var engineInput: String? |
| @@ -184,8 +185,10 @@ public struct AudioDevice: Identifiable, Hashable, Sendable { |
| return [head] + inventory.list(input).map { AudioDevice(id: $0.uid, name: $0.name, selectable: true) } |
| } |
| - func attach(_ module: RTCAudioDeviceModule, pin: AudioEnginePin, inputRate: @escaping () -> Double?) { |
| - attach(AudioEngineControl(module, pin: pin, inputRate: inputRate)) |
| + func attach( |
| + _ module: RTCAudioDeviceModule, state: ModuleState, pin: AudioEnginePin, inputRate: @escaping () -> Double? |
| + ) { |
| + attach(AudioEngineControl(module, state: state, pin: pin, inputRate: inputRate)) |
| listenForDevices() |
| } |
| @@ -370,6 +373,24 @@ public struct AudioDevice: Identifiable, Hashable, Sendable { |
| return AudioRoutes.describe(device) |
| } |
| + /// The microphone a start would open, by name, when it is a Bluetooth |
| + /// headset still in its listening profile: opening its microphone |
| + /// moves it to the hands-free one (16 or 24 kHz), which takes it a |
| + /// second or two. Read from CoreAudio directly, so it is known before |
| + /// the call's audio is built. Nil for anything else. |
| + nonisolated static func bluetoothSwitching(preference: AudioPreference?, failed: [String: String]) -> String? { |
| + let inventory = CoreAudioDevices.inventory() |
| + let chosen = AudioRouteRules.resolve(preference, inventory, input: true, failed: failed) |
| + return bluetoothSwitching(uid: chosen.explicit ?? inventory.defaultInput, inventory) |
| + } |
| + |
| + nonisolated static func bluetoothSwitching(uid: String?, _ inventory: AudioInventory) -> String? { |
| + guard let device = inventory.devices.first(where: { $0.uid == uid }), device.transport == "bluetooth" |
| + else { return nil } |
| + // At the hands-free rates already: nothing to switch. |
| + return device.sampleRate > 0 && device.sampleRate <= 24_000 ? nil : device.name |
| + } |
| + |
| nonisolated static func describe(_ device: SystemAudioDevice) -> String { |
| var parts: [String] = [] |
| switch device.transport { |
| @@ -564,10 +585,15 @@ public struct AudioDevice: Identifiable, Hashable, Sendable { |
| self.pinned = pinned |
| } |
| - init(_ module: RTCAudioDeviceModule, pin enginePin: AudioEnginePin, inputRate: @escaping () -> Double?) { |
| + /// The module is only ever asked on the audio queue; the main thread |
| + /// reads its state from `state` (ModuleState), never from the module. |
| + init( |
| + _ module: RTCAudioDeviceModule, state: ModuleState, pin enginePin: AudioEnginePin, |
| + inputRate: @escaping () -> Double? |
| + ) { |
| inventory = CoreAudioDevices.inventory |
| self.inputRate = inputRate |
| - isRecording = { [weak module] in module?.isRecording ?? false } |
| + isRecording = { state.read().isRecording } |
| pinned = { |
| let target = enginePin.target() |
| if case .pair = target { |
| @@ -580,22 +606,45 @@ public struct AudioDevice: Identifiable, Hashable, Sendable { |
| // not what it was put on. Before an engine exists (the module makes |
| // one for the first connection) there is nothing to move: the first |
| // engine is made on the choice. |
| - pin = { [weak module] input, output in |
| + // A rebuild runs on the audio queue, so `lastApplied` lags the ask by |
| + // the time it takes; a second look in between (CoreAudio's device |
| + // list changes as the old aggregate goes) must not ask again. |
| + let asked = OSAllocatedUnfairLock<EngineTarget?>(initialState: nil) |
| + pin = { input, output in |
| _ = enginePin.set(input: input, output: output) |
| - guard let module, !module.isManualRenderingMode, module.isPlaying || module.isRecording, |
| + let module = state.read() |
| + guard !module.isManualRenderingMode, module.isPlaying || module.isRecording, |
| let applied = enginePin.lastApplied |
| else { return false } |
| - return enginePin.target() != applied |
| + let target = enginePin.target() |
| + guard target != applied else { |
| + // Caught up: the next move, even back to a target asked for |
| + // before (a device that failed, chosen again), is a new ask. |
| + asked.withLock { $0 = nil } |
| + return false |
| + } |
| + return asked.withLock { last in |
| + guard last != target else { return false } |
| + last = target |
| + return true |
| + } |
| } |
| // The module has no "rebuild" call. Taking both sides away releases |
| // its engine; giving them back makes a new one, and the pin is |
| // applied to it in the "will enable" hook. |
| + // On the audio queue, as every engine start is (AudioWork): a rebuild |
| + // onto a Bluetooth microphone waits for the headset to change |
| + // profile, and the main thread must not wait with it. |
| rebuild = { [weak module] in |
| - guard let module, !module.isManualRenderingMode else { return } |
| - let saved = module.engineAvailability |
| - _ = module.setEngineAvailability(RTCAudioEngineAvailability(isInputAvailable: false, isOutputAvailable: false)) |
| - let result = module.setEngineAvailability(saved) |
| - AudioLog.info("engine rebuilt (result \(result), running \(module.isEngineRunning))") |
| + guard let module else { return } |
| + AudioWork.post { |
| + guard !module.isManualRenderingMode else { return } |
| + let saved = module.engineAvailability |
| + _ = module.setEngineAvailability( |
| + RTCAudioEngineAvailability(isInputAvailable: false, isOutputAvailable: false)) |
| + let result = module.setEngineAvailability(saved) |
| + AudioLog.info("engine rebuilt (result \(result), running \(module.isEngineRunning))") |
| + } |
| } |
| channels = { uid in CoreAudioInput.device(forModuleID: uid).flatMap(CoreAudioInput.describe) } |
| // Taking the input away and giving it back rewires the microphone, |
| @@ -603,19 +652,27 @@ public struct AudioDevice: Identifiable, Hashable, Sendable { |
| // (Re-selecting the same device does nothing: the module ignores a |
| // state that has not changed.) |
| rebuildInput = { [weak module] in |
| - guard let module, module.isRecording, !module.isManualRenderingMode else { return } |
| - _ = module.setEngineAvailability( |
| - RTCAudioEngineAvailability(isInputAvailable: false, isOutputAvailable: true)) |
| - _ = module.setEngineAvailability( |
| - RTCAudioEngineAvailability(isInputAvailable: true, isOutputAvailable: true)) |
| + guard let module else { return } |
| + AudioWork.post { |
| + guard module.isRecording, !module.isManualRenderingMode else { return } |
| + _ = module.setEngineAvailability( |
| + RTCAudioEngineAvailability(isInputAvailable: false, isOutputAvailable: true)) |
| + _ = module.setEngineAvailability( |
| + RTCAudioEngineAvailability(isInputAvailable: true, isOutputAvailable: true)) |
| + } |
| } |
| + // Off the main thread: the module's first list is made on WebRTC's |
| + // worker thread, 61–68 ms measured, only to be logged. |
| logModuleLists = { [weak module] in |
| guard let module else { return } |
| - func line(_ list: [RTCIODevice]) -> String { |
| - list.map { "\($0.name) [\($0.deviceId)]" }.joined(separator: "; ") |
| + AudioWork.post { |
| + func line(_ list: [RTCIODevice]) -> String { |
| + list.map { "\($0.name) [\($0.deviceId)]" }.joined(separator: "; ") |
| + } |
| + AudioLog.info( |
| + "engine module lists (not used to choose): inputs \(line(module.inputDevices)); outputs \(line(module.outputDevices))" |
| + ) |
| } |
| - AudioLog.info( |
| - "engine module lists (not used to choose): inputs \(line(module.inputDevices)); outputs \(line(module.outputDevices))") |
| } |
| } |
| } |
diff --git a/native/apple/Sources/CallsKit/AudioSelfTest.swift b/native/apple/Sources/CallsKit/AudioSelfTest.swift| index 2d22e3e..0315155 100644 |
| --- a/native/apple/Sources/CallsKit/AudioSelfTest.swift |
| +++ b/native/apple/Sources/CallsKit/AudioSelfTest.swift |
| @@ -28,6 +28,114 @@ public enum MicVerdict: Equatable, Sendable { |
| case notStarted |
| } |
| +/// Where the test's microphone is on its way to running. The test's |
| +/// window opens at once; this is what it says until the microphone is |
| +/// actually capturing, which with a Bluetooth headset can be a couple of |
| +/// seconds (it has to move to its hands-free profile to open a |
| +/// microphone at all — physics, not a fault). |
| +public enum MicStartPhase: Equatable, Sendable { |
| + case idle |
| + /// Asking for microphone permission; a system prompt may be showing. |
| + case asking |
| + /// Building the audio and starting the microphone. `switching` names a |
| + /// Bluetooth microphone that is moving to its hands-free profile. |
| + case starting(switching: String?) |
| + case running |
| + /// Would not start, or no permission: the test says why elsewhere. |
| + case failed |
| + /// Closed before it had started. |
| + case cancelled |
| +} |
| + |
| +/// The start's rules, apart from any device, so they can be tested: every |
| +/// way through open, permission, start and close, and what a start that |
| +/// finishes after the window has closed must do (stop what it opened). |
| +public struct MicStart: Sendable { |
| + public private(set) var phase: MicStartPhase = .idle |
| + |
| + public init() {} |
| + |
| + /// What to do with a start that has just finished. |
| + public enum Finish: Equatable, Sendable { |
| + /// Keep it: it is the test's microphone now. |
| + case keep |
| + /// The test was closed while it started: stop it at once. |
| + case stop |
| + /// It failed; nothing to stop. |
| + case failed |
| + } |
| + |
| + /// Opening the window. False if it was already opened or closed. |
| + public mutating func open() -> Bool { |
| + guard phase == .idle else { return false } |
| + phase = .asking |
| + return true |
| + } |
| + |
| + /// The answer to the permission question. False: do not start. |
| + public mutating func permission(_ granted: Bool) -> Bool { |
| + guard phase == .asking else { return false } |
| + phase = granted ? .starting(switching: nil) : .failed |
| + return granted |
| + } |
| + |
| + /// Starting again after the test stopped the microphone to play |
| + /// something (iPhone). False once closed. |
| + public mutating func restart() -> Bool { |
| + guard phase == .running || phase == .failed else { return false } |
| + phase = .starting(switching: nil) |
| + return true |
| + } |
| + |
| + /// The audio is built and the microphone it will open is known. |
| + public mutating func microphone(bluetoothSwitching name: String?) { |
| + guard case .starting = phase else { return } |
| + phase = .starting(switching: name) |
| + } |
| + |
| + public mutating func finished(ok: Bool) -> Finish { |
| + switch phase { |
| + case .starting: |
| + phase = ok ? .running : .failed |
| + return ok ? .keep : .failed |
| + case .cancelled: |
| + return ok ? .stop : .failed |
| + default: |
| + // Not ours to judge (a start nobody asked for): stop it. |
| + return ok ? .stop : .failed |
| + } |
| + } |
| + |
| + /// The window closed. Returns whether a microphone may be open or |
| + /// opening, so the caller stops it. |
| + @discardableResult |
| + public mutating func close() -> Bool { |
| + let live: Bool |
| + switch phase { |
| + case .starting, .running: live = true |
| + default: live = false |
| + } |
| + phase = .cancelled |
| + return live |
| + } |
| + |
| + public var isStarting: Bool { |
| + if case .starting = phase { return true } |
| + return phase == .asking |
| + } |
| + |
| + /// What the window says while the microphone starts; nil otherwise. |
| + public var sentence: String? { |
| + switch phase { |
| + case .asking: return "Asking to use the microphone…" |
| + case .starting(let switching?): |
| + return "Switching \(switching) to microphone mode…" |
| + case .starting(nil): return "Starting microphone…" |
| + default: return nil |
| + } |
| + } |
| +} |
| + |
| /// Judges the microphone from the capture hook's two meters. |
| /// |
| /// "Working" is asked of `CaptureDenoiser.level`, the audio after noise |
diff --git a/native/apple/Sources/CallsKit/AudioSelfTestLoopback.swift b/native/apple/Sources/CallsKit/AudioSelfTestLoopback.swift| index 7f06aea..c929ff4 100644 |
| --- a/native/apple/Sources/CallsKit/AudioSelfTestLoopback.swift |
| +++ b/native/apple/Sources/CallsKit/AudioSelfTestLoopback.swift |
| @@ -21,70 +21,73 @@ import LiveKitWebRTC |
| /// for WebRTC to open the microphone and process every buffer, as it does |
| /// for a call waiting to connect. The receiving side hears nothing — no |
| /// packet reaches it — and its track is silenced anyway. |
| -@MainActor final class SelfTestLoopback { |
| - private var sender: RTCPeerConnection? |
| - private var receiver: RTCPeerConnection? |
| +/// |
| +/// Every call to WebRTC here is made on the audio queue (PeerCalls): |
| +/// applying the answer starts the engine's playout, 188–229 ms measured, |
| +/// and in 0.3.8 the main thread waited for it. |
| +final class SelfTestLoopback: @unchecked Sendable { |
| + private let sender: RTCPeerConnection |
| + private let receiver: RTCPeerConnection |
| + /// Held until the connections are closed: a connection must never |
| + /// outlive its factory. |
| + private var factory: RTCPeerConnectionFactory? |
| + private var closed = false |
| - init(factory: RTCPeerConnectionFactory, track: RTCAudioTrack) async throws { |
| - let config = RTCConfiguration() |
| - config.sdpSemantics = .unifiedPlan |
| - config.iceTransportPolicy = .none |
| - let constraints = RTCMediaConstraints(mandatoryConstraints: nil, optionalConstraints: nil) |
| - guard let sender = factory.peerConnection(with: config, constraints: constraints, delegate: nil), |
| - let receiver = factory.peerConnection(with: config, constraints: constraints, delegate: nil) |
| - else { throw SelfTestLoopback.failed("the audio connection could not be made") } |
| + private init(sender: RTCPeerConnection, receiver: RTCPeerConnection, factory: RTCPeerConnectionFactory) { |
| self.sender = sender |
| self.receiver = receiver |
| - do { |
| + self.factory = factory |
| + } |
| + |
| + static func make(factory: RTCPeerConnectionFactory, track: RTCAudioTrack) async throws -> SelfTestLoopback { |
| + let constraints = RTCMediaConstraints(mandatoryConstraints: nil, optionalConstraints: nil) |
| + let pair: SelfTestLoopback = try await AudioWork.run { |
| + let config = RTCConfiguration() |
| + config.sdpSemantics = .unifiedPlan |
| + config.iceTransportPolicy = .none |
| + guard let sender = factory.peerConnection(with: config, constraints: constraints, delegate: nil), |
| + let receiver = factory.peerConnection(with: config, constraints: constraints, delegate: nil) |
| + else { throw SelfTestLoopback.failed("the audio connection could not be made") } |
| + let pair = SelfTestLoopback(sender: sender, receiver: receiver, factory: factory) |
| guard sender.add(track, streamIds: ["selftest"]) != nil else { |
| + pair.close() |
| throw SelfTestLoopback.failed("the microphone could not be added") |
| } |
| - let offer = try await SelfTestLoopback.describe(sender, answer: false, constraints) |
| - try await SelfTestLoopback.set(sender, local: true, offer) |
| - try await SelfTestLoopback.set(receiver, local: false, offer) |
| - for transceiver in receiver.transceivers { |
| - (transceiver.receiver.track as? RTCAudioTrack)?.source.volume = 0 |
| - } |
| - let answer = try await SelfTestLoopback.describe(receiver, answer: true, constraints) |
| - try await SelfTestLoopback.set(receiver, local: true, answer) |
| - try await SelfTestLoopback.set(sender, local: false, answer) |
| + return pair |
| + } |
| + do { |
| + try await pair.negotiate(constraints) |
| } catch { |
| - close() |
| + pair.close() |
| throw error |
| } |
| + return pair |
| } |
| - /// Ends both; WebRTC closes the microphone with the last sending stream. |
| - func close() { |
| - sender?.close() |
| - receiver?.close() |
| - sender = nil |
| - receiver = nil |
| - } |
| - |
| - private static func describe( |
| - _ pc: RTCPeerConnection, answer: Bool, _ constraints: RTCMediaConstraints |
| - ) async throws -> RTCSessionDescription { |
| - try await withCheckedThrowingContinuation { c in |
| - let completion: @Sendable (RTCSessionDescription?, Error?) -> Void = { desc, error in |
| - if let desc { c.resume(returning: desc) } else { c.resume(throwing: error ?? CancellationError()) } |
| - } |
| - if answer { |
| - pc.answer(for: constraints, completionHandler: completion) |
| - } else { |
| - pc.offer(for: constraints, completionHandler: completion) |
| + private func negotiate(_ constraints: RTCMediaConstraints) async throws { |
| + let offer = try await PeerCalls.describe(sender, answer: false, constraints) |
| + try await PeerCalls.set(sender, local: true, offer) |
| + try await PeerCalls.set(receiver, local: false, offer) |
| + await PeerCalls.read(receiver) { receiver in |
| + for transceiver in receiver.transceivers { |
| + (transceiver.receiver.track as? RTCAudioTrack)?.source.volume = 0 |
| } |
| } |
| + let answer = try await PeerCalls.describe(receiver, answer: true, constraints) |
| + try await PeerCalls.set(receiver, local: true, answer) |
| + try await PeerCalls.set(sender, local: false, answer) |
| } |
| - private static func set(_ pc: RTCPeerConnection, local: Bool, _ desc: RTCSessionDescription) async throws { |
| - try await withCheckedThrowingContinuation { (c: CheckedContinuation<Void, Error>) in |
| - let done: @Sendable (Error?) -> Void = { error in |
| - if let error { c.resume(throwing: error) } else { c.resume() } |
| - } |
| - if local { pc.setLocalDescription(desc, completionHandler: done) } else { |
| - pc.setRemoteDescription(desc, completionHandler: done) |
| - } |
| + /// Ends both, on the audio queue after anything already asked of them; |
| + /// WebRTC closes the microphone with the last sending stream. Safe to |
| + /// call twice. |
| + func close() { |
| + AudioWork.post { [self] in |
| + guard !closed else { return } |
| + closed = true |
| + sender.close() |
| + receiver.close() |
| + factory = nil |
| } |
| } |
diff --git a/native/apple/Sources/CallsKit/AudioSelfTestModel.swift b/native/apple/Sources/CallsKit/AudioSelfTestModel.swift| index a89d3a9..4b89ea6 100644 |
| --- a/native/apple/Sources/CallsKit/AudioSelfTestModel.swift |
| +++ b/native/apple/Sources/CallsKit/AudioSelfTestModel.swift |
| @@ -61,6 +61,9 @@ import LiveKitWebRTC |
| /// iPhone the voice-processing unit ducks other audio while it runs. |
| var stopsForPlayback: Bool { get } |
| func permitted() async -> Bool |
| + /// A Bluetooth microphone that starting will move to its hands-free |
| + /// profile, by name: said while it switches. Nil for anything else. |
| + func switchingMicrophone() async -> String? |
| func start() async throws |
| func stop() |
| /// The meters since the last read: before the suppressor, and after. |
| @@ -69,6 +72,10 @@ import LiveKitWebRTC |
| func diagnostics() -> [(String, [String])] |
| } |
| +extension AudioSelfTestCapture { |
| + func switchingMicrophone() async -> String? { nil } |
| +} |
| + |
| /// Plays mono audio to the chosen speaker. Public so the interop harness |
| /// can stand in a player that only checks what it was given. |
| @MainActor public protocol AudioSelfTestPlayer: AnyObject { |
| @@ -114,6 +121,9 @@ public struct RecordingSummary: Equatable, Sendable { |
| /// "Paul's AirPods Pro (Bluetooth, 24 kHz)": what the verdict names. |
| @Published public private(set) var microphoneDetail = "" |
| @Published public private(set) var speakerName = "" |
| + /// Where the microphone is on its way to running: the window opens at |
| + /// once and says "Starting microphone…" until it is. |
| + @Published public private(set) var startPhase: MicStartPhase = .idle |
| public private(set) var lastRecording: RecordingSummary? |
| public let recordSeconds: Double |
| @@ -134,9 +144,9 @@ public struct RecordingSummary: Equatable, Sendable { |
| private let recorder: SelfTestRecorder |
| private let outputMeter = OutputMeter() |
| private var judge = MicJudge() |
| + private var start = MicStart() |
| private var capturing = false |
| - private var opened = false |
| - private var closed = false |
| + private var closed: Bool { start.phase == .cancelled } |
| /// Set while this test is playing something, when the microphone's |
| /// readings are its own sound and are not judged. |
| private var playingSince: Double? |
| @@ -185,16 +195,17 @@ public struct RecordingSummary: Equatable, Sendable { |
| } catch { |
| // Refused (in a test process, for want of permission): what |
| // matters is whether it was asked. |
| - let asked = capture.audio.recordingRequested |
| + let asked = capture.audio?.recordingRequested ?? false |
| return (asked, "recording requested \(asked); the module refused: \(error.localizedDescription)") |
| } |
| let deadline = Date().addingTimeInterval(3) |
| - while !capture.audio.recordingRequested, Date() < deadline { |
| + while capture.audio?.recordingRequested != true, Date() < deadline { |
| try? await Task.sleep(for: .milliseconds(100)) |
| } |
| - let asked = capture.audio.recordingRequested |
| - let recording = capture.audio.factory.audioDeviceModule.isRecording |
| + let asked = capture.audio?.recordingRequested ?? false |
| + let recording = capture.audio?.module.isRecording ?? false |
| capture.stop() |
| + await AudioWork.drain() |
| return (asked, "recording requested \(asked), module recording \(recording) (no permission refuses it)") |
| } |
| #endif |
| @@ -221,18 +232,24 @@ public struct RecordingSummary: Equatable, Sendable { |
| // MARK: Lifecycle |
| /// Starts the microphone (outside a call) and the meters. |
| + /// |
| + /// Nothing here waits on the main thread: the audio is built, the |
| + /// microphone started and any Bluetooth headset switched on the audio |
| + /// queue (AudioWork), and the window says what is happening meanwhile |
| + /// (`startPhase`). Closing it partway cancels cleanly: a start that |
| + /// finishes after the close is stopped at once, so the microphone is |
| + /// never left open. |
| public func open() async { |
| - guard !opened, !closed else { return } |
| - opened = true |
| - guard await capture.permitted() else { |
| - micProblem = AudioSelfTest.noPermission |
| + guard start.open() else { return } |
| + startPhase = start.phase |
| + let began = clock() |
| + let granted = await capture.permitted() |
| + guard start.permission(granted) else { |
| + startPhase = start.phase |
| + if !closed { micProblem = AudioSelfTest.noPermission } |
| return |
| } |
| - guard !closed else { return } |
| - await startCapture() |
| - refreshNames() |
| - AudioLog.info( |
| - "test opened \(inCall ? "in a call" : "before a call"): microphone \(microphoneName) = \(microphoneDetail.isEmpty ? "unknown" : microphoneDetail); capturing \(capturing)") |
| + startPhase = start.phase |
| ticker = Task { [weak self] in |
| while !Task.isCancelled { |
| try? await Task.sleep(for: .milliseconds(100)) |
| @@ -240,34 +257,52 @@ public struct RecordingSummary: Equatable, Sendable { |
| self.tick() |
| } |
| } |
| + await startCapture() |
| + guard !closed else { return } |
| + refreshNames() |
| + AudioLog.info( |
| + "test opened \(inCall ? "in a call" : "before a call") in \(Int((clock() - began) * 1000)) ms: microphone \(microphoneName) = \(microphoneDetail.isEmpty ? "unknown" : microphoneDetail); capturing \(capturing)") |
| } |
| /// Stops everything this test started and throws any recording away. |
| - /// Called when the view closes; safe to call twice. |
| + /// Called when the view closes; safe to call twice, and at any point of |
| + /// the start. |
| public func close() { |
| guard !closed else { return } |
| - closed = true |
| + let live = start.close() |
| + startPhase = start.phase |
| ticker?.cancel() |
| ticker = nil |
| player.stop() |
| if !capture.inCall { capture.denoiser.sink = nil } |
| - if capturing { capture.stop() } |
| + if capturing || live { capture.stop() } |
| capturing = false |
| recorder.discard() |
| } |
| private func startCapture() async { |
| - do { |
| - try await capture.start() |
| - guard !closed else { return capture.stop() } |
| + let switching = await capture.switchingMicrophone() |
| + start.microphone(bluetoothSwitching: switching) |
| + startPhase = start.phase |
| + var failure: Error? |
| + do { try await capture.start() } catch { failure = error } |
| + switch start.finished(ok: failure == nil) { |
| + case .keep: |
| + startPhase = start.phase |
| capturing = true |
| micProblem = nil |
| judge.start(at: clock()) |
| // Whatever was measured before this start is not this microphone's. |
| _ = capture.levels() |
| - } catch { |
| + case .stop: |
| + // Closed while it started: what it opened is closed now. |
| + capture.stop() |
| + AudioLog.info("test: closed while the microphone started; stopped it") |
| + case .failed: |
| + startPhase = start.phase |
| capturing = false |
| - let said = error.localizedDescription |
| + guard !closed, let failure else { return } |
| + let said = failure.localizedDescription |
| micProblem = said.hasPrefix("The microphone") || said == AudioSelfTest.noPermission |
| ? said : "The microphone would not start: \(said)" |
| AudioLog.error("test: \(micProblem ?? said)") |
| @@ -429,7 +464,7 @@ public struct RecordingSummary: Equatable, Sendable { |
| do { try await body() } catch { failure = error } |
| playingSince = nil |
| outputStep = 0 |
| - if restart, !closed { await startCapture() } |
| + if restart, start.restart() { await startCapture() } |
| judge.resume(after: clock() - started) |
| if let failure { throw failure } |
| } |
| @@ -443,9 +478,11 @@ public struct RecordingSummary: Equatable, Sendable { |
| "Not during a call: everyone in it would hear the sound too. Test your speaker and hear yourself from the home screen before a call." |
| public var micSentence: String { |
| - micProblem ?? MicJudge.sentence(micVerdict, device: verdictName) |
| + micProblem ?? start.sentence ?? MicJudge.sentence(micVerdict, device: verdictName) |
| } |
| - public var micAdvice: String? { micProblem == nil ? MicJudge.advice(micVerdict) : nil } |
| + /// Said beside a spinner while the microphone starts; nil once it has. |
| + public var startingSentence: String? { micProblem == nil ? start.sentence : nil } |
| + public var micAdvice: String? { micProblem == nil && !start.isStarting ? MicJudge.advice(micVerdict) : nil } |
| public var speakerSentence: String? { |
| let name = speakerName.isEmpty ? "your speaker" : speakerName |
| @@ -485,31 +522,44 @@ extension Call { |
| /// The microphone before a call: a CallAudio of the test's own, recording |
| /// on its own. See the top of this file. |
| +/// |
| +/// The CallAudio is built when the test starts, not when its window is |
| +/// made, and on the audio queue (CallAudio.make): the window appears at |
| +/// once. The capture hook is made first, cheaply, so the test can read it |
| +/// from the start. |
| @MainActor final class StandaloneCapture: AudioSelfTestCapture { |
| let routes: AudioRoutes |
| - let audio: CallAudio |
| + /// Nil until the start has built it. |
| + private(set) var audio: CallAudio? |
| + let denoiser: CaptureDenoiser |
| let reduceNoise: Bool |
| - private let synthetic: Bool |
| + private let input: AudioInputSource? |
| private let askToRecord: Bool |
| private var track: RTCAudioTrack? |
| private var loopback: SelfTestLoopback? |
| + /// Bumped by every stop, so a start in flight knows it was stopped. |
| + private var attempt = 0 |
| #if os(iOS) |
| private var session: SavedSession? |
| #endif |
| - init(routes: AudioRoutes, input: AudioInputSource?, reduceNoise: Bool, askToRecord: Bool = true) { |
| + /// Set by the stall harness only, which has no microphone permission to |
| + /// ask for (a test process), and measures the start behind the question. |
| + private let assumePermitted: Bool |
| + |
| + init( |
| + routes: AudioRoutes, input: AudioInputSource?, reduceNoise: Bool, askToRecord: Bool = true, |
| + assumePermitted: Bool = false |
| + ) { |
| self.routes = routes |
| self.reduceNoise = reduceNoise |
| self.askToRecord = askToRecord |
| - synthetic = input != nil |
| - #if os(macOS) |
| - audio = CallAudio(routes: routes, audioDevice: nil, input: input, reduceNoise: reduceNoise) |
| - #else |
| - audio = CallAudio(routes: routes, audioDevice: nil, reduceNoise: reduceNoise) |
| - #endif |
| + self.assumePermitted = assumePermitted |
| + self.input = input |
| + denoiser = CaptureDenoiser(suppressor: CallAudio.makeSuppressor()) |
| } |
| - var denoiser: CaptureDenoiser { audio.denoiser } |
| + private var synthetic: Bool { input != nil } |
| var inCall: Bool { false } |
| var captureExpected: Bool { true } |
| var muted: Bool { false } |
| @@ -523,6 +573,17 @@ extension Call { |
| var microphoneDetail: String { AudioSelfTest.inputDetail(routes) } |
| var speakerName: String { routes.outputName } |
| var speakerID: String? { routes.currentOutput } |
| + /// Read from CoreAudio on the audio queue: the process's first CoreAudio |
| + /// read sets up its connection to the audio server, 80–180 ms measured. |
| + func switchingMicrophone() async -> String? { |
| + guard !synthetic else { return nil } |
| + #if os(macOS) |
| + let (preference, failed) = (AudioPreference.load(input: true, routes.defaults), routes.failed) |
| + return await AudioWork.run { AudioRoutes.bluetoothSwitching(preference: preference, failed: failed) } |
| + #else |
| + return nil |
| + #endif |
| + } |
| func diagnostics() -> [(String, [String])] { |
| #if os(macOS) |
| @@ -530,11 +591,14 @@ extension Call { |
| #else |
| let routeLines = ["microphone: \(microphoneName)", "speaker: \(speakerName)"] |
| #endif |
| - return [("Devices", routeLines), ("Audio engine and processing", audio.diagnostics())] |
| + return [ |
| + ("Devices", routeLines), |
| + ("Audio engine and processing", audio?.diagnostics() ?? ["the audio has not been built yet"]), |
| + ] |
| } |
| func permitted() async -> Bool { |
| - if synthetic { return true } |
| + if synthetic || assumePermitted { return true } |
| #if os(macOS) |
| switch AVCaptureDevice.authorizationStatus(for: .audio) { |
| case .authorized: return true |
| @@ -550,22 +614,44 @@ extension Call { |
| #endif |
| } |
| + /// Builds the audio (the first time), sends the microphone track over |
| + /// the loopback and opens the microphone: every slow step on the audio |
| + /// queue. A stop during any of it wins: what was opened is closed, in |
| + /// order, behind it. |
| func start() async throws { |
| + let attempt = self.attempt |
| #if os(iOS) |
| if session == nil { session = try SavedSession.configureForCall() } |
| #endif |
| + let audio: CallAudio |
| + if let built = self.audio { |
| + audio = built |
| + } else { |
| + #if os(macOS) |
| + audio = await CallAudio.make( |
| + routes: routes, audioDevice: nil, input: input, reduceNoise: reduceNoise, denoiser: denoiser) |
| + #else |
| + audio = await CallAudio.make(routes: routes, audioDevice: nil, reduceNoise: reduceNoise, denoiser: denoiser) |
| + #endif |
| + self.audio = audio |
| + } |
| + guard attempt == self.attempt else { throw CancellationError() } |
| // Made here, as a call makes its own: making it applies the call's |
| // processing options (CallAudio.apply). CallAudio holds it weakly. |
| let track = audio.makeTrack() |
| self.track = track |
| - let loop = try await SelfTestLoopback(factory: audio.factory, track: track) |
| - guard self.track === track else { return loop.close() } |
| + let loop = try await SelfTestLoopback.make(factory: audio.factory, track: track) |
| + guard attempt == self.attempt else { |
| + loop.close() |
| + throw CancellationError() |
| + } |
| loopback = loop |
| // The loopback is never connected, and WebRTC opens the microphone |
| // only for a connection that has been (CallAudio.startRecordingAlone): |
| // so the microphone is opened here, as a call's first send stream |
| // opens it. |
| - let result = audio.startRecordingAlone(ask: askToRecord) |
| + let result = await audio.startRecordingAlone(ask: askToRecord) |
| + guard attempt == self.attempt else { throw CancellationError() } |
| routes.refresh() |
| if result != 0, !synthetic { |
| stop() |
| @@ -574,10 +660,13 @@ extension Call { |
| } |
| } |
| + /// Closes the loopback and the microphone, on the audio queue behind |
| + /// anything still starting; never waits. |
| func stop() { |
| + attempt += 1 |
| loopback?.close() |
| loopback = nil |
| - audio.stopRecordingAlone() |
| + audio?.stopRecordingAlone() |
| track = nil |
| #if os(iOS) |
| session?.restore() |
| @@ -586,7 +675,7 @@ extension Call { |
| } |
| func levels() -> (input: CaptureLevel.Reading, output: CaptureLevel.Reading) { |
| - (audio.denoiser.inputLevel.take(), audio.denoiser.level.take()) |
| + (denoiser.inputLevel.take(), denoiser.level.take()) |
| } |
| } |
| @@ -745,12 +834,16 @@ extension AudioSelfTest { |
| } |
| self.engine = engine |
| self.node = node |
| - do { try engine.start() } catch { |
| - finish() |
| - throw error |
| - } |
| generation += 1 |
| let playback = generation |
| + // Started on the audio queue: an engine start on a Bluetooth headset |
| + // waits for its route, and the main thread must not wait with it. |
| + do { try await AudioWork.run { try engine.start() } } catch { |
| + if generation == playback { finish() } |
| + throw error |
| + } |
| + // Stopped while it started: `finish` has queued its stop. |
| + guard generation == playback, self.engine === engine else { return } |
| try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in |
| waiting = continuation |
| node.scheduleBuffer(buffer, at: nil, options: [], completionCallbackType: .dataPlayedBack) { |
| @@ -769,9 +862,15 @@ extension AudioSelfTest { |
| private func finish() { |
| let waiting = self.waiting |
| self.waiting = nil |
| - engine?.mainMixerNode.removeTap(onBus: 0) |
| - node?.stop() |
| - engine?.stop() |
| + // Stopped on the audio queue, behind its start if that is still |
| + // running. |
| + if let engine, let node { |
| + AudioWork.post { |
| + engine.mainMixerNode.removeTap(onBus: 0) |
| + node.stop() |
| + engine.stop() |
| + } |
| + } |
| node = nil |
| engine = nil |
| #if os(iOS) |
| @@ -786,7 +885,10 @@ extension AudioSelfTest { |
| waiting?.resume() |
| } |
| - func stop() { finish() } |
| + func stop() { |
| + generation += 1 |
| + finish() |
| + } |
| #if os(macOS) |
| /// A CoreAudio device from its UID, or nil. |
diff --git a/native/apple/Sources/CallsKit/AudioSelfTestView.swift b/native/apple/Sources/CallsKit/AudioSelfTestView.swift| index 7b07005..6ca7b83 100644 |
| --- a/native/apple/Sources/CallsKit/AudioSelfTestView.swift |
| +++ b/native/apple/Sources/CallsKit/AudioSelfTestView.swift |
| @@ -102,10 +102,22 @@ import SwiftUI |
| .accessibilityIdentifier("testMicrophoneDevice") |
| } |
| LevelMeter(step: test.micStep, label: "Microphone level") |
| - Label(test.micSentence, systemImage: micSymbol) |
| - .font(.callout) |
| - .fixedSize(horizontal: false, vertical: true) |
| + .opacity(test.startingSentence == nil ? 1 : 0.4) |
| + if let starting = test.startingSentence { |
| + // The window is up at once; the microphone takes a moment, and a |
| + // Bluetooth headset a second or two more. Said, not frozen. |
| + HStack(spacing: 8) { |
| + ProgressView().controlSize(.small) |
| + Text(starting).font(.callout).fixedSize(horizontal: false, vertical: true) |
| + } |
| + .accessibilityElement(children: .combine) |
| .accessibilityIdentifier("testMicrophoneResult") |
| + } else { |
| + Label(test.micSentence, systemImage: micSymbol) |
| + .font(.callout) |
| + .fixedSize(horizontal: false, vertical: true) |
| + .accessibilityIdentifier("testMicrophoneResult") |
| + } |
| if let advice = test.micAdvice { |
| Text(advice).font(.footnote).foregroundStyle(.secondary) |
| .fixedSize(horizontal: false, vertical: true) |
| @@ -181,7 +193,7 @@ import SwiftUI |
| Task { await test.recordAndPlayBack() } |
| } |
| .buttonStyle(.bordered) |
| - .disabled(busy || test.micProblem != nil) |
| + .disabled(busy || test.micProblem != nil || test.startingSentence != nil) |
| .accessibilityIdentifier("testRecord") |
| if test.recording == .recording { |
| ProgressView(value: test.recordingProgress) |
diff --git a/native/apple/Sources/CallsKit/AudioStartProbe.swift b/native/apple/Sources/CallsKit/AudioStartProbe.swift| new file mode 100644 |
| index 0000000..dd97016 |
| --- /dev/null |
| +++ b/native/apple/Sources/CallsKit/AudioStartProbe.swift |
| @@ -0,0 +1,141 @@ |
| +import Combine |
| +import Foundation |
| +import LiveKitWebRTC |
| + |
| +/// The main-thread stall harness's view into the audio start |
| +/// (`CallsInterop --audio-start-stall`). It runs the start the app runs — |
| +/// Test audio before a call, and a call's audio as a join builds it — on |
| +/// the real engine module, not manual rendering, while the harness |
| +/// watches the main thread. A test process has no microphone permission, |
| +/// so the module refuses right after it is asked; everything before that |
| +/// (building the factory, negotiating, the engine's start up to the |
| +/// microphone) runs as in the app. A Bluetooth headset's profile switch is |
| +/// stood in for by `engineStartDelay`, held on WebRTC's worker thread in |
| +/// the engine's "will enable" hook, which is where a real switch holds it. |
| +@_spi(Fixtures) public enum AudioStartProbe { |
| + /// Seconds the engine's start with a microphone is held, as a Bluetooth |
| + /// headset's switch to its hands-free profile holds it. Zero in the app. |
| + nonisolated(unsafe) public static var engineStartDelay: TimeInterval = 0 |
| + |
| + public struct Report: Sendable { |
| + /// What the test window said while it started, and when (seconds from |
| + /// the start of the run). |
| + public var phases: [(at: Double, phase: MicStartPhase)] = [] |
| + /// Seconds until `open` (or the join's audio start) returned. |
| + public var startSeconds: Double = 0 |
| + /// Whether the module was asked for the microphone. |
| + public var asked = false |
| + /// Whether the module was still recording once it had all settled. |
| + public var recordingAfter = false |
| + public var problem: String? |
| + public var detail = "" |
| + } |
| + |
| + /// Test audio before a call, on the device module: opened, and closed |
| + /// once started — or, with `closeAfter`, closed partway through. |
| + @MainActor public static func testAudio(closeAfter: Double? = nil) async -> Report { |
| + let began = CaptureDenoiser.now() |
| + var report = Report() |
| + #if os(macOS) |
| + let routes = AudioRoutes(defaults: UserDefaults(suiteName: "calls-stall-\(UUID().uuidString)")!) |
| + #else |
| + let routes = AudioRoutes() |
| + #endif |
| + let capture = StandaloneCapture(routes: routes, input: nil, reduceNoise: true, assumePermitted: true) |
| + let test = AudioSelfTest(capture: capture, player: EnginePlayer(), routes: routes) |
| + var phases: [(Double, MicStartPhase)] = [] |
| + let watch = test.$startPhase.sink { phases.append((CaptureDenoiser.now() - began, $0)) } |
| + if let closeAfter { |
| + Task { @MainActor in |
| + try? await Task.sleep(for: .milliseconds(Int(closeAfter * 1000))) |
| + test.close() |
| + } |
| + } |
| + await test.open() |
| + report.startSeconds = CaptureDenoiser.now() - began |
| + report.problem = test.micProblem |
| + test.close() |
| + // The stop is queued behind the start; let it run. |
| + await AudioWork.drain() |
| + try? await Task.sleep(for: .milliseconds(200)) |
| + await AudioWork.drain() |
| + watch.cancel() |
| + report.phases = phases |
| + report.asked = capture.audio?.recordingRequested ?? false |
| + let module = capture.audio?.module |
| + report.recordingAfter = await AudioWork.run { module?.isRecording ?? false } |
| + report.detail = "suppressor \(test.suppressorName)" |
| + return report |
| + } |
| + |
| + /// A call's audio as a join starts it: the media built as `Call.join` |
| + /// builds it (MediaEngine.make), the camera and microphone tracks made |
| + /// (`prepare`), the first sending and receiving streams negotiated on |
| + /// the call's factory through PeerCalls, as MediaPeer negotiates them, |
| + /// and then WebRTC's worker thread opening the microphone for the first |
| + /// sending stream (AudioState::AddSendingStream), as it does once a |
| + /// connection is up. While the worker is held there, the main thread |
| + /// does what a call's does: reads the meters and the readout ten times |
| + /// a second, and — half a second in — negotiates a second person's |
| + /// connection. |
| + @MainActor public static func callJoin(camera: VideoCapture) async -> Report { |
| + let began = CaptureDenoiser.now() |
| + var report = Report() |
| + #if os(macOS) |
| + let call = Call(playAudio: false, audioInput: nil) |
| + #else |
| + let call = Call(playAudio: false) |
| + #endif |
| + let media = await MediaEngine.make(call: call) |
| + do { |
| + try await media.prepare(capture: camera) |
| + } catch { |
| + report.problem = "prepare: \(error.localizedDescription)" |
| + } |
| + guard let track = media.localAudio else { |
| + report.problem = "no microphone track" |
| + return report |
| + } |
| + var loops: [SelfTestLoopback] = [] |
| + do { |
| + loops.append(try await SelfTestLoopback.make(factory: media.factory, track: track)) |
| + } catch { |
| + report.problem = "negotiation: \(error.localizedDescription)" |
| + } |
| + let module = media.audio.module |
| + let opened = DispatchSemaphore(value: 0) |
| + // WebRTC's own start, on its own thread: not the app's to call. |
| + DispatchQueue.global(qos: .userInitiated).async { |
| + if module.initRecording() == 0 { _ = module.startRecording() } |
| + opened.signal() |
| + } |
| + let deadline = CaptureDenoiser.now() + AudioStartProbe.engineStartDelay + 1 |
| + var tick = 0 |
| + while CaptureDenoiser.now() < deadline { |
| + try? await Task.sleep(for: .milliseconds(100)) |
| + tick += 1 |
| + // The call's main-thread reads each tick (sampleMic, the readout). |
| + _ = media.audio.capturing |
| + _ = media.audio.report() |
| + _ = media.audio.denoiser.level.take() |
| + if tick == 3 { _ = media.audio.diagnostics() } |
| + if tick == 5 { |
| + do { |
| + loops.append(try await SelfTestLoopback.make(factory: media.factory, track: track)) |
| + } catch { |
| + report.problem = "second negotiation: \(error.localizedDescription)" |
| + } |
| + } |
| + } |
| + await AudioWork.run { opened.wait() } |
| + report.startSeconds = CaptureDenoiser.now() - began |
| + report.asked = media.audio.recordingRequested |
| + for loop in loops { loop.close() } |
| + AudioWork.post { _ = module.stopRecording() } |
| + media.close() |
| + await AudioWork.drain() |
| + report.recordingAfter = await AudioWork.run { module.isRecording } |
| + report.detail = "\(loops.count) connections negotiated" |
| + return report |
| + } |
| +} |
diff --git a/native/apple/Sources/CallsKit/AudioWork.swift b/native/apple/Sources/CallsKit/AudioWork.swift| new file mode 100644 |
| index 0000000..ccf38ec |
| --- /dev/null |
| +++ b/native/apple/Sources/CallsKit/AudioWork.swift |
| @@ -0,0 +1,258 @@ |
| +import Foundation |
| +import LiveKitWebRTC |
| +import os |
| + |
| +// Where the audio stack's slow work runs: never on the main thread. |
| +// |
| +// Paul, 0.3.8, MacBook Air, AirPods Pro: "after I open the Test window, |
| +// there's a pause, then a beachball, then it works." Measured on an M1 Max |
| +// with a main-thread watchdog (now `CallsInterop --audio-start-stall`), |
| +// 0.3.8's test held the main thread three times on its way to a running |
| +// microphone: |
| +// |
| +// 1. Building the audio (CallAudio): 166–200 ms. WebRTC's peer connection |
| +// factory with LiveKit's engine module, 100–135 ms; the module's first |
| +// device list, read only to be logged, 61–68 ms. |
| +// 2. The in-memory loopback's answer, applied: 188–229 ms. Applying it |
| +// starts the engine's playout on WebRTC's worker thread, and |
| +// `setLocalDescription` waits for that before it returns, completion |
| +// handler or not — every RTCPeerConnection call is a blocking hop to |
| +// WebRTC's own threads. |
| +// 3. `initRecording`, which waits on the worker thread while the engine |
| +// starts with the microphone. Here, with no microphone permission, 25 |
| +// ms. With AirPods it is as long as the headset takes to move from A2DP |
| +// to its hands-free profile, often one to two seconds: with that start |
| +// simulated at 1.5 s the main thread stalled for 1.5–1.7 s. That is the |
| +// beachball; 1 and 2 are the pause before it. |
| +// |
| +// A call's join had the same shape (1.3–1.5 s with the switch simulated), |
| +// and more: while the worker thread is starting the engine, *every* call |
| +// into the engine module waits for it — `isRecording`, `isPlaying`, the |
| +// device lists, the factory's processing state, a track's |
| +// `isEnabled = false` (mute), its processing options — 0.9 to 1.2 s each, |
| +// and a call's meters read some of them ten times a second. |
| +// |
| +// None of it is the main thread's business. It all runs here, on one |
| +// serial queue, in the order it was asked for; the main actor awaits the |
| +// result and only ever sets what the window shows. What the main thread |
| +// needs to know about the module it reads from ModuleState. |
| + |
| +/// The audio stack's serial queue: the factory and its engine module, the |
| +/// engine's start and stop, and the peer connections' calls. |
| +/// |
| +/// Serial on purpose. A test closed while its microphone is still |
| +/// starting queues its stop behind the start, so the stop always sees the |
| +/// microphone it has to close; nothing is left recording. |
| +enum AudioWork { |
| + static let queue = DispatchQueue(label: "net.amadan.oficina.calls.audio", qos: .userInitiated) |
| + |
| + /// Runs `body` on the audio queue and hands its result back. |
| + static func run<T>(_ body: @escaping () -> T) async -> T { |
| + await withCheckedContinuation { (c: CheckedContinuation<T, Never>) in |
| + queue.async { c.resume(returning: body()) } |
| + } |
| + } |
| + |
| + static func run<T>(_ body: @escaping () throws -> T) async throws -> T { |
| + try await withCheckedThrowingContinuation { (c: CheckedContinuation<T, Error>) in |
| + queue.async { |
| + do { c.resume(returning: try body()) } catch { c.resume(throwing: error) } |
| + } |
| + } |
| + } |
| + |
| + /// Queues `body` and does not wait for it: a stop, a close, a log line |
| + /// that has to read the module. |
| + static func post(_ body: @escaping () -> Void) { |
| + queue.async(execute: body) |
| + } |
| + |
| + /// Waits for everything queued so far. For tests and the harness. |
| + static func drain() async { |
| + await run {} |
| + } |
| +} |
| + |
| +/// A peer connection's calls, made from the audio queue. |
| +/// |
| +/// Every RTCPeerConnection method is a blocking hop to one of WebRTC's |
| +/// own threads, completion handler or not, and applying a description |
| +/// waits for the worker thread — which may be busy starting the audio |
| +/// engine for seconds. So a call makes them from here and awaits the |
| +/// answer; the main thread never waits on WebRTC. |
| +enum PeerCalls { |
| + static func describe( |
| + _ pc: RTCPeerConnection, answer: Bool, _ constraints: RTCMediaConstraints |
| + ) async throws -> RTCSessionDescription { |
| + try await withCheckedThrowingContinuation { c in |
| + AudioWork.queue.async { |
| + let completion: @Sendable (RTCSessionDescription?, Error?) -> Void = { desc, error in |
| + if let desc { c.resume(returning: desc) } else { c.resume(throwing: error ?? CancellationError()) } |
| + } |
| + if answer { |
| + pc.answer(for: constraints, completionHandler: completion) |
| + } else { |
| + pc.offer(for: constraints, completionHandler: completion) |
| + } |
| + } |
| + } |
| + } |
| + |
| + static func set(_ pc: RTCPeerConnection, local: Bool, _ desc: RTCSessionDescription) async throws { |
| + try await withCheckedThrowingContinuation { (c: CheckedContinuation<Void, Error>) in |
| + AudioWork.queue.async { |
| + let done: @Sendable (Error?) -> Void = { error in |
| + if let error { c.resume(throwing: error) } else { c.resume() } |
| + } |
| + if local { pc.setLocalDescription(desc, completionHandler: done) } else { |
| + pc.setRemoteDescription(desc, completionHandler: done) |
| + } |
| + } |
| + } |
| + } |
| + |
| + static func add(_ pc: RTCPeerConnection, _ candidate: RTCIceCandidate) async throws { |
| + try await withCheckedThrowingContinuation { (c: CheckedContinuation<Void, Error>) in |
| + AudioWork.queue.async { |
| + pc.add(candidate) { error in if let error { c.resume(throwing: error) } else { c.resume() } } |
| + } |
| + } |
| + } |
| + |
| + static func statistics(_ pc: RTCPeerConnection) async -> RTCStatisticsReport { |
| + await withCheckedContinuation { c in |
| + AudioWork.queue.async { pc.statistics { c.resume(returning: $0) } } |
| + } |
| + } |
| + |
| + static func statistics(_ pc: RTCPeerConnection, for receiver: RTCRtpReceiver) async -> RTCStatisticsReport { |
| + await withCheckedContinuation { c in |
| + AudioWork.queue.async { pc.statistics(for: receiver) { c.resume(returning: $0) } } |
| + } |
| + } |
| + |
| + /// Anything else, read or done on the audio queue. |
| + static func read<T>(_ pc: RTCPeerConnection, _ body: @escaping (RTCPeerConnection) -> T) async -> T { |
| + await AudioWork.run { body(pc) } |
| + } |
| + |
| + /// Closes it after whatever is queued for it; not waited for. Closing |
| + /// stops the audio engine when it is the last connection, and that can |
| + /// take as long as a start. |
| + static func close(_ pc: RTCPeerConnection) { |
| + AudioWork.post { pc.close() } |
| + } |
| +} |
| + |
| +#if canImport(DeepFilter) |
| + /// DeepFilterNet's model, loaded before anyone needs it. |
| + /// |
| + /// Loading takes about a third of a second on an M1 Max (and longer on |
| + /// an Air) of one core, and a call or test that has to load it starts on |
| + /// RNNoise and moves to DeepFilterNet when it is ready. So one is loaded |
| + /// in the background a few seconds after launch, at utility priority, |
| + /// and the first test or call takes it ready-made. |
| + /// |
| + /// Memory is the cost: a loaded model is about 88 MB resident. So only |
| + /// one is kept spare; a spare nobody has taken for fifteen minutes is |
| + /// let go, and the next user loads its own as before (off the main |
| + /// thread, starting on RNNoise). Taking the spare does not replace it at |
| + /// once — a call would then hold two — but when its user is done, a new |
| + /// spare is loaded for the next one. A model is never handed from one |
| + /// user to the next: its state holds the last audio it heard, and there |
| + /// is no way to clear it. |
| + public enum DeepFilterPreload { |
| + private struct State { |
| + var spare: DeepFilterSuppressor? |
| + var loading = false |
| + var generation = 0 |
| + } |
| + // The suppressor is only ever touched by whoever holds it; the lock |
| + // guards the handing over. |
| + nonisolated(unsafe) private static var state = State() |
| + private static let lock = NSLock() |
| + private static let background = DispatchQueue(label: "net.amadan.oficina.calls.deepfilter", qos: .utility) |
| + static let idleRelease: TimeInterval = 15 * 60 |
| + |
| + /// After launch: loads a spare in `delay` seconds, if there is a model |
| + /// and no spare yet. |
| + public static func preload(after delay: TimeInterval = 3) { |
| + background.asyncAfter(deadline: .now() + delay) { load() } |
| + } |
| + |
| + /// The spare, if one is ready; nil otherwise, and the caller loads. |
| + static func take() -> DeepFilterSuppressor? { |
| + lock.lock() |
| + defer { lock.unlock() } |
| + let spare = state.spare |
| + state.spare = nil |
| + state.generation += 1 |
| + if spare != nil { AudioLog.info("DeepFilterNet: using the preloaded model") } |
| + return spare |
| + } |
| + |
| + /// A user of a model is done with it: a fresh spare, a little later. |
| + static func finished() { |
| + preload(after: 5) |
| + } |
| + |
| + /// Whether a spare is loaded (for the tests). |
| + static var ready: Bool { |
| + lock.lock() |
| + defer { lock.unlock() } |
| + return state.spare != nil |
| + } |
| + |
| + private static func load() { |
| + lock.lock() |
| + guard state.spare == nil, !state.loading, let model = DeepFilterSuppressor.model else { |
| + lock.unlock() |
| + return |
| + } |
| + state.loading = true |
| + lock.unlock() |
| + let started = CaptureDenoiser.now() |
| + let loaded = DeepFilterSuppressor(model: model) |
| + lock.lock() |
| + state.loading = false |
| + state.spare = loaded |
| + state.generation += 1 |
| + let generation = state.generation |
| + lock.unlock() |
| + guard loaded != nil else { return } |
| + AudioLog.info( |
| + "DeepFilterNet: preloaded in \(Int((CaptureDenoiser.now() - started) * 1000)) ms, kept for the next test or call") |
| + background.asyncAfter(deadline: .now() + idleRelease) { release(ifStill: generation) } |
| + } |
| + |
| + private static func release(ifStill generation: Int) { |
| + lock.lock() |
| + guard state.generation == generation, state.spare != nil else { |
| + lock.unlock() |
| + return |
| + } |
| + let spare = state.spare |
| + state.spare = nil |
| + lock.unlock() |
| + _ = spare |
| + AudioLog.info("DeepFilterNet: preloaded model unused for \(Int(idleRelease / 60)) minutes; released") |
| + } |
| + } |
| +#endif |
| + |
| +/// What the app does for its audio once it has launched: loads the |
| +/// noise suppressor's model in the background (DeepFilterPreload), so the |
| +/// first test or call does not wait for it. Nothing on iPhone, which runs |
| +/// RNNoise (made in well under a millisecond). |
| +@MainActor public enum AudioWarmUp { |
| + private static var done = false |
| + |
| + /// Once per launch; later calls (a window shown again) do nothing. |
| + public static func afterLaunch() { |
| + guard !done else { return } |
| + done = true |
| + #if canImport(DeepFilter) |
| + DeepFilterPreload.preload(after: 3) |
| + #endif |
| + } |
| +} |
diff --git a/native/apple/Sources/CallsKit/Call.swift b/native/apple/Sources/CallsKit/Call.swift| index 6484edb..a0fb899 100644 |
| --- a/native/apple/Sources/CallsKit/Call.swift |
| +++ b/native/apple/Sources/CallsKit/Call.swift |
| @@ -52,7 +52,7 @@ private final class Events: NSObject, BridgeListenerProtocol { |
| didSet { |
| guard screenMode != oldValue else { return } |
| ScreenShareMode.remembered = screenMode |
| - media.screenMode = screenMode |
| + built?.screenMode = screenMode |
| } |
| } |
| private var screenReports: [String: ScreenSendReport] = [:] |
| @@ -95,8 +95,9 @@ private final class Events: NSObject, BridgeListenerProtocol { |
| @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() |
| + guard let built else { return } |
| + built.audio.reduceNoise = reduceNoise |
| + audioProcessing = built.audio.report() |
| } |
| } |
| static let reduceNoiseKey = "reduceBackgroundNoise" |
| @@ -123,17 +124,28 @@ private final class Events: NSObject, BridgeListenerProtocol { |
| fileprivate var epoch = UUID() |
| private var bridge: BridgeClient? |
| private var events: Events? |
| - private lazy var media = MediaEngine(call: self) |
| + /// The call's media, made by the first join (MediaEngine.make, off the |
| + /// main thread) and kept for the next. |
| + private var built: MediaEngine? |
| + /// The media, made here and now only if something reaches for it |
| + /// before any join has — which nothing in the app does. |
| + private var media: MediaEngine { |
| + if let built { return built } |
| + AudioLog.info("call media made on the main thread, before a join") |
| + let made = MediaEngine(call: self) |
| + built = made |
| + return made |
| + } |
| /// The audio test's view of this call's microphone (AudioSelfTestModel): |
| /// the capture hook, nil outside a call, and whether WebRTC has opened it. |
| - var captureDenoiser: CaptureDenoiser? { bridge == nil ? nil : media.audio.denoiser } |
| - var captureStarted: Bool { bridge != nil && media.audio.capturing } |
| + var captureDenoiser: CaptureDenoiser? { bridge == nil ? nil : built?.audio.denoiser } |
| + var captureStarted: Bool { bridge != nil && built?.audio.capturing == true } |
| /// For "Copy audio diagnostics" in the panel's ⓘ and in Test audio |
| /// during a call: this call's microphone, devices, engine and hook. |
| func audioDiagnosticSections() -> [(String, [String])] { |
| var lines = [ |
| - "in a call: \(bridge != nil); muted \(muted); microphone track \(bridge != nil && media.localAudio != nil)", |
| + "in a call: \(bridge != nil); muted \(muted); microphone track \(bridge != nil && built?.localAudio != nil)", |
| "people here: \(self.state?.peers.count ?? 0)", |
| "microphone meter: \(levels.mic.silent ? "silent" : "step \(levels.mic.step)"), speaking \(levels.mic.speaking), sending from \(levels.mic.source.isEmpty ? "-" : levels.mic.source)", |
| ] |
| @@ -145,7 +157,7 @@ private final class Events: NSObject, BridgeListenerProtocol { |
| + ["output: \(audio.outputName)"] |
| #endif |
| var sections: [(String, [String])] = [("Call", lines), ("Devices", routeLines)] |
| - if bridge != nil { sections.append(("Audio engine and processing", media.audio.diagnostics())) } |
| + if bridge != nil, let built { sections.append(("Audio engine and processing", built.audio.diagnostics())) } |
| return sections |
| } |
| @@ -193,6 +205,13 @@ private final class Events: NSObject, BridgeListenerProtocol { |
| receivedChatCount = 0 |
| title = "Calls" |
| standing = "" |
| + // The factory and its audio device module, built on the audio queue |
| + // the first time: the call's window is up while it is made. |
| + if built == nil { |
| + let made = await MediaEngine.make(call: self) |
| + if built == nil { built = made } |
| + } |
| + guard epoch == joiningEpoch, bridge === client else { throw CancellationError() } |
| try await media.prepare(capture: capture) |
| guard epoch == joiningEpoch, bridge === client else { throw CancellationError() } |
| localTrack = media.localVideo |
| @@ -213,7 +232,7 @@ private final class Events: NSObject, BridgeListenerProtocol { |
| let old = bridge |
| bridge = nil |
| events = nil |
| - media.close() |
| + built?.close() |
| // This call's microphone and speaker choices end with it (see AudioRoutes). |
| #if os(macOS) |
| audio.reset() |
| @@ -255,12 +274,20 @@ private final class Events: NSObject, BridgeListenerProtocol { |
| } |
| public func mute() { |
| muted.toggle() |
| - media.localAudio?.isEnabled = !muted |
| + // On the audio queue: switching a track waits out an engine start, |
| + // 1,136 ms measured on the main thread with a Bluetooth switch |
| + // simulated — and the first thing someone does as they join is often |
| + // to mute. |
| + let track = media.localAudio |
| + let on = !muted |
| + AudioWork.post { track?.isEnabled = on } |
| reportMedia() |
| } |
| public func toggleCamera() { |
| cameraOff.toggle() |
| - media.localVideo?.isEnabled = !cameraOff |
| + let track = media.localVideo |
| + let on = !cameraOff |
| + AudioWork.post { track?.isEnabled = on } |
| reportMedia() |
| } |
| @@ -297,7 +324,7 @@ private final class Events: NSObject, BridgeListenerProtocol { |
| @Published public private(set) var cameras: [CameraDevice] = [] |
| public func refreshDevices() { |
| - let available = media.cameras |
| + let available = built?.cameras ?? [] |
| if cameras != available { cameras = available } |
| if let currentCamera, !available.contains(where: { $0.id == currentCamera }) { |
| self.currentCamera = nil |
diff --git a/native/apple/Sources/CallsKit/CallAudio.swift b/native/apple/Sources/CallsKit/CallAudio.swift| index 5c24b5f..f3d53df 100644 |
| --- a/native/apple/Sources/CallsKit/CallAudio.swift |
| +++ b/native/apple/Sources/CallsKit/CallAudio.swift |
| @@ -105,6 +105,16 @@ public struct ProcessingReport: Equatable, Sendable { |
| /// made. |
| @MainActor final class CallAudio { |
| let factory: RTCPeerConnectionFactory |
| + /// The factory's audio device module, fetched once, off the main |
| + /// thread. Fetching it, and every property and call on it, is a blocking |
| + /// hop to WebRTC's worker thread, which is held for the whole of an |
| + /// engine start, a Bluetooth switch included (1,544 ms measured on the |
| + /// main thread with the switch simulated). So it is only ever asked on |
| + /// the audio queue; the main thread reads `state`. |
| + let module: RTCAudioDeviceModule |
| + /// The module's state for the main thread to read: never by asking the |
| + /// module, whose every property waits out an engine start (ModuleState). |
| + let state: ModuleState |
| /// Nil for the iPhone tests' silent device, which takes the constructor |
| /// that has no processing module. |
| let processing: RTCDefaultAudioProcessingModule? |
| @@ -125,43 +135,57 @@ public struct ProcessingReport: Equatable, Sendable { |
| var enginePin: AudioEnginePin { devices.pin } |
| #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 |
| - ) { |
| + /// What building a CallAudio takes, made off the main thread (`make`): |
| + /// the peer connection factory with its audio device module and |
| + /// processing module, and the capture hook. Making the factory alone |
| + /// took 100–135 ms on an M1 Max. |
| + struct Parts: @unchecked Sendable { |
| + let factory: RTCPeerConnectionFactory |
| + let module: RTCAudioDeviceModule |
| + let state: ModuleState |
| + let processing: RTCDefaultAudioProcessingModule? |
| + let denoiser: CaptureDenoiser |
| + #if os(macOS) |
| + let devices: EngineWatch |
| + #endif |
| + } |
| + |
| + /// Builds the parts. Nonisolated, and slow: `make` runs it on the audio |
| + /// queue. `routes` is only held (weakly) by the module's observer, which |
| + /// reaches it on the main queue. |
| + nonisolated static func parts( |
| + routes: AudioRoutes, defaults: UserDefaults, audioDevice: RTCAudioDevice?, input: AudioInputSource?, |
| + denoiser: CaptureDenoiser? |
| + ) -> Parts { |
| let encoder = RTCDefaultVideoEncoderFactory() |
| let decoder = RTCDefaultVideoDecoderFactory() |
| - self.reduceNoise = reduceNoise |
| - denoiser = CaptureDenoiser(suppressor: CallAudio.makeSuppressor()) |
| + let denoiser = 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( |
| + let factory = RTCPeerConnectionFactory( |
| encoderFactory: encoder, decoderFactory: decoder, audioDevice: audioDevice) |
| - return |
| + let module = factory.audioDeviceModule |
| + return Parts( |
| + factory: factory, module: module, state: ModuleState(factory: factory, module: module), processing: nil, |
| + denoiser: denoiser) |
| } |
| 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( |
| + let factory = RTCPeerConnectionFactory( |
| audioDeviceModuleType: .platformDefault, bypassVoiceProcessing: false, |
| encoderFactory: encoder, decoderFactory: decoder, audioProcessingModule: processing) |
| + let module = factory.audioDeviceModule |
| + return Parts( |
| + factory: factory, module: module, state: ModuleState(factory: factory, module: module), |
| + processing: processing, denoiser: denoiser) |
| #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( |
| + let factory = RTCPeerConnectionFactory( |
| audioDeviceModuleType: .audioEngine, bypassVoiceProcessing: false, |
| encoderFactory: encoder, decoderFactory: decoder, audioProcessingModule: processing) |
| let module = factory.audioDeviceModule |
| @@ -174,17 +198,75 @@ public struct ProcessingReport: Equatable, Sendable { |
| // module, and is what runs now. |
| _ = module.setPlatformVoiceProcessingAllowed(false) |
| let pin = AudioEnginePin() |
| - devices = EngineWatch( |
| + let devices = EngineWatch( |
| routes: routes, module: module, input: input, pin: pin, |
| channels: InputChannelMapper( |
| - defaults: routes.defaults, layout: pin.inputLayout, explicitInput: { pin.choices.input })) |
| + defaults: defaults, layout: pin.inputLayout, explicitInput: { pin.choices.input })) |
| module.observer = devices |
| if input != nil { _ = module.setManualRenderingMode(true) } |
| + return Parts( |
| + factory: factory, module: module, state: ModuleState(factory: factory, module: module), |
| + processing: processing, denoiser: denoiser, devices: devices) |
| + #endif |
| + } |
| + |
| + /// Builds a CallAudio with its slow half on the audio queue: the main |
| + /// thread only attaches the result. What a call and the audio test use. |
| + static func make( |
| + routes: AudioRoutes, audioDevice: RTCAudioDevice?, input: AudioInputSource? = nil, |
| + reduceNoise: Bool = true, denoiser: CaptureDenoiser? = nil |
| + ) async -> CallAudio { |
| + #if os(macOS) |
| + let defaults = routes.defaults |
| + #else |
| + let defaults = UserDefaults.standard |
| + #endif |
| + let parts = await AudioWork.run { |
| + CallAudio.parts(routes: routes, defaults: defaults, audioDevice: audioDevice, input: input, denoiser: denoiser) |
| + } |
| + return CallAudio(parts: parts, routes: routes, reduceNoise: reduceNoise) |
| + } |
| + |
| + /// `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. |
| + /// |
| + /// Builds on the calling thread; the app uses `make`. |
| + convenience init( |
| + routes: AudioRoutes, audioDevice: RTCAudioDevice?, input: AudioInputSource? = nil, |
| + reduceNoise: Bool = true |
| + ) { |
| + #if os(macOS) |
| + let defaults = routes.defaults |
| + #else |
| + let defaults = UserDefaults.standard |
| + #endif |
| + self.init( |
| + parts: CallAudio.parts(routes: routes, defaults: defaults, audioDevice: audioDevice, input: input, denoiser: nil), |
| + routes: routes, reduceNoise: reduceNoise) |
| + } |
| + |
| + /// Attaches built parts: cheap, and on the main actor. |
| + init(parts: Parts, routes: AudioRoutes, reduceNoise: Bool) { |
| + factory = parts.factory |
| + module = parts.module |
| + state = parts.state |
| + processing = parts.processing |
| + denoiser = parts.denoiser |
| + self.reduceNoise = reduceNoise |
| + #if os(iOS) |
| + plan = ProcessingPlan.plan(iPhone: true, reduceNoise: reduceNoise, rate: nil, voiceIsolation: false) |
| + #else |
| + plan = ProcessingPlan.plan(iPhone: false, reduceNoise: reduceNoise, rate: nil, voiceIsolation: false) |
| + devices = parts.devices |
| // Attached in manual rendering too, so the interop harness drives the |
| // same routing code as the app; no device is opened either way. |
| let watch = devices |
| - routes.attach(module, pin: pin, inputRate: { watch.lastInputRate }) |
| + routes.attach(module, state: state, pin: devices.pin, inputRate: { watch.lastInputRate }) |
| #endif |
| + guard let processing else { return } |
| // The listener first, then the delegate: attaching the delegate can |
| // initialise it at once, and that report used to go nowhere. |
| denoiser.rateChanged = { [weak self] rate in |
| @@ -200,10 +282,27 @@ public struct ProcessingReport: Equatable, Sendable { |
| // 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) |
| + upgraded = CallAudio.upgrade(denoiser) |
| denoiser.enabled = plan.suppressor |
| } |
| + /// Whether this CallAudio's suppressor is (or is becoming) DeepFilterNet, |
| + /// so a fresh spare is loaded for the next one when it is done. |
| + private var upgraded = false |
| + |
| + deinit { |
| + #if canImport(DeepFilter) |
| + if upgraded { DeepFilterPreload.finished() } |
| + #endif |
| + // The factory goes on the audio queue, after everything queued for |
| + // it: a stop or a close queued there must never find its module gone |
| + // (it did, in the harness: a crash in `stopRecording`). Its teardown, |
| + // which stops the engine, then happens there too, not on the main |
| + // thread. |
| + let held: (RTCPeerConnectionFactory, RTCAudioDeviceModule) = (factory, module) |
| + AudioWork.post { withExtendedLifetime(held) {} } |
| + } |
| + |
| /// Whether the microphone is capturing. The module opens it when the |
| /// first connection starts sending, not when the track is made. Either |
| /// the module says it is recording, or the capture hook has been handed |
| @@ -211,7 +310,7 @@ public struct ProcessingReport: Equatable, Sendable { |
| /// interop gate's manual rendering shows up). |
| var capturing: Bool { |
| #if os(macOS) |
| - return factory.audioDeviceModule.isRecording || denoiser.buffersSeen > buffersAtStart |
| + return state.read().isRecording || denoiser.buffersSeen > buffersAtStart |
| #else |
| // Not asked on iPhone: its module is the platform one, whose |
| // recording state has not been checked against a device here. |
| @@ -229,6 +328,11 @@ public struct ProcessingReport: Equatable, Sendable { |
| /// Set while this CallAudio asked the module to record by itself (the |
| /// audio test before a call); undone by `stopRecordingAlone`. |
| private var recordingAlone = false |
| + /// Whether the module really was started by this CallAudio's ask, as |
| + /// opposed to already recording. Only read and written on the audio |
| + /// queue, where the start and the stop run in order. |
| + private final class Started: @unchecked Sendable { var byUs = false } |
| + private var started = Started() |
| /// Opens the microphone outside a call, as a call's first sending stream |
| /// does: the module's `initRecording` then `startRecording`, which is |
| @@ -243,27 +347,42 @@ public struct ProcessingReport: Equatable, Sendable { |
| /// 2026-09-26). The interop harness did not see it: in manual rendering |
| /// the module runs its input whenever its output runs. |
| /// |
| + /// On the audio queue, never the main thread (0.3.9): `initRecording` |
| + /// waits on WebRTC's worker thread while the engine starts, and with a |
| + /// Bluetooth headset that includes its move to the hands-free profile, |
| + /// one to two seconds. In 0.3.8 the main thread waited with it. |
| + /// |
| /// - Parameter ask: false only for the harness's control run, which |
| /// shows that without it the module is never asked to record. |
| /// - Returns: 0, or the module's first error (-9000: no microphone |
| /// permission, as the module sees it). |
| @discardableResult |
| - func startRecordingAlone(ask: Bool = true) -> Int { |
| - let module = factory.audioDeviceModule |
| + func startRecordingAlone(ask: Bool = true) async -> Int { |
| guard ask else { |
| AudioLog.info("test: not asking the module to record (control run)") |
| return 0 |
| } |
| - guard !module.isRecording else { |
| + let module = self.module |
| + // Set before the wait, so a stop asked for during it is queued, and |
| + // runs after the start, and closes what it opened. |
| + recordingAlone = true |
| + let started = self.started |
| + let began = CaptureDenoiser.now() |
| + let (initialised, result, recording, already) = await AudioWork.run { () -> (Int, Int, Bool, Bool) in |
| + guard !module.isRecording else { return (0, 0, true, true) } |
| + let initialised = module.initRecording() |
| + let result = initialised == 0 ? module.startRecording() : 0 |
| + started.byUs = true |
| + return (initialised, result, module.isRecording, false) |
| + } |
| + if already { |
| AudioLog.info("test: the module is already recording") |
| - return 0 |
| + } else { |
| + AudioLog.info( |
| + "test: asked the module to record (init \(initialised), start \(result)) in \(Int((CaptureDenoiser.now() - began) * 1000)) ms off the main thread; recording \(recording)" |
| + ) |
| } |
| - let initialised = module.initRecording() |
| - let started = initialised == 0 ? module.startRecording() : 0 |
| - recordingAlone = true |
| - AudioLog.info( |
| - "test: asked the module to record (init \(initialised), start \(started)); recording \(module.isRecording)") |
| - return initialised != 0 ? initialised : started |
| + return initialised != 0 ? initialised : result |
| } |
| /// A module error, in words, for the audio test. |
| @@ -277,11 +396,24 @@ public struct ProcessingReport: Equatable, Sendable { |
| } |
| } |
| + /// Closes what `startRecordingAlone` opened, on the audio queue after |
| + /// it: never waited for here (the engine's stop can take as long as its |
| + /// start, a headset moving back to A2DP). |
| func stopRecordingAlone() { |
| guard recordingAlone else { return } |
| recordingAlone = false |
| - let result = factory.audioDeviceModule.stopRecording() |
| - AudioLog.info("test: stopped the module's recording (\(result))") |
| + let module = self.module |
| + let factory = self.factory |
| + let started = self.started |
| + self.started = Started() |
| + AudioWork.post { |
| + // The module is only as alive as its factory. |
| + withExtendedLifetime(factory) {} |
| + guard started.byUs else { return } |
| + started.byUs = false |
| + let result = module.stopRecording() |
| + AudioLog.info("test: stopped the module's recording (\(result))") |
| + } |
| } |
| /// Whether the module has been asked to enable its microphone since |
| @@ -298,17 +430,29 @@ public struct ProcessingReport: Equatable, Sendable { |
| } |
| /// The suppressor a call starts on. See `upgrade`. |
| - static func makeSuppressor() -> NoiseSuppressor { RNNoiseSuppressor() } |
| + nonisolated 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) { |
| + /// |
| + /// The model preloaded after launch is taken when there is one |
| + /// (DeepFilterPreload), so the suppressor is DeepFilterNet from the |
| + /// first buffer. Returns whether it is, or will be, DeepFilterNet. |
| + private static func upgrade(_ denoiser: CaptureDenoiser) -> Bool { |
| #if canImport(DeepFilter) |
| - guard let model = DeepFilterSuppressor.model else { return } |
| + if denoiser.engineName == "DeepFilterNet 3" { return true } |
| + if let ready = DeepFilterPreload.take() { |
| + denoiser.replace(with: ready) |
| + return true |
| + } |
| + guard let model = DeepFilterSuppressor.model else { return false } |
| DispatchQueue.global(qos: .userInitiated).async { |
| if let deep = DeepFilterSuppressor(model: model) { denoiser.replace(with: deep) } |
| } |
| + return true |
| + #else |
| + return false |
| #endif |
| } |
| @@ -343,17 +487,37 @@ public struct ProcessingReport: Equatable, Sendable { |
| 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 } |
| + // Set on the audio queue: setting a track's processing waits out an |
| + // engine start (1,061 ms measured on the main thread with a Bluetooth |
| + // switch simulated), and a rate change — which calls this — arrives |
| + // exactly then. Queued ahead of anything that sends the track, so the |
| + // options are still in place before it is ever sent. |
| + let plan = self.plan |
| + let factory = self.factory |
| + applied += 1 |
| + let generation = applied |
| + AudioWork.post { |
| + withExtendedLifetime(factory) {} |
| + var refused: String? |
| + 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 } |
| + } |
| + DispatchQueue.main.async { |
| + MainActor.assumeIsolated { [weak self] in |
| + guard let self, self.applied == generation else { return } |
| + self.refused = refused |
| + } |
| + } |
| } |
| } |
| + private var applied = 0 |
| static func options(_ plan: ProcessingPlan, automatic: Bool = false) -> RTCAudioProcessingOptions { |
| func component(_ place: ProcessingPlan.Where) -> RTCAudioProcessingComponentOptions { |
| @@ -375,10 +539,10 @@ public struct ProcessingReport: Equatable, Sendable { |
| /// For "Copy audio diagnostics": the module, the engine, the hook. |
| func diagnostics() -> [String] { |
| var lines: [String] = [] |
| - let module = factory.audioDeviceModule |
| + let module = state.read() |
| #if os(macOS) |
| lines.append( |
| - "module: manual rendering \(module.isManualRenderingMode), engine running \(module.isEngineRunning), playout initialised \(module.isPlayoutInitialized), playing \(module.isPlaying), recording initialised \(module.isRecordingInitialized), recording \(module.isRecording), microphone muted \(module.isMicrophoneMuted)" |
| + "module: manual rendering \(module.isManualRenderingMode), engine running \(module.isEngineRunning), playout initialised \(module.isPlayoutInitialized), playing \(module.isPlaying), recording initialised \(module.isRecordingInitialized), recording \(module.isRecording), microphone muted \(module.isMicrophoneMuted)\(module.stale ? " (as last read: the module is busy)" : "")" |
| ) |
| let choices = enginePin.choices |
| lines.append( |
| @@ -401,7 +565,11 @@ public struct ProcessingReport: Equatable, Sendable { |
| /// What is actually running, as WebRTC reports it — not what was asked. |
| func report() -> ProcessingReport { |
| - let state = factory.audioProcessingState |
| + guard let state = state.read().processing else { |
| + return ProcessingReport( |
| + suppression: denoiser.state, echoCancellation: "unknown", noiseSuppression: "unknown", |
| + gainControl: "unknown", refused: refused, started: capturing) |
| + } |
| func says(_ component: RTCAudioProcessingComponentState) -> String { |
| switch component.effective { |
| case .disabled: return "off" |
| @@ -428,7 +596,7 @@ public struct ProcessingReport: Equatable, Sendable { |
| /// for a real microphone — where the chosen input of a multi-channel one |
| /// is mapped (InputChannel.swift). Starting and stopping are watched so a |
| /// device the engine cannot start on is noticed rather than left silent. |
| - private final class EngineWatch: NSObject, RTCAudioDeviceModuleDelegate, @unchecked Sendable { |
| + final class EngineWatch: NSObject, RTCAudioDeviceModuleDelegate, @unchecked Sendable { |
| weak var routes: AudioRoutes? |
| weak var module: RTCAudioDeviceModule? |
| let input: AudioInputSource? |
| @@ -497,6 +665,14 @@ public struct ProcessingReport: Equatable, Sendable { |
| lock.lock() |
| requested = true |
| lock.unlock() |
| + // The harness's stand-in for a Bluetooth headset changing profile, |
| + // which holds up the engine's start here, on WebRTC's worker |
| + // thread. Zero in the app. |
| + let delay = AudioStartProbe.engineStartDelay |
| + if delay > 0, input == nil { |
| + AudioLog.info("harness: holding the engine's start \(Int(delay * 1000)) ms, as a Bluetooth switch does") |
| + Thread.sleep(forTimeInterval: delay) |
| + } |
| } |
| note("will enable: playout \(isPlayoutEnabled), recording \(isRecordingEnabled), voice processing \(isVoiceProcessingEnabled)") |
| guard input == nil, !engine.isInManualRenderingMode else { |
| @@ -571,16 +747,20 @@ public struct ProcessingReport: Equatable, Sendable { |
| // The module retries a start a few times and then gives up without |
| // telling its observer. Looked at once it has had time: an engine |
| // that is not running, with no stop since, did not start. |
| - DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in |
| - MainActor.assumeIsolated { |
| - guard let self, let module = self.module else { return } |
| - self.lock.lock() |
| - let now = (self.starts, self.stops) |
| - self.lock.unlock() |
| - guard now == seen, !module.isEngineRunning else { return } |
| - AudioLog.error("engine did not start on \(target.description)") |
| - self.note("did not start on \(target.description)") |
| - self.routes?.engineFailed(target, reason: "the audio engine would not start on it") |
| + // Asked on the audio queue: the module's answer waits out any start |
| + // in progress. |
| + AudioWork.queue.asyncAfter(deadline: .now() + 2) { [weak self] in |
| + guard let self, let module = self.module else { return } |
| + self.lock.lock() |
| + let now = (self.starts, self.stops) |
| + self.lock.unlock() |
| + guard now == seen, !module.isEngineRunning else { return } |
| + AudioLog.error("engine did not start on \(target.description)") |
| + self.note("did not start on \(target.description)") |
| + DispatchQueue.main.async { [weak self] in |
| + MainActor.assumeIsolated { |
| + self?.routes?.engineFailed(target, reason: "the audio engine would not start on it") |
| + } |
| } |
| } |
| return 0 |
| @@ -649,3 +829,90 @@ public struct ProcessingReport: Equatable, Sendable { |
| ) -> Int { 0 } |
| } |
| #endif |
| + |
| +/// The audio device module's state, for the main thread to read. |
| +/// |
| +/// Every property of LiveKit's module is a blocking hop to WebRTC's worker |
| +/// thread, and the worker holds on to it for the whole of an engine start |
| +/// or rebuild — a Bluetooth headset's profile switch included. Measured |
| +/// with that switch simulated at 1.5 s: `isRecording`, `isEngineRunning`, |
| +/// `isPlaying`, the device lists and the factory's processing state each |
| +/// held the main thread for 0.9 to 1.2 s. The meters read it ten times a |
| +/// second. |
| +/// |
| +/// So the main thread never asks the module. It asks this, which reads the |
| +/// module on a queue of its own and waits at most a few milliseconds for |
| +/// the answer; if the module is busy it gets the last answer, marked stale, |
| +/// and the fresh one is there for the next look. |
| +final class ModuleState: @unchecked Sendable { |
| + struct Snapshot: @unchecked Sendable { |
| + var isRecording = false |
| + var isPlaying = false |
| + var isEngineRunning = false |
| + var isManualRenderingMode = false |
| + var isPlayoutInitialized = false |
| + var isRecordingInitialized = false |
| + var isMicrophoneMuted = false |
| + var processing: RTCAudioProcessingState? |
| + /// The module did not answer in time: this is its last answer. |
| + var stale = false |
| + } |
| + |
| + private weak var factory: RTCPeerConnectionFactory? |
| + private weak var module: RTCAudioDeviceModule? |
| + private let queue = DispatchQueue(label: "net.amadan.oficina.calls.audio-state", qos: .userInitiated) |
| + private let lock = NSLock() |
| + private var latest = Snapshot() |
| + private var reading: DispatchGroup? |
| + /// How long a read on the main thread may wait for the module. |
| + static let patience: TimeInterval = 0.005 |
| + |
| + /// Reads once, here and now: made off the main thread with the parts. |
| + init(factory: RTCPeerConnectionFactory, module: RTCAudioDeviceModule) { |
| + self.factory = factory |
| + self.module = module |
| + latest = ModuleState.snapshot(factory: factory, module: module) |
| + } |
| + |
| + func read(patience: TimeInterval = ModuleState.patience) -> Snapshot { |
| + lock.lock() |
| + let group: DispatchGroup |
| + if let reading { |
| + group = reading |
| + } else { |
| + group = DispatchGroup() |
| + group.enter() |
| + reading = group |
| + queue.async { [self] in |
| + let fresh = factory.flatMap { f in module.map { ModuleState.snapshot(factory: f, module: $0) } } |
| + lock.lock() |
| + if let fresh { latest = fresh } |
| + reading = nil |
| + lock.unlock() |
| + group.leave() |
| + } |
| + } |
| + lock.unlock() |
| + let answered = group.wait(timeout: .now() + patience) == .success |
| + lock.lock() |
| + defer { lock.unlock() } |
| + var out = latest |
| + out.stale = !answered |
| + return out |
| + } |
| + |
| + private static func snapshot(factory: RTCPeerConnectionFactory, module: RTCAudioDeviceModule) -> Snapshot { |
| + var s = Snapshot() |
| + s.isRecording = module.isRecording |
| + s.isPlaying = module.isPlaying |
| + #if os(macOS) |
| + s.isEngineRunning = module.isEngineRunning |
| + s.isManualRenderingMode = module.isManualRenderingMode |
| + s.isPlayoutInitialized = module.isPlayoutInitialized |
| + s.isRecordingInitialized = module.isRecordingInitialized |
| + s.isMicrophoneMuted = module.isMicrophoneMuted |
| + #endif |
| + s.processing = factory.audioProcessingState |
| + return s |
| + } |
| +} |
diff --git a/native/apple/Sources/CallsKit/Media.swift b/native/apple/Sources/CallsKit/Media.swift| index 06b45c0..df3ea7d 100644 |
| --- a/native/apple/Sources/CallsKit/Media.swift |
| +++ b/native/apple/Sources/CallsKit/Media.swift |
| @@ -28,16 +28,35 @@ import LiveKitWebRTC |
| /// The audio device module and processing behind the factory. See CallAudio. |
| let audio: CallAudio |
| - init(call: Call) { |
| - self.call = call |
| + /// Builds on the calling thread: only for a call touched before it |
| + /// joined. A join uses `make`. |
| + convenience init(call: Call) { |
| RTCInitializeSSL() |
| #if os(iOS) |
| - audio = CallAudio(routes: call.audio, audioDevice: call.customAudioDevice) |
| + self.init(call: call, audio: CallAudio(routes: call.audio, audioDevice: call.customAudioDevice)) |
| #else |
| - audio = CallAudio(routes: call.audio, audioDevice: nil, input: call.audioInput) |
| + self.init(call: call, audio: CallAudio(routes: call.audio, audioDevice: nil, input: call.audioInput)) |
| #endif |
| + } |
| + |
| + init(call: Call, audio: CallAudio) { |
| + self.call = call |
| + self.audio = audio |
| factory = audio.factory |
| } |
| + |
| + /// The media for a call, with its factory and audio device module built |
| + /// on the audio queue (CallAudio.make): 100–200 ms the main thread no |
| + /// longer spends when someone joins. |
| + static func make(call: Call) async -> MediaEngine { |
| + await AudioWork.run { RTCInitializeSSL() } |
| + #if os(iOS) |
| + let audio = await CallAudio.make(routes: call.audio, audioDevice: call.customAudioDevice) |
| + #else |
| + let audio = await CallAudio.make(routes: call.audio, audioDevice: nil, input: call.audioInput) |
| + #endif |
| + return MediaEngine(call: call, audio: audio) |
| + } |
| func prepare(capture supplied: VideoCapture?) async throws { |
| close() |
| let source = factory.videoSource() |
| @@ -155,8 +174,13 @@ import LiveKitWebRTC |
| closePeers() |
| capture?.stop() |
| capture = nil |
| - localVideo?.isEnabled = false |
| - localAudio?.isEnabled = false |
| + // On the audio queue: a track's switch waits out an engine start. |
| + let video = localVideo |
| + let audio = localAudio |
| + AudioWork.post { |
| + video?.isEnabled = false |
| + audio?.isEnabled = false |
| + } |
| localVideo = nil |
| localAudio = nil |
| } |
| @@ -220,9 +244,8 @@ import LiveKitWebRTC |
| ] |
| for peer in peers.values { |
| guard let pc = peer.pc else { continue } |
| - let report: RTCStatisticsReport = await withCheckedContinuation { c in |
| - pc.statistics { c.resume(returning: $0) } |
| - } |
| + let ownScreen = await peer.ownScreenMid() |
| + let report = await PeerCalls.statistics(pc) |
| for stat in report.statistics.values { |
| let kind = (stat.values["kind"] as? String) ?? (stat.values["mediaType"] as? String) ?? "" |
| let mid = stat.values["mid"] as? String ?? "" |
| @@ -253,7 +276,7 @@ import LiveKitWebRTC |
| } |
| if stat.type == "outbound-rtp" { |
| if kind == "video" { |
| - let field = peer.isOwnScreen(mid: mid) ? "screenBytesSent" : "videoBytesSent" |
| + let field = !mid.isEmpty && mid == ownScreen ? "screenBytesSent" : "videoBytesSent" |
| values[field, default: 0] += |
| (stat.values["bytesSent"] as? NSNumber)?.uint64Value ?? 0 |
| } |
| @@ -277,15 +300,18 @@ import LiveKitWebRTC |
| for (id, peer) in peers { |
| guard let pc = peer.pc else { continue } |
| let (peak, buffers) = peer.tap.take() |
| - let state = pc.iceConnectionState |
| + // Read on the audio queue, as every call into the connection is |
| + // (PeerCalls): five times a second, and each one a blocking hop to |
| + // WebRTC's signalling thread. |
| + let (state, receivers) = await PeerCalls.read(pc) { pc in |
| + (pc.iceConnectionState, stats ? pc.receivers.filter { $0.track?.kind == "audio" } : []) |
| + } |
| var reading = PeerAudioReading( |
| peak: peak, buffers: buffers, connected: state == .connected || state == .completed) |
| if stats { |
| var bytes: UInt64 = 0 |
| - for receiver in pc.receivers where receiver.track?.kind == "audio" { |
| - let report: RTCStatisticsReport = await withCheckedContinuation { c in |
| - pc.statistics(for: receiver) { c.resume(returning: $0) } |
| - } |
| + for receiver in receivers { |
| + let report = await PeerCalls.statistics(pc, for: receiver) |
| for stat in report.statistics.values where stat.type == "inbound-rtp" { |
| bytes += (stat.values["bytesReceived"] as? NSNumber)?.uint64Value ?? 0 |
| } |
| @@ -311,10 +337,8 @@ import LiveKitWebRTC |
| func screenReports() async -> [String: ScreenSendReport] { |
| var reports: [String: ScreenSendReport] = [:] |
| for (id, peer) in peers { |
| - guard let pc = peer.pc, let mid = peer.ownScreenMid else { continue } |
| - let report: RTCStatisticsReport = await withCheckedContinuation { c in |
| - pc.statistics { c.resume(returning: $0) } |
| - } |
| + guard let pc = peer.pc, let mid = await peer.ownScreenMid() else { continue } |
| + let report = await PeerCalls.statistics(pc) |
| let flat = report.statistics.mapValues { (type: $0.type, values: $0.values as [String: Any]) } |
| if var read = ScreenSendReport.read(flat, mid: mid) { |
| read.timestamp = report.timestamp_us / 1_000_000 |
| @@ -394,24 +418,44 @@ struct PeerAudioReading: Equatable, Sendable { |
| } |
| return c |
| } |
| + /// Makes the connection on the audio queue, as the first thing this |
| + /// peer's work does: everything that arrives for it waits behind it. |
| + /// Making one waits on WebRTC's worker thread, which can be busy |
| + /// starting the audio engine for someone else (846 ms measured on the |
| + /// main thread with a Bluetooth switch simulated). |
| func open(ice: ICEConfig) { |
| guard let engine else { return } |
| lastICE = ice |
| - pc = engine.factory.peerConnection( |
| - with: configuration(ice), |
| - constraints: RTCMediaConstraints(mandatoryConstraints: nil, optionalConstraints: nil), |
| - delegate: self) |
| - if let audio = engine.localAudio { _ = pc?.add(audio, streamIds: ["calls"]) } |
| - if let video = engine.localVideo { |
| - sentVideo = pc?.add(video, streamIds: ["calls"]) != nil |
| + let factory = engine.factory |
| + let config = configuration(ice) |
| + let audio = engine.localAudio |
| + let video = engine.localVideo |
| + let delegate: RTCPeerConnectionDelegate = self |
| + enqueue { [weak self] in |
| + let (made, sent) = await AudioWork.run { () -> (RTCPeerConnection?, Bool) in |
| + guard |
| + let pc = factory.peerConnection( |
| + with: config, constraints: RTCMediaConstraints(mandatoryConstraints: nil, optionalConstraints: nil), |
| + delegate: delegate) |
| + else { return (nil, false) } |
| + if let audio { _ = pc.add(audio, streamIds: ["calls"]) } |
| + let sent = video.map { pc.add($0, streamIds: ["calls"]) != nil } ?? false |
| + return (pc, sent) |
| + } |
| + guard let self, !self.closed, let made else { |
| + if let made { PeerCalls.close(made) } |
| + return |
| + } |
| + self.pc = made |
| + self.sentVideo = sent |
| + if let limit = self.engine?.uplinkLimit { self.limitUplink(limit) } |
| + // A connection built while this app is already sharing gets the |
| + // screen with everything else: someone joining mid-share sees it. |
| + // Last, as in the browser, so the camera and microphone take the |
| + // m-lines the far side built for them and the screen a place of its |
| + // own after them. |
| + self.addScreen() |
| } |
| - if let limit = engine.uplinkLimit { limitUplink(limit) } |
| - // A connection built while this app is already sharing gets the |
| - // screen with everything else: someone joining mid-share sees it. |
| - // Last, as in the browser, so the camera and microphone take the |
| - // m-lines the far side built for them and the screen a place of its |
| - // own after them. |
| - addScreen() |
| } |
| /// Gives this connection the screen on a `sendonly` transceiver in a |
| @@ -425,31 +469,58 @@ struct PeerAudioReading: Equatable, Sendable { |
| /// up each sending a screen in the place the other built for something |
| /// else. The browser's mutualshare gate found that; the deferred ones |
| /// are added when the connection settles. |
| + /// |
| + /// Added on the audio queue, as every call into the connection is; a |
| + /// share that ended while it was being added is taken away again. |
| func addScreen() { |
| - guard let pc, !closed, screenTx == nil, let track = engine?.localScreen, |
| - pc.signalingState == .stable |
| + guard let pc, !closed, screenTx == nil, !addingScreen, let track = engine?.localScreen, let engine |
| else { return } |
| - let setup = RTCRtpTransceiverInit() |
| - setup.direction = .sendOnly |
| - setup.streamIds = ["screen"] |
| - screenTx = pc.addTransceiver(with: track, init: setup) |
| - if let tx = screenTx, let engine { |
| - try? tx.setCodecPreferences( |
| - ScreenSendPolicy.preferringH264(engine.factory.rtpSenderCapabilities(forKind: "video").codecs), |
| - error: ()) |
| + addingScreen = true |
| + let factory = engine.factory |
| + Task { @MainActor [weak self] in |
| + let added = await PeerCalls.read(pc) { pc -> RTCRtpTransceiver? in |
| + guard pc.signalingState == .stable else { return nil } |
| + let setup = RTCRtpTransceiverInit() |
| + setup.direction = .sendOnly |
| + setup.streamIds = ["screen"] |
| + guard let tx = pc.addTransceiver(with: track, init: setup) else { return nil } |
| + try? tx.setCodecPreferences( |
| + ScreenSendPolicy.preferringH264(factory.rtpSenderCapabilities(forKind: "video").codecs), error: ()) |
| + return tx |
| + } |
| + guard let self else { return } |
| + self.addingScreen = false |
| + guard let added else { return } |
| + self.screenTx = added |
| + self.screenMidCache = nil |
| + guard !self.closed, self.pc === pc, self.engine?.localScreen === track else { |
| + self.removeScreen() |
| + return |
| + } |
| + self.applySendPolicy() |
| } |
| - applySendPolicy() |
| } |
| + private var addingScreen = false |
| /// The screen at its mode's parameters and the camera capped beside |
| /// it, or the camera back at its defaults when there is no screen. |
| /// Sender parameters only: nothing here renegotiates. |
| + /// |
| + /// Applied on the audio queue: a sender's parameters are read and set on |
| + /// WebRTC's worker thread, and this runs each time a negotiation settles |
| + /// — just when the worker may be starting the audio engine. |
| func applySendPolicy() { |
| guard let pc, !closed, let engine else { return } |
| - if let screen = screenTx?.sender { screen.apply(engine.screenPlan) } |
| - let camera = engine.localVideo?.trackId |
| - if let sender = pc.senders.first(where: { $0.track != nil && $0.track?.trackId == camera }) { |
| - sender.apply(ScreenSendPolicy.camera(sharing: screenTx != nil)) |
| + let screen = screenTx?.sender |
| + let screenPlan = engine.screenPlan |
| + let cameraTrack = engine.localVideo |
| + let camera = ScreenSendPolicy.camera(sharing: screenTx != nil) |
| + AudioWork.post { |
| + if let screen { screen.apply(screenPlan) } |
| + let id = cameraTrack?.trackId |
| + if let sender = pc.senders.first(where: { $0.track != nil && $0.track?.trackId == id }) { |
| + sender.apply(camera) |
| + } |
| } |
| } |
| @@ -464,7 +535,8 @@ struct PeerAudioReading: Equatable, Sendable { |
| func removeScreen() { |
| guard let tx = screenTx else { return } |
| screenTx = nil |
| - tx.stopInternal() |
| + screenMidCache = nil |
| + AudioWork.post { tx.stopInternal() } |
| applySendPolicy() |
| renegotiate() |
| } |
| @@ -474,16 +546,16 @@ struct PeerAudioReading: Equatable, Sendable { |
| /// owed, and made the next time it settles. |
| private func renegotiate() { |
| guard let pc, !closed else { return } |
| - guard pc.signalingState == .stable else { |
| - owesOffer = true |
| - return |
| - } |
| Task { @MainActor [weak self] in |
| + guard await PeerCalls.read(pc, { $0.signalingState }) == .stable else { |
| + self?.owesOffer = true |
| + return |
| + } |
| guard let self else { return } |
| if self.polite { try? await Task.sleep(nanoseconds: 200_000_000) } |
| self.enqueue { [weak self] in |
| guard let self, let pc = self.pc else { return } |
| - if pc.signalingState != .stable { |
| + if await PeerCalls.read(pc, { $0.signalingState }) != .stable { |
| self.owesOffer = true |
| return |
| } |
| @@ -495,19 +567,37 @@ struct PeerAudioReading: Equatable, Sendable { |
| /// The connection is back at stable: the one moment a deferred screen |
| /// can be added, and an owed offer made. |
| private func settled() { |
| - guard let pc, !closed, pc.signalingState == .stable else { return } |
| - addScreen() |
| - applySendPolicy() |
| - if owesOffer { |
| - owesOffer = false |
| - renegotiate() |
| + guard let pc, !closed else { return } |
| + Task { @MainActor [weak self] in |
| + // Read on the audio queue, as every call into the connection is. |
| + let stable = await PeerCalls.read(pc) { $0.signalingState } == .stable |
| + guard let self, stable, !self.closed, self.pc === pc else { return } |
| + self.addScreen() |
| + self.applySendPolicy() |
| + if self.owesOffer { |
| + self.owesOffer = false |
| + self.renegotiate() |
| + } |
| } |
| } |
| func isScreen(mid: String) -> Bool { !mid.isEmpty && mid == screenMid } |
| - func isOwnScreen(mid: String) -> Bool { !mid.isEmpty && mid == screenTx?.mid } |
| - var ownScreenMid: String? { screenTx?.mid.isEmpty == false ? screenTx?.mid : nil } |
| + /// The mid our screen went out on, once negotiated: read on the audio |
| + /// queue (a transceiver's mid is a hop to WebRTC's signalling thread), |
| + /// and kept once known. |
| + func ownScreenMid() async -> String? { |
| + if let screenMidCache { return screenMidCache } |
| + guard let pc, let tx = screenTx else { return nil } |
| + let mid = await PeerCalls.read(pc) { _ in tx.mid } |
| + guard !mid.isEmpty, screenTx === tx else { return nil } |
| + screenMidCache = mid |
| + return mid |
| + } |
| + private var screenMidCache: String? |
| func limitUplink(_ bps: Int?) { |
| - _ = pc?.setBweMinBitrateBps(nil, currentBitrateBps: nil, maxBitrateBps: bps.map { NSNumber(value: $0) }) |
| + guard let pc else { return } |
| + AudioWork.post { |
| + _ = pc.setBweMinBitrateBps(nil, currentBitrateBps: nil, maxBitrateBps: bps.map { NSNumber(value: $0) }) |
| + } |
| } |
| /// The `share` message: on draws the captioned tile ahead of the media, |
| @@ -564,7 +654,9 @@ struct PeerAudioReading: Equatable, Sendable { |
| screenTx = nil |
| work?.cancel() |
| work = nil |
| - pc?.close() |
| + // Closing can stop the audio engine (the last connection), which |
| + // takes as long as starting it: on the audio queue. |
| + if let pc { PeerCalls.close(pc) } |
| pc = nil |
| candidates = [] |
| } |
| @@ -580,21 +672,18 @@ struct PeerAudioReading: Equatable, Sendable { |
| guard !closed, let body = json(payload) else { return } |
| engine?.call?.send(type: type, to: id, gen: generation, payload: body) |
| } |
| + // Descriptions and candidates are applied from the audio queue |
| + // (PeerCalls). Applying one waits for WebRTC's worker thread, which |
| + // starts the audio engine for the first stream in each direction — a |
| + // Bluetooth headset's profile switch included — and up to 0.3.8 the |
| + // main thread waited with it. |
| private func setLocal(_ desc: RTCSessionDescription) async throws { |
| guard let pc, !closed else { return } |
| - try await withCheckedThrowingContinuation { (c: CheckedContinuation<Void, Error>) in |
| - pc.setLocalDescription(desc) { error in |
| - if let error { c.resume(throwing: error) } else { c.resume() } |
| - } |
| - } |
| + try await PeerCalls.set(pc, local: true, desc) |
| } |
| private func setRemote(_ desc: RTCSessionDescription) async throws { |
| guard let pc, !closed else { return } |
| - try await withCheckedThrowingContinuation { (c: CheckedContinuation<Void, Error>) in |
| - pc.setRemoteDescription(desc) { error in |
| - if let error { c.resume(throwing: error) } else { c.resume() } |
| - } |
| - } |
| + try await PeerCalls.set(pc, local: false, desc) |
| } |
| private func makeDescription(answer: Bool, restart: Bool = false) async throws |
| -> RTCSessionDescription |
| @@ -602,23 +691,10 @@ struct PeerAudioReading: Equatable, Sendable { |
| guard let pc else { throw CancellationError() } |
| let constraints = RTCMediaConstraints( |
| mandatoryConstraints: restart ? ["IceRestart": "true"] : nil, optionalConstraints: nil) |
| - return try await withCheckedThrowingContinuation { c in |
| - let completion: @Sendable (RTCSessionDescription?, Error?) -> Void = { desc, error in |
| - if let desc { |
| - c.resume(returning: desc) |
| - } else { |
| - c.resume(throwing: error ?? CancellationError()) |
| - } |
| - } |
| - if answer { |
| - pc.answer(for: constraints, completionHandler: completion) |
| - } else { |
| - pc.offer(for: constraints, completionHandler: completion) |
| - } |
| - } |
| + return try await PeerCalls.describe(pc, answer: answer, constraints) |
| } |
| private func offer(restart: Bool = false) async throws { |
| - guard let pc, !closed, pc.signalingState == .stable else { return } |
| + guard let pc, !closed, await PeerCalls.read(pc, { $0.signalingState }) == .stable, !closed else { return } |
| let desc = try await makeDescription(answer: false, restart: restart) |
| guard !closed else { return } |
| try await setLocal(desc) |
| @@ -630,7 +706,7 @@ struct PeerAudioReading: Equatable, Sendable { |
| guard let self, let pc = self.pc, !self.closed else { return } |
| if message.type == "candidate", let candidate = message.payload?.candidate { |
| if self.ignoreOffer { return } |
| - if pc.remoteDescription == nil { |
| + if await PeerCalls.read(pc, { $0.remoteDescription == nil }) { |
| guard self.candidates.count < 256 else { throw CancellationError() } |
| self.candidates.append(candidate) |
| } else { |
| @@ -639,8 +715,10 @@ struct PeerAudioReading: Equatable, Sendable { |
| return |
| } |
| guard let desc = message.payload?.description else { return } |
| + let signaling = await PeerCalls.read(pc) { $0.signalingState } |
| + guard !self.closed else { return } |
| if message.type == "offer", desc.type == "offer" { |
| - let collision = pc.signalingState != .stable |
| + let collision = signaling != .stable |
| self.ignoreOffer = collision && !self.polite |
| if self.ignoreOffer { return } |
| if collision { try await self.setLocal(RTCSessionDescription(type: .rollback, sdp: "")) } |
| @@ -650,9 +728,7 @@ struct PeerAudioReading: Equatable, Sendable { |
| let answer = try await self.makeDescription(answer: true) |
| try await self.setLocal(answer) |
| self.send("answer", ["description": ["type": "answer", "sdp": answer.sdp]]) |
| - } else if message.type == "answer", desc.type == "answer", |
| - pc.signalingState == .haveLocalOffer |
| - { |
| + } else if message.type == "answer", desc.type == "answer", signaling == .haveLocalOffer { |
| try await self.setRemote(RTCSessionDescription(type: .answer, sdp: desc.sdp)) |
| self.ignoreOffer = false |
| try await self.drain() |
| @@ -664,9 +740,7 @@ struct PeerAudioReading: Equatable, Sendable { |
| let ice = RTCIceCandidate( |
| sdp: candidate.candidate, sdpMLineIndex: candidate.sdpMLineIndex ?? 0, |
| sdpMid: candidate.sdpMid) |
| - try await withCheckedThrowingContinuation { (c: CheckedContinuation<Void, Error>) in |
| - pc.add(ice) { error in if let error { c.resume(throwing: error) } else { c.resume() } } |
| - } |
| + try await PeerCalls.add(pc, ice) |
| } |
| private func drain() async throws { |
| let saved = candidates |
| @@ -678,7 +752,8 @@ struct PeerAudioReading: Equatable, Sendable { |
| lastICE = ice |
| enqueue { [weak self] in |
| guard let self, let pc = self.pc else { return } |
| - if pc.setConfiguration(self.configuration(ice)) { try await self.offer(restart: true) } |
| + let config = self.configuration(ice) |
| + if await PeerCalls.read(pc, { $0.setConfiguration(config) }) { try await self.offer(restart: true) } |
| } |
| } |
| nonisolated func peerConnectionShouldNegotiate(_ peerConnection: RTCPeerConnection) { |
diff --git a/native/apple/Sources/CallsMac/CallsMac.swift b/native/apple/Sources/CallsMac/CallsMac.swift| index cb85552..93e0348 100644 |
| --- a/native/apple/Sources/CallsMac/CallsMac.swift |
| +++ b/native/apple/Sources/CallsMac/CallsMac.swift |
| @@ -23,6 +23,7 @@ import LiveKitWebRTC |
| .onAppear { |
| CallsAppDelegate.model = model |
| model.restore() |
| + AudioWarmUp.afterLaunch() |
| } |
| .miniWindow(for: model) |
| .onChange(of: model.joining, initial: true) { updater.isBusy = model.joining } |
diff --git a/native/apple/Tests/CallsKitTests/AudioRoutesTests.swift b/native/apple/Tests/CallsKitTests/AudioRoutesTests.swift| index 3fb5adb..ae5dee0 100644 |
| --- a/native/apple/Tests/CallsKitTests/AudioRoutesTests.swift |
| +++ b/native/apple/Tests/CallsKitTests/AudioRoutesTests.swift |
| @@ -389,6 +389,36 @@ |
| /// The real CoreAudio on this machine: the inventory the app lists at |
| /// launch, with no engine and no permission needed. |
| + /// Which microphone the test says it is switching: a Bluetooth headset |
| + /// not yet at its hands-free rate, and nothing else. |
| + final class BluetoothSwitchTests: XCTestCase { |
| + private let inventory = AudioInventory( |
| + devices: [ |
| + SystemAudioDevice( |
| + uid: "airpods", name: "Paul’s AirPods Pro", inputChannels: 1, outputChannels: 2, sampleRate: 48_000, |
| + transport: "bluetooth"), |
| + SystemAudioDevice( |
| + uid: "airpods-hfp", name: "Headset", inputChannels: 1, outputChannels: 1, sampleRate: 24_000, |
| + transport: "bluetooth"), |
| + SystemAudioDevice( |
| + uid: "BuiltInMicrophoneDevice", name: "MacBook Air Microphone", inputChannels: 1, outputChannels: 0, |
| + sampleRate: 48_000, transport: "built-in"), |
| + ], defaultInput: "airpods", defaultOutput: "airpods") |
| + |
| + func testABluetoothHeadsetInItsListeningProfileIsSwitching() { |
| + XCTAssertEqual(AudioRoutes.bluetoothSwitching(uid: "airpods", inventory), "Paul’s AirPods Pro") |
| + } |
| + |
| + func testOneAlreadyAtItsHandsFreeRateIsNot() { |
| + XCTAssertNil(AudioRoutes.bluetoothSwitching(uid: "airpods-hfp", inventory)) |
| + } |
| + |
| + func testABuiltInMicrophoneOrNothingIsNot() { |
| + XCTAssertNil(AudioRoutes.bluetoothSwitching(uid: "BuiltInMicrophoneDevice", inventory)) |
| + XCTAssertNil(AudioRoutes.bluetoothSwitching(uid: nil, inventory)) |
| + } |
| + } |
| + |
| final class CoreAudioInventoryTests: XCTestCase { |
| func testTheInventoryIsReadyWithoutAnEngine() { |
| let inventory = CoreAudioDevices.inventory() |
| @@ -412,7 +442,7 @@ |
| /// default microphone and that speaker before the module wires it, the |
| /// engine runs there, and going back to the default rebuilds it. |
| final class EnginePinIntegrationTests: XCTestCase { |
| - @MainActor func testAChosenSpeakerRunsTheEngineOnItAndBack() throws { |
| + @MainActor func testAChosenSpeakerRunsTheEngineOnItAndBack() async throws { |
| let inventory = CoreAudioDevices.inventory() |
| guard let speaker = inventory.defaultOutput, let microphone = inventory.defaultInput else { |
| throw XCTSkip("this machine has no default input and output") |
| @@ -437,6 +467,8 @@ |
| XCTAssertEqual(audio.enginePin.lastApplied, expected) |
| try routes.use(output: "") |
| + // The rebuild runs on the audio queue (0.3.9), never the main thread. |
| + await AudioWork.drain() |
| XCTAssertEqual(audio.enginePin.lastApplied, .systemDefault, "rebuilt on the default") |
| XCTAssertTrue(module.isEngineRunning) |
| } |
diff --git a/native/apple/Tests/CallsKitTests/AudioSelfTestTests.swift b/native/apple/Tests/CallsKitTests/AudioSelfTestTests.swift| index 5cb76de..febc1b8 100644 |
| --- a/native/apple/Tests/CallsKitTests/AudioSelfTestTests.swift |
| +++ b/native/apple/Tests/CallsKitTests/AudioSelfTestTests.swift |
| @@ -202,12 +202,36 @@ private final class TestClock: @unchecked Sendable { |
| var allowed = true |
| var starts = 0 |
| var stops = 0 |
| + /// Set: `start` waits until it is released, as a start held up by a |
| + /// Bluetooth headset's switch does. |
| + var holdStart = false |
| + var failStart: Error? |
| + var switching: String? |
| + /// Whether the microphone would be open now: started and not stopped |
| + /// since. |
| + var open = false |
| + private var held: CheckedContinuation<Void, Never>? |
| init() { denoiser.audioProcessingInitialize(sampleRate: 48_000, channels: 1) } |
| func permitted() async -> Bool { allowed } |
| - func start() async throws { starts += 1 } |
| - func stop() { stops += 1 } |
| + func switchingMicrophone() async -> String? { switching } |
| + func start() async throws { |
| + starts += 1 |
| + if holdStart { await withCheckedContinuation { held = $0 } } |
| + if let failStart { throw failStart } |
| + open = true |
| + } |
| + func stop() { |
| + stops += 1 |
| + open = false |
| + } |
| + /// Lets a held start finish. |
| + func release() { |
| + held?.resume() |
| + held = nil |
| + } |
| + var isHeld: Bool { held != nil } |
| func levels() -> (input: CaptureLevel.Reading, output: CaptureLevel.Reading) { |
| (denoiser.inputLevel.take(), denoiser.level.take()) |
| } |
| @@ -433,4 +457,174 @@ private final class TestClock: @unchecked Sendable { |
| } |
| XCTAssertEqual(published, first, "the same level and verdict are not published again") |
| } |
| + |
| + // MARK: The start, off the main thread |
| + |
| + /// Waits (yielding the main actor) until `condition` holds. |
| + private func until(_ condition: () -> Bool) async { |
| + for _ in 0..<200 where !condition() { await Task.yield() } |
| + } |
| + |
| + /// The window opens at once and says it is starting; the verdict and |
| + /// meter take over only once the microphone has started. |
| + func testStartingIsSaidUntilTheMicrophoneHasStarted() async { |
| + let (test, capture, _, clock) = make { $0.holdStart = true } |
| + let opening = Task { await test.open() } |
| + await until { capture.isHeld } |
| + XCTAssertEqual(test.startPhase, .starting(switching: nil)) |
| + XCTAssertEqual(test.startingSentence, "Starting microphone…") |
| + XCTAssertEqual(test.micSentence, "Starting microphone…") |
| + XCTAssertNil(test.micAdvice) |
| + capture.release() |
| + await opening.value |
| + XCTAssertEqual(test.startPhase, .running) |
| + XCTAssertNil(test.startingSentence) |
| + capture.feed(8_000) |
| + clock.now += 0.1 |
| + test.tick() |
| + XCTAssertEqual(test.micVerdict, .working) |
| + test.close() |
| + } |
| + |
| + /// A Bluetooth headset is named while it switches. |
| + func testABluetoothSwitchIsSaid() async { |
| + let (test, capture, _, _) = make { |
| + $0.holdStart = true |
| + $0.switching = "Paul’s AirPods Pro" |
| + } |
| + let opening = Task { await test.open() } |
| + await until { capture.isHeld } |
| + XCTAssertEqual(test.startPhase, .starting(switching: "Paul’s AirPods Pro")) |
| + XCTAssertEqual(test.startingSentence, "Switching Paul’s AirPods Pro to microphone mode…") |
| + capture.release() |
| + await opening.value |
| + XCTAssertEqual(test.startPhase, .running) |
| + test.close() |
| + } |
| + |
| + /// Closed while the microphone starts: the start, when it finishes, is |
| + /// stopped at once, and nothing is left open or judged. |
| + func testClosingMidStartLeavesNothingOpen() async { |
| + let (test, capture, _, _) = make { $0.holdStart = true } |
| + let opening = Task { await test.open() } |
| + await until { capture.isHeld } |
| + test.close() |
| + XCTAssertEqual(test.startPhase, .cancelled) |
| + capture.release() |
| + await opening.value |
| + XCTAssertFalse(capture.open, "the microphone the late start opened was closed") |
| + XCTAssertEqual(capture.starts, 1) |
| + XCTAssertGreaterThanOrEqual(capture.stops, 1) |
| + XCTAssertEqual(test.startPhase, .cancelled) |
| + XCTAssertNil(test.micProblem, "a cancelled start is not reported as a failure") |
| + } |
| + |
| + /// Closed while permission is being asked: nothing starts at all. |
| + func testClosingWhileAskingStartsNothing() async { |
| + let (test, capture, _, _) = make() |
| + test.close() |
| + await test.open() |
| + XCTAssertEqual(capture.starts, 0) |
| + XCTAssertEqual(test.startPhase, .cancelled) |
| + } |
| + |
| + func testAStartThatFailsSaysWhy() async { |
| + let (test, capture, _, _) = make { |
| + $0.failStart = NSError(domain: "t", code: 1, userInfo: [NSLocalizedDescriptionKey: "no device"]) |
| + } |
| + await test.open() |
| + XCTAssertEqual(test.startPhase, .failed) |
| + XCTAssertEqual(test.micProblem, "The microphone would not start: no device") |
| + XCTAssertFalse(capture.open) |
| + test.close() |
| + } |
| + |
| + func testOpeningTwiceStartsOnce() async { |
| + let (test, capture, _, _) = make() |
| + await test.open() |
| + await test.open() |
| + XCTAssertEqual(capture.starts, 1) |
| + test.close() |
| + test.close() |
| + XCTAssertEqual(capture.stops, 1) |
| + } |
| +} |
| + |
| +/// The start's rules alone. |
| +final class MicStartTests: XCTestCase { |
| + func testTheHappyPath() { |
| + var start = MicStart() |
| + XCTAssertTrue(start.open()) |
| + XCTAssertEqual(start.phase, .asking) |
| + XCTAssertEqual(start.sentence, "Asking to use the microphone…") |
| + XCTAssertTrue(start.permission(true)) |
| + XCTAssertEqual(start.sentence, "Starting microphone…") |
| + start.microphone(bluetoothSwitching: "AirPods") |
| + XCTAssertEqual(start.sentence, "Switching AirPods to microphone mode…") |
| + XCTAssertEqual(start.finished(ok: true), .keep) |
| + XCTAssertEqual(start.phase, .running) |
| + XCTAssertNil(start.sentence) |
| + XCTAssertTrue(start.close(), "a running microphone is stopped on close") |
| + XCTAssertEqual(start.phase, .cancelled) |
| + } |
| + |
| + func testOpenOnlyOnce() { |
| + var start = MicStart() |
| + XCTAssertTrue(start.open()) |
| + XCTAssertFalse(start.open()) |
| + start.close() |
| + XCTAssertFalse(start.open(), "a closed test does not open again") |
| + } |
| + |
| + func testNoPermission() { |
| + var start = MicStart() |
| + _ = start.open() |
| + XCTAssertFalse(start.permission(false)) |
| + XCTAssertEqual(start.phase, .failed) |
| + XCTAssertFalse(start.close(), "nothing to stop") |
| + } |
| + |
| + func testPermissionAfterClose() { |
| + var start = MicStart() |
| + _ = start.open() |
| + XCTAssertFalse(start.close(), "nothing started yet") |
| + XCTAssertFalse(start.permission(true), "the answer comes back to a closed window") |
| + XCTAssertEqual(start.phase, .cancelled) |
| + } |
| + |
| + func testALateStartIsStopped() { |
| + var start = MicStart() |
| + _ = start.open() |
| + _ = start.permission(true) |
| + XCTAssertTrue(start.close(), "a start in flight may open the microphone") |
| + XCTAssertEqual(start.finished(ok: true), .stop) |
| + XCTAssertEqual(start.phase, .cancelled) |
| + } |
| + |
| + func testALateFailureIsQuiet() { |
| + var start = MicStart() |
| + _ = start.open() |
| + _ = start.permission(true) |
| + start.close() |
| + XCTAssertEqual(start.finished(ok: false), .failed) |
| + XCTAssertEqual(start.phase, .cancelled) |
| + } |
| + |
| + func testRestartAfterPlayback() { |
| + var start = MicStart() |
| + _ = start.open() |
| + _ = start.permission(true) |
| + _ = start.finished(ok: true) |
| + XCTAssertTrue(start.restart()) |
| + XCTAssertTrue(start.isStarting) |
| + XCTAssertEqual(start.finished(ok: true), .keep) |
| + start.close() |
| + XCTAssertFalse(start.restart(), "not after close") |
| + } |
| + |
| + func testTheBluetoothNameOnlyWhileStarting() { |
| + var start = MicStart() |
| + start.microphone(bluetoothSwitching: "AirPods") |
| + XCTAssertEqual(start.phase, .idle) |
| + } |
| } |
diff --git a/native/ios/project.yml b/native/ios/project.yml| index a74c77d..36fa266 100644 |
| --- a/native/ios/project.yml |
| +++ b/native/ios/project.yml |
| @@ -10,7 +10,7 @@ settings: |
| base: |
| SWIFT_VERSION: '5.0' |
| CODE_SIGN_STYLE: Automatic |
| - MARKETING_VERSION: '0.3.8' |
| + MARKETING_VERSION: '0.3.9' |
| CURRENT_PROJECT_VERSION: '1' |
| # The App Group the app and its broadcast extension share: the socket |
| # the iPhone's screen crosses lives in its container. One setting |