oficina / meet Public

Clone
git clone https://amadan.net/oficina/meet

Plain git — no account needed to clone.

Download

Download this file

1import Foundation
2import LiveKitWebRTC
3import os
4
5// Where the audio stack's slow work runs: never on the main thread.
6//
7// Paul, 0.3.8, MacBook Air, AirPods Pro: "after I open the Test window,
8// there's a pause, then a beachball, then it works." Measured on an M1 Max
9// with a main-thread watchdog (now `CallsInterop --audio-start-stall`),
10// 0.3.8's test held the main thread three times on its way to a running
11// microphone:
12//
13// 1. Building the audio (CallAudio): 166–200 ms. WebRTC's peer connection
14// factory with LiveKit's engine module, 100–135 ms; the module's first
15// device list, read only to be logged, 61–68 ms.
16// 2. The in-memory loopback's answer, applied: 188–229 ms. Applying it
17// starts the engine's playout on WebRTC's worker thread, and
18// `setLocalDescription` waits for that before it returns, completion
19// handler or not — every RTCPeerConnection call is a blocking hop to
20// WebRTC's own threads.
21// 3. `initRecording`, which waits on the worker thread while the engine
22// starts with the microphone. Here, with no microphone permission, 25
23// ms. With AirPods it is as long as the headset takes to move from A2DP
24// to its hands-free profile, often one to two seconds: with that start
25// simulated at 1.5 s the main thread stalled for 1.5–1.7 s. That is the
26// beachball; 1 and 2 are the pause before it.
27//
28// A call's join had the same shape (1.3–1.5 s with the switch simulated),
29// and more: while the worker thread is starting the engine, *every* call
30// into the engine module waits for it — `isRecording`, `isPlaying`, the
31// device lists, the factory's processing state, a track's
32// `isEnabled = false` (mute), its processing options — 0.9 to 1.2 s each,
33// and a call's meters read some of them ten times a second.
34//
35// None of it is the main thread's business. It all runs here, on one
36// serial queue, in the order it was asked for; the main actor awaits the
37// result and only ever sets what the window shows. What the main thread
38// needs to know about the module it reads from ModuleState.
39
40/// The audio stack's serial queue: the factory and its engine module, the
41/// engine's start and stop, and the peer connections' calls.
42///
43/// Serial on purpose. A test closed while its microphone is still
44/// starting queues its stop behind the start, so the stop always sees the
45/// microphone it has to close; nothing is left recording.
46enum AudioWork {
47 static let queue = DispatchQueue(label: "net.amadan.oficina.calls.audio", qos: .userInitiated)
48
49 /// Runs `body` on the audio queue and hands its result back.
50 static func run<T>(_ body: @escaping () -> T) async -> T {
51 await withCheckedContinuation { (c: CheckedContinuation<T, Never>) in
52 queue.async { c.resume(returning: body()) }
53 }
54 }
55
56 static func run<T>(_ body: @escaping () throws -> T) async throws -> T {
57 try await withCheckedThrowingContinuation { (c: CheckedContinuation<T, Error>) in
58 queue.async {
59 do { c.resume(returning: try body()) } catch { c.resume(throwing: error) }
60 }
61 }
62 }
63
64 /// Queues `body` and does not wait for it: a stop, a close, a log line
65 /// that has to read the module.
66 static func post(_ body: @escaping () -> Void) {
67 queue.async(execute: body)
68 }
69
70 /// Waits for everything queued so far. For tests and the harness.
71 static func drain() async {
72 await run {}
73 }
74}
75
76/// A peer connection's calls, made from the audio queue.
77///
78/// Every RTCPeerConnection method is a blocking hop to one of WebRTC's
79/// own threads, completion handler or not, and applying a description
80/// waits for the worker thread — which may be busy starting the audio
81/// engine for seconds. So a call makes them from here and awaits the
82/// answer; the main thread never waits on WebRTC.
83enum PeerCalls {
84 static func describe(
85 _ pc: RTCPeerConnection, answer: Bool, _ constraints: RTCMediaConstraints
86 ) async throws -> RTCSessionDescription {
87 try await withCheckedThrowingContinuation { c in
88 AudioWork.queue.async {
89 let completion: @Sendable (RTCSessionDescription?, Error?) -> Void = { desc, error in
90 if let desc { c.resume(returning: desc) } else { c.resume(throwing: error ?? CancellationError()) }
91 }
92 if answer {
93 pc.answer(for: constraints, completionHandler: completion)
94 } else {
95 pc.offer(for: constraints, completionHandler: completion)
96 }
97 }
98 }
99 }
100
101 static func set(_ pc: RTCPeerConnection, local: Bool, _ desc: RTCSessionDescription) async throws {
102 try await withCheckedThrowingContinuation { (c: CheckedContinuation<Void, Error>) in
103 AudioWork.queue.async {
104 let done: @Sendable (Error?) -> Void = { error in
105 if let error { c.resume(throwing: error) } else { c.resume() }
106 }
107 if local { pc.setLocalDescription(desc, completionHandler: done) } else {
108 pc.setRemoteDescription(desc, completionHandler: done)
109 }
110 }
111 }
112 }
113
114 static func add(_ pc: RTCPeerConnection, _ candidate: RTCIceCandidate) async throws {
115 try await withCheckedThrowingContinuation { (c: CheckedContinuation<Void, Error>) in
116 AudioWork.queue.async {
117 pc.add(candidate) { error in if let error { c.resume(throwing: error) } else { c.resume() } }
118 }
119 }
120 }
121
122 static func statistics(_ pc: RTCPeerConnection) async -> RTCStatisticsReport {
123 await withCheckedContinuation { c in
124 AudioWork.queue.async { pc.statistics { c.resume(returning: $0) } }
125 }
126 }
127
128 static func statistics(_ pc: RTCPeerConnection, for receiver: RTCRtpReceiver) async -> RTCStatisticsReport {
129 await withCheckedContinuation { c in
130 AudioWork.queue.async { pc.statistics(for: receiver) { c.resume(returning: $0) } }
131 }
132 }
133
134 static func statistics(_ pc: RTCPeerConnection, for sender: RTCRtpSender) async -> RTCStatisticsReport {
135 await withCheckedContinuation { c in
136 AudioWork.queue.async { pc.statistics(for: sender) { c.resume(returning: $0) } }
137 }
138 }
139
140 /// Anything else, read or done on the audio queue.
141 static func read<T>(_ pc: RTCPeerConnection, _ body: @escaping (RTCPeerConnection) -> T) async -> T {
142 await AudioWork.run { body(pc) }
143 }
144
145 /// Closes it after whatever is queued for it; not waited for. Closing
146 /// stops the audio engine when it is the last connection, and that can
147 /// take as long as a start.
148 static func close(_ pc: RTCPeerConnection) {
149 AudioWork.post { pc.close() }
150 }
151}
152
153#if canImport(DeepFilter)
154 /// DeepFilterNet's model, loaded before anyone needs it.
155 ///
156 /// Loading takes about a third of a second on an M1 Max (and longer on
157 /// an Air) of one core, and a call or test that has to load it starts on
158 /// RNNoise and moves to DeepFilterNet when it is ready. So one is loaded
159 /// in the background a few seconds after launch, at utility priority,
160 /// and the first test or call takes it ready-made.
161 ///
162 /// Memory is the cost: a loaded model is about 88 MB resident. So only
163 /// one is kept spare; a spare nobody has taken for fifteen minutes is
164 /// let go, and the next user loads its own as before (off the main
165 /// thread, starting on RNNoise). Taking the spare does not replace it at
166 /// once — a call would then hold two — but when its user is done, a new
167 /// spare is loaded for the next one. A model is never handed from one
168 /// user to the next: its state holds the last audio it heard, and there
169 /// is no way to clear it.
170 public enum DeepFilterPreload {
171 private struct State {
172 var spare: DeepFilterSuppressor?
173 var loading = false
174 var generation = 0
175 }
176 // The suppressor is only ever touched by whoever holds it; the lock
177 // guards the handing over.
178 nonisolated(unsafe) private static var state = State()
179 private static let lock = NSLock()
180 private static let background = DispatchQueue(label: "net.amadan.oficina.calls.deepfilter", qos: .utility)
181 static let idleRelease: TimeInterval = 15 * 60
182
183 /// After launch: loads a spare in `delay` seconds, if there is a model
184 /// and no spare yet.
185 public static func preload(after delay: TimeInterval = 3) {
186 background.asyncAfter(deadline: .now() + delay) { load() }
187 }
188
189 /// The spare, if one is ready; nil otherwise, and the caller loads.
190 static func take() -> DeepFilterSuppressor? {
191 lock.lock()
192 defer { lock.unlock() }
193 let spare = state.spare
194 state.spare = nil
195 state.generation += 1
196 if spare != nil { AudioLog.info("DeepFilterNet: using the preloaded model") }
197 return spare
198 }
199
200 /// A user of a model is done with it: a fresh spare, a little later.
201 static func finished() {
202 preload(after: 5)
203 }
204
205 /// Whether a spare is loaded (for the tests).
206 static var ready: Bool {
207 lock.lock()
208 defer { lock.unlock() }
209 return state.spare != nil
210 }
211
212 private static func load() {
213 lock.lock()
214 guard state.spare == nil, !state.loading, let model = DeepFilterSuppressor.model else {
215 lock.unlock()
216 return
217 }
218 state.loading = true
219 lock.unlock()
220 let started = CaptureDenoiser.now()
221 let loaded = DeepFilterSuppressor(model: model)
222 lock.lock()
223 state.loading = false
224 state.spare = loaded
225 state.generation += 1
226 let generation = state.generation
227 lock.unlock()
228 guard loaded != nil else { return }
229 AudioLog.info(
230 "DeepFilterNet: preloaded in \(Int((CaptureDenoiser.now() - started) * 1000)) ms, kept for the next test or call")
231 background.asyncAfter(deadline: .now() + idleRelease) { release(ifStill: generation) }
232 }
233
234 private static func release(ifStill generation: Int) {
235 lock.lock()
236 guard state.generation == generation, state.spare != nil else {
237 lock.unlock()
238 return
239 }
240 let spare = state.spare
241 state.spare = nil
242 lock.unlock()
243 _ = spare
244 AudioLog.info("DeepFilterNet: preloaded model unused for \(Int(idleRelease / 60)) minutes; released")
245 }
246 }
247#endif
248
249/// What the app does for its audio once it has launched: loads the
250/// noise suppressor's model in the background (DeepFilterPreload), so the
251/// first test or call does not wait for it. Nothing on iPhone, which runs
252/// RNNoise (made in well under a millisecond).
253@MainActor public enum AudioWarmUp {
254 private static var done = false
255
256 /// Once per launch; later calls (a window shown again) do nothing.
257 public static func afterLaunch() {
258 guard !done else { return }
259 done = true
260 #if canImport(DeepFilter)
261 DeepFilterPreload.preload(after: 3)
262 #endif
263 }
264}
265