oficina / meet Public

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

Plain git — no account needed to clone.

Download

Download this file

1import AVFoundation
2import Foundation
3import LiveKitWebRTC
4
5/// Something other than a microphone to capture from: the interop
6/// harness's synthetic voice, as SyntheticVideo is its camera. Called on
7/// the audio engine's render thread with mono float samples in -1...1.
8/// On Mac only, where the engine module can run without any device.
9public protocol AudioInputSource: AnyObject, Sendable {
10 func render(_ samples: UnsafeMutablePointer<Float>, count: Int, sampleRate: Double)
11}
12
13/// Which of WebRTC's processing stages run on the microphone, decided in
14/// one place so the rule can be read and tested apart from WebRTC.
15///
16/// The rule the plan holds: our suppressor never runs beside another one.
17/// Not beside WebRTC's own noise suppressor, and not beside Apple's Voice
18/// Isolation. And WebRTC's gain control, which runs *before* our
19/// suppressor, is off whenever ours is on; SpeechGain lifts the voice
20/// after the noise has gone instead.
21public struct ProcessingPlan: Equatable, Sendable {
22 public enum Where: Equatable, Sendable { case off, software, platform }
23 /// Our neural suppressor, in the capture post-processing hook.
24 public var suppressor: Bool
25 public var echoCancellation: Where
26 public var noiseSuppression: Where
27 public var gainControl: Where
28 /// WebRTC's high-pass filter, always: it is cheap and only takes out
29 /// rumble below the voice.
30 public var highPass = true
31
32 /// - Parameters:
33 /// - iPhone: the phone runs on Apple's voice-processing unit, whose
34 /// echo canceller and noise suppressor are one switch; the Mac runs
35 /// WebRTC's software processing.
36 /// - reduceNoise: the person's toggle.
37 /// - rate: the capture rate WebRTC reported, or nil before it has.
38 /// - voiceIsolation: Apple's Voice Isolation is the active mic mode.
39 public static func plan(iPhone: Bool, reduceNoise: Bool, rate: Int?, voiceIsolation: Bool)
40 -> ProcessingPlan
41 {
42 let rateOK = rate.map { $0 == CaptureDenoiser.supportedRate } ?? true
43 let ours = reduceNoise && rateOK && !voiceIsolation
44 if iPhone {
45 // Apple's echo canceller cannot be had without Apple's suppressor
46 // beside it; that pairing is what the phone has always run. Its gain
47 // control is separate, and is what comes off.
48 return ProcessingPlan(
49 suppressor: ours, echoCancellation: .platform, noiseSuppression: .platform,
50 gainControl: ours ? .off : .platform)
51 }
52 let classicNS = reduceNoise && !rateOK
53 return ProcessingPlan(
54 suppressor: ours, echoCancellation: .software,
55 noiseSuppression: classicNS ? .software : .off,
56 gainControl: ours ? .off : .software)
57 }
58}
59
60/// What the audio readout says about processing.
61public struct ProcessingReport: Equatable, Sendable {
62 public var suppression: SuppressionState
63 public var echoCancellation: String
64 public var noiseSuppression: String
65 public var gainControl: String
66 /// Set when WebRTC refused the options asked of it.
67 public var refused: String?
68 /// False until the microphone is actually capturing. WebRTC opens it
69 /// only once there is someone to send to, so a person alone in a call
70 /// has no microphone running yet, and what the processing module says
71 /// before then is its construction-time default (16 kHz, everything
72 /// off) rather than anything about their microphone.
73 public var started = true
74
75 /// One or two plain sentences.
76 public var summary: String {
77 guard started else {
78 return "The microphone has not started yet. It starts when someone else is in the call, and this will say what is running on it then."
79 }
80 var lines: [String] = []
81 switch suppression {
82 case .off: lines.append("Background noise reduction is off.")
83 case .running(let engine, let cpu):
84 lines.append(
85 "Reducing background noise with \(engine) (\(String(format: "%.1f", cpu * 100))% of real time on the audio thread).")
86 case .idle(let engine): lines.append("Reducing background noise with \(engine) when you speak.")
87 case .unsupportedRate(let rate):
88 lines.append(
89 "The microphone runs at \(rate / 1000) kHz, which the noise reducer cannot; WebRTC's own is standing in.")
90 case .standingAside:
91 lines.append("Voice Isolation is on, so Calls leaves noise reduction to it.")
92 }
93 lines.append(
94 "Echo cancellation: \(echoCancellation). Noise suppression: \(noiseSuppression). Gain: \(gainControl).")
95 if let refused { lines.append("WebRTC refused the requested processing: \(refused)") }
96 return lines.joined(separator: " ")
97 }
98}
99
100/// How a call's audio is built: which audio device module WebRTC runs on,
101/// what processing runs on the microphone, and — on Mac — what the app is
102/// told about the devices it can use.
103///
104/// Kept out of Media.swift so that file only changes where the factory is
105/// made.
106@MainActor final class CallAudio {
107 let factory: RTCPeerConnectionFactory
108 /// The factory's audio device module, fetched once, off the main
109 /// thread. Fetching it, and every property and call on it, is a blocking
110 /// hop to WebRTC's worker thread, which is held for the whole of an
111 /// engine start, a Bluetooth switch included (1,544 ms measured on the
112 /// main thread with the switch simulated). So it is only ever asked on
113 /// the audio queue; the main thread reads `state`.
114 let module: RTCAudioDeviceModule
115 /// The module's state for the main thread to read: never by asking the
116 /// module, whose every property waits out an engine start (ModuleState).
117 let state: ModuleState
118 /// Nil for the iPhone tests' silent device, which takes the constructor
119 /// that has no processing module.
120 let processing: RTCDefaultAudioProcessingModule?
121 let denoiser: CaptureDenoiser
122 private weak var track: RTCAudioTrack?
123 private var rate: Int?
124 private var voiceIsolation = false
125 private var refused: String?
126 private(set) var plan: ProcessingPlan
127 private var buffersAtStart: UInt64 = 0
128 var reduceNoise: Bool {
129 didSet { if reduceNoise != oldValue { apply() } }
130 }
131
132 #if os(macOS)
133 private let devices: EngineWatch
134 /// Where the engine is put (AudioDevices.swift); read by the tests.
135 var enginePin: AudioEnginePin { devices.pin }
136 #endif
137
138 /// What building a CallAudio takes, made off the main thread (`make`):
139 /// the peer connection factory with its audio device module and
140 /// processing module, and the capture hook. Making the factory alone
141 /// took 100–135 ms on an M1 Max.
142 struct Parts: @unchecked Sendable {
143 let factory: RTCPeerConnectionFactory
144 let module: RTCAudioDeviceModule
145 let state: ModuleState
146 let processing: RTCDefaultAudioProcessingModule?
147 let denoiser: CaptureDenoiser
148 #if os(macOS)
149 let devices: EngineWatch
150 #endif
151 }
152
153 /// Builds the parts. Nonisolated, and slow: `make` runs it on the audio
154 /// queue. `routes` is only held (weakly) by the module's observer, which
155 /// reaches it on the main queue.
156 nonisolated static func parts(
157 routes: AudioRoutes, defaults: UserDefaults, audioDevice: RTCAudioDevice?, input: AudioInputSource?,
158 denoiser: CaptureDenoiser?
159 ) -> Parts {
160 let encoder = RTCDefaultVideoEncoderFactory()
161 // WebRTC's own, except in the gate's decoder-wedge scenarios.
162 let decoder: RTCVideoDecoderFactory =
163 DecoderWedge.enabled ? DecoderWedge.factory() : RTCDefaultVideoDecoderFactory()
164 let denoiser = denoiser ?? CaptureDenoiser(suppressor: CallAudio.makeSuppressor())
165 #if os(iOS)
166 if let audioDevice {
167 let factory = RTCPeerConnectionFactory(
168 encoderFactory: encoder, decoderFactory: decoder, audioDevice: audioDevice)
169 let module = factory.audioDeviceModule
170 return Parts(
171 factory: factory, module: module, state: ModuleState(factory: factory, module: module), processing: nil,
172 denoiser: denoiser)
173 }
174 let processing = RTCDefaultAudioProcessingModule(
175 config: nil, capturePostProcessingDelegate: nil, renderPreProcessingDelegate: nil)
176 // The platform default on iPhone is the voice-processing I/O unit it
177 // has always been.
178 let factory = RTCPeerConnectionFactory(
179 audioDeviceModuleType: .platformDefault, bypassVoiceProcessing: false,
180 encoderFactory: encoder, decoderFactory: decoder, audioProcessingModule: processing)
181 let module = factory.audioDeviceModule
182 return Parts(
183 factory: factory, module: module, state: ModuleState(factory: factory, module: module),
184 processing: processing, denoiser: denoiser)
185 #else
186 let processing = RTCDefaultAudioProcessingModule(
187 config: nil, capturePostProcessingDelegate: nil, renderPreProcessingDelegate: nil)
188 // AVAudioEngine rather than the CoreAudio module: it is the one with a
189 // device list and a way to choose from it.
190 let factory = RTCPeerConnectionFactory(
191 audioDeviceModuleType: .audioEngine, bypassVoiceProcessing: false,
192 encoderFactory: encoder, decoderFactory: decoder, audioProcessingModule: processing)
193 let module = factory.audioDeviceModule
194 // Apple's voice processing stays off on Mac. Left allowed, the engine
195 // module resolves echo cancellation, noise suppression and gain to
196 // Apple's coupled voice-processing unit instead of WebRTC's own —
197 // a different pipeline from the one the Mac app has always had, and
198 // one whose noise suppression cannot be separated from its echo
199 // canceller. WebRTC's software processing is what ran before this
200 // module, and is what runs now.
201 _ = module.setPlatformVoiceProcessingAllowed(false)
202 let pin = AudioEnginePin()
203 let devices = EngineWatch(
204 routes: routes, module: module, input: input, pin: pin,
205 channels: InputChannelMapper(
206 defaults: defaults, layout: pin.inputLayout, explicitInput: { pin.choices.input }))
207 module.observer = devices
208 if input != nil { _ = module.setManualRenderingMode(true) }
209 return Parts(
210 factory: factory, module: module, state: ModuleState(factory: factory, module: module),
211 processing: processing, denoiser: denoiser, devices: devices)
212 #endif
213 }
214
215 /// Builds a CallAudio with its slow half on the audio queue: the main
216 /// thread only attaches the result. What a call and the audio test use.
217 static func make(
218 routes: AudioRoutes, audioDevice: RTCAudioDevice?, input: AudioInputSource? = nil,
219 reduceNoise: Bool = true, denoiser: CaptureDenoiser? = nil
220 ) async -> CallAudio {
221 #if os(macOS)
222 let defaults = routes.defaults
223 #else
224 let defaults = UserDefaults.standard
225 #endif
226 let parts = await AudioWork.run {
227 CallAudio.parts(routes: routes, defaults: defaults, audioDevice: audioDevice, input: input, denoiser: denoiser)
228 }
229 return CallAudio(parts: parts, routes: routes, reduceNoise: reduceNoise)
230 }
231
232 /// `audioDevice` is the iPhone tests' silent device. It takes the old
233 /// constructor, which has no audio device module to speak of.
234 ///
235 /// `input`, on Mac, replaces the microphone and the speaker both: the
236 /// engine runs in manual rendering mode, touching no device at all.
237 ///
238 /// Builds on the calling thread; the app uses `make`.
239 convenience init(
240 routes: AudioRoutes, audioDevice: RTCAudioDevice?, input: AudioInputSource? = nil,
241 reduceNoise: Bool = true
242 ) {
243 #if os(macOS)
244 let defaults = routes.defaults
245 #else
246 let defaults = UserDefaults.standard
247 #endif
248 self.init(
249 parts: CallAudio.parts(routes: routes, defaults: defaults, audioDevice: audioDevice, input: input, denoiser: nil),
250 routes: routes, reduceNoise: reduceNoise)
251 }
252
253 /// Attaches built parts: cheap, and on the main actor.
254 init(parts: Parts, routes: AudioRoutes, reduceNoise: Bool) {
255 factory = parts.factory
256 module = parts.module
257 state = parts.state
258 processing = parts.processing
259 denoiser = parts.denoiser
260 self.reduceNoise = reduceNoise
261 #if os(iOS)
262 plan = ProcessingPlan.plan(iPhone: true, reduceNoise: reduceNoise, rate: nil, voiceIsolation: false)
263 #else
264 plan = ProcessingPlan.plan(iPhone: false, reduceNoise: reduceNoise, rate: nil, voiceIsolation: false)
265 devices = parts.devices
266 // Attached in manual rendering too, so the interop harness drives the
267 // same routing code as the app; no device is opened either way.
268 let watch = devices
269 routes.attach(module, state: state, pin: devices.pin, inputRate: { watch.lastInputRate })
270 #endif
271 guard let processing else { return }
272 // The listener first, then the delegate: attaching the delegate can
273 // initialise it at once, and that report used to go nowhere.
274 denoiser.rateChanged = { [weak self] rate in
275 DispatchQueue.main.async {
276 MainActor.assumeIsolated {
277 guard let self, self.rate != rate else { return }
278 self.rate = rate
279 self.apply()
280 }
281 }
282 }
283 // Set after the factory exists: handed to the module's initialiser, the
284 // delegate is never called (seen in the spike, M150). The property is
285 // weak; `denoiser` is what keeps it alive.
286 processing.capturePostProcessingDelegate = denoiser
287 upgraded = CallAudio.upgrade(denoiser)
288 denoiser.enabled = plan.suppressor
289 }
290
291 /// Whether this CallAudio's suppressor is (or is becoming) DeepFilterNet,
292 /// so a fresh spare is loaded for the next one when it is done.
293 private var upgraded = false
294
295 deinit {
296 #if canImport(DeepFilter)
297 if upgraded { DeepFilterPreload.finished() }
298 #endif
299 // The factory goes on the audio queue, after everything queued for
300 // it: a stop or a close queued there must never find its module gone
301 // (it did, in the harness: a crash in `stopRecording`). Its teardown,
302 // which stops the engine, then happens there too, not on the main
303 // thread.
304 let held: (RTCPeerConnectionFactory, RTCAudioDeviceModule) = (factory, module)
305 AudioWork.post { withExtendedLifetime(held) {} }
306 }
307
308 /// Whether the microphone is capturing. The module opens it when the
309 /// first connection starts sending, not when the track is made. Either
310 /// the module says it is recording, or the capture hook has been handed
311 /// audio since this call's track was made (which is also how the
312 /// interop gate's manual rendering shows up).
313 var capturing: Bool {
314 #if os(macOS)
315 return state.read().isRecording || denoiser.buffersSeen > buffersAtStart
316 #else
317 // Not asked on iPhone: its module is the platform one, whose
318 // recording state has not been checked against a device here.
319 return true
320 #endif
321 }
322
323 /// Whether the capture hook has been handed audio since this call's
324 /// track was made: the microphone meter's "capturing" on iPhone, where
325 /// `capturing` cannot tell a microphone that has not started (alone in a
326 /// call) from one that has, and a warning about silence from a
327 /// microphone nobody has opened would be wrong.
328 var hookFlowing: Bool { denoiser.buffersSeen > buffersAtStart }
329
330 /// Set while this CallAudio asked the module to record by itself (the
331 /// audio test before a call); undone by `stopRecordingAlone`.
332 private var recordingAlone = false
333 /// Whether the module really was started by this CallAudio's ask, as
334 /// opposed to already recording. Only read and written on the audio
335 /// queue, where the start and the stop run in order.
336 private final class Started: @unchecked Sendable { var byUs = false }
337 private var started = Started()
338
339 /// Opens the microphone outside a call, as a call's first sending stream
340 /// does: the module's `initRecording` then `startRecording`, which is
341 /// exactly what WebRTC's AudioState::AddSendingStream calls.
342 ///
343 /// The audio test before a call needs this. Its in-memory loopback is
344 /// never connected, and WebRTC only starts a send stream on a
345 /// connection whose transport has been writable (BaseChannel::
346 /// IsReadyToSendMedia_w, `was_ever_writable_`), so up to 0.3.7 the test
347 /// never opened the microphone on a real device: an empty meter and
348 /// "We can't hear anything", for every microphone (Paul's AirPods,
349 /// 2026-09-26). The interop harness did not see it: in manual rendering
350 /// the module runs its input whenever its output runs.
351 ///
352 /// On the audio queue, never the main thread (0.3.9): `initRecording`
353 /// waits on WebRTC's worker thread while the engine starts, and with a
354 /// Bluetooth headset that includes its move to the hands-free profile,
355 /// one to two seconds. In 0.3.8 the main thread waited with it.
356 ///
357 /// - Parameter ask: false only for the harness's control run, which
358 /// shows that without it the module is never asked to record.
359 /// - Returns: 0, or the module's first error (-9000: no microphone
360 /// permission, as the module sees it).
361 @discardableResult
362 func startRecordingAlone(ask: Bool = true) async -> Int {
363 guard ask else {
364 AudioLog.info("test: not asking the module to record (control run)")
365 return 0
366 }
367 let module = self.module
368 // Set before the wait, so a stop asked for during it is queued, and
369 // runs after the start, and closes what it opened.
370 recordingAlone = true
371 let started = self.started
372 let began = CaptureDenoiser.now()
373 let (initialised, result, recording, already) = await AudioWork.run { () -> (Int, Int, Bool, Bool) in
374 guard !module.isRecording else { return (0, 0, true, true) }
375 let initialised = module.initRecording()
376 let result = initialised == 0 ? module.startRecording() : 0
377 started.byUs = true
378 return (initialised, result, module.isRecording, false)
379 }
380 if already {
381 AudioLog.info("test: the module is already recording")
382 } else {
383 AudioLog.info(
384 "test: asked the module to record (init \(initialised), start \(result)) in \(Int((CaptureDenoiser.now() - began) * 1000)) ms off the main thread; recording \(recording)"
385 )
386 }
387 return initialised != 0 ? initialised : result
388 }
389
390 /// A module error, in words, for the audio test.
391 static func recordingError(_ code: Int) -> String {
392 switch code {
393 case -9000, -4005, -2003: return AudioSelfTest.noPermission
394 case -4010, -2000, -2001, -2002:
395 return "The microphone would not start: macOS says it isn’t available (error \(code))."
396 case -2004: return "The microphone would not start: another app is using it (error \(code))."
397 default: return "The microphone would not start (error \(code))."
398 }
399 }
400
401 /// Closes what `startRecordingAlone` opened, on the audio queue after
402 /// it: never waited for here (the engine's stop can take as long as its
403 /// start, a headset moving back to A2DP).
404 func stopRecordingAlone() {
405 guard recordingAlone else { return }
406 recordingAlone = false
407 let module = self.module
408 let factory = self.factory
409 let started = self.started
410 self.started = Started()
411 AudioWork.post {
412 // The module is only as alive as its factory.
413 withExtendedLifetime(factory) {}
414 guard started.byUs else { return }
415 started.byUs = false
416 let result = module.stopRecording()
417 AudioLog.info("test: stopped the module's recording (\(result))")
418 }
419 }
420
421 /// Whether the module has been asked to enable its microphone since
422 /// this CallAudio was made (its "will enable" hook ran with recording
423 /// on). Asked, not granted: without microphone permission the module
424 /// refuses right after. The harness's proof that the test opens the
425 /// microphone on a real device.
426 var recordingRequested: Bool {
427 #if os(macOS)
428 return devices.recordingRequested
429 #else
430 return true
431 #endif
432 }
433
434 /// The suppressor a call starts on. See `upgrade`.
435 nonisolated static func makeSuppressor() -> NoiseSuppressor { RNNoiseSuppressor() }
436
437 /// DeepFilterNet 3 on Mac, where the app carries its model: loaded off
438 /// the main thread, then swapped in. RNNoise otherwise, and always on
439 /// iPhone (DeepFilterNet's cost there has not been measured on a phone).
440 ///
441 /// The model preloaded after launch is taken when there is one
442 /// (DeepFilterPreload), so the suppressor is DeepFilterNet from the
443 /// first buffer. Returns whether it is, or will be, DeepFilterNet.
444 private static func upgrade(_ denoiser: CaptureDenoiser) -> Bool {
445 #if canImport(DeepFilter)
446 if denoiser.engineName == "DeepFilterNet 3" { return true }
447 if let ready = DeepFilterPreload.take() {
448 denoiser.replace(with: ready)
449 return true
450 }
451 guard let model = DeepFilterSuppressor.model else { return false }
452 DispatchQueue.global(qos: .userInitiated).async {
453 if let deep = DeepFilterSuppressor(model: model) { denoiser.replace(with: deep) }
454 }
455 return true
456 #else
457 return false
458 #endif
459 }
460
461 /// The microphone track, made here so its processing options are set
462 /// before it is ever sent.
463 func makeTrack() -> RTCAudioTrack {
464 let track = factory.audioTrack(withTrackId: "microphone")
465 self.track = track
466 buffersAtStart = denoiser.buffersSeen
467 apply()
468 return track
469 }
470
471 /// Re-reads Apple's mic mode and reapplies the plan if anything moved.
472 /// Called on the audio-health tick.
473 func refresh() {
474 let isolating = AVCaptureDevice.activeMicrophoneMode == .voiceIsolation
475 if isolating != voiceIsolation {
476 voiceIsolation = isolating
477 apply()
478 }
479 }
480
481 private func apply() {
482 #if os(iOS)
483 let iPhone = true
484 #else
485 let iPhone = false
486 #endif
487 plan = ProcessingPlan.plan(
488 iPhone: iPhone, reduceNoise: reduceNoise, rate: rate, voiceIsolation: voiceIsolation)
489 denoiser.enabled = reduceNoise
490 denoiser.standAside = voiceIsolation
491 guard let track, processing != nil else { return }
492 // Set on the audio queue: setting a track's processing waits out an
493 // engine start (1,061 ms measured on the main thread with a Bluetooth
494 // switch simulated), and a rate change — which calls this — arrives
495 // exactly then. Queued ahead of anything that sends the track, so the
496 // options are still in place before it is ever sent.
497 let plan = self.plan
498 let factory = self.factory
499 applied += 1
500 let generation = applied
501 AudioWork.post {
502 withExtendedLifetime(factory) {}
503 var refused: String?
504 let result = track.setAudioProcessingOptions(CallAudio.options(plan))
505 refused = result.isSuccess ? nil : result.message
506 // Platform modes can be refused where Apple's unit cannot give them
507 // (a simulator, a route without it). Then let WebRTC choose where each
508 // enabled stage runs; what was off stays off, so the plan's rule —
509 // never two suppressors, never gain before ours — still holds.
510 if !result.isSuccess {
511 let fallback = track.setAudioProcessingOptions(CallAudio.options(plan, automatic: true))
512 if !fallback.isSuccess { refused = fallback.message }
513 }
514 DispatchQueue.main.async {
515 MainActor.assumeIsolated { [weak self] in
516 guard let self, self.applied == generation else { return }
517 self.refused = refused
518 }
519 }
520 }
521 }
522 private var applied = 0
523
524 static func options(_ plan: ProcessingPlan, automatic: Bool = false) -> RTCAudioProcessingOptions {
525 func component(_ place: ProcessingPlan.Where) -> RTCAudioProcessingComponentOptions {
526 switch place {
527 case .off: return RTCAudioProcessingComponentOptions(enabled: false)
528 case .software:
529 return RTCAudioProcessingComponentOptions(enabled: true, mode: automatic ? .automatic : .software)
530 case .platform:
531 return RTCAudioProcessingComponentOptions(enabled: true, mode: automatic ? .automatic : .platform)
532 }
533 }
534 return RTCAudioProcessingOptions(
535 echoCancellationOptions: component(plan.echoCancellation),
536 noiseSuppressionOptions: component(plan.noiseSuppression),
537 autoGainControlOptions: component(plan.gainControl),
538 highPassFilterOptions: component(plan.highPass ? .software : .off))
539 }
540
541 /// For "Copy audio diagnostics": the module, the engine, the hook.
542 func diagnostics() -> [String] {
543 var lines: [String] = []
544 let module = state.read()
545 #if os(macOS)
546 lines.append(
547 "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)" : "")"
548 )
549 let choices = enginePin.choices
550 lines.append(
551 "engine pin: chosen input \(choices.input ?? "default"), output \(choices.output ?? "default"); last applied \(enginePin.lastApplied?.description ?? "none yet"); would apply now \(enginePin.target().description)"
552 )
553 lines.append("recording requested since made: \(devices.recordingRequested)")
554 lines.append(contentsOf: devices.history())
555 #else
556 lines.append("module: playing \(module.isPlaying), recording \(module.isRecording)")
557 #endif
558 let hook = denoiser.format
559 lines.append(
560 "capture hook: \(hook.initialised ? "initialised" : "not initialised") at \(hook.rate) Hz, \(hook.channels) ch; buffers \(denoiser.buffersSeen) in all, \(denoiser.buffersSeen - buffersAtStart) since this microphone track; frames suppressed \(denoiser.framesProcessed)"
561 )
562 lines.append("suppressor: \(denoiser.engineName), \(String(describing: denoiser.state)); reduce background noise \(reduceNoise)")
563 lines.append("plan: \(String(describing: plan))")
564 lines.append("processing: \(report().summary)")
565 return lines
566 }
567
568 /// What is actually running, as WebRTC reports it — not what was asked.
569 func report() -> ProcessingReport {
570 guard let state = state.read().processing else {
571 return ProcessingReport(
572 suppression: denoiser.state, echoCancellation: "unknown", noiseSuppression: "unknown",
573 gainControl: "unknown", refused: refused, started: capturing)
574 }
575 func says(_ component: RTCAudioProcessingComponentState) -> String {
576 switch component.effective {
577 case .disabled: return "off"
578 case .software: return "WebRTC"
579 case .platform: return "Apple"
580 case .softwareAndPlatform: return "WebRTC and Apple"
581 default: return "unknown"
582 }
583 }
584 var gain = says(state.autoGainControl)
585 if plan.suppressor, gain == "off" { gain = "Calls, after noise reduction" }
586 return ProcessingReport(
587 suppression: denoiser.state, echoCancellation: says(state.echoCancellation),
588 noiseSuppression: says(state.noiseSuppression), gainControl: gain, refused: refused,
589 started: capturing)
590 }
591}
592
593#if os(macOS)
594 /// The module's observer. The device-list callback keeps the pickers
595 /// current; "will enable" is where the engine is put on the chosen
596 /// devices, before the module reads a format from it (AudioDevices.swift);
597 /// configuring the input is where a synthetic source is wired in, or —
598 /// for a real microphone — where the chosen input of a multi-channel one
599 /// is mapped (InputChannel.swift). Starting and stopping are watched so a
600 /// device the engine cannot start on is noticed rather than left silent.
601 final class EngineWatch: NSObject, RTCAudioDeviceModuleDelegate, @unchecked Sendable {
602 weak var routes: AudioRoutes?
603 weak var module: RTCAudioDeviceModule?
604 let input: AudioInputSource?
605 let pin: AudioEnginePin
606 let channels: InputChannelMapper
607 private var node: AVAudioSourceNode?
608 private let lock = NSLock()
609 /// Starts and stops seen, so a start that is followed by neither a
610 /// running engine nor a deliberate stop is known to have failed.
611 private var starts = 0
612 private var stops = 0
613 private var requested = false
614 /// The input format of the last engine start, for the rate watch.
615 private var inputRate: Double?
616 /// The last few engine events, for the diagnostics.
617 private var events: [String] = []
618
619 var recordingRequested: Bool {
620 lock.lock()
621 defer { lock.unlock() }
622 return requested
623 }
624
625 /// The input rate the engine last started with; nil when it last
626 /// started for playout only.
627 var lastInputRate: Double? {
628 lock.lock()
629 defer { lock.unlock() }
630 return inputRate
631 }
632
633 func history() -> [String] {
634 lock.lock()
635 defer { lock.unlock() }
636 return events.isEmpty ? ["engine: no events yet"] : events.map { "engine: \($0)" }
637 }
638
639 private func note(_ event: String) {
640 lock.lock()
641 events.append("\(AudioDiagnosticsStore.stamp(Date())) \(event)")
642 if events.count > 12 { events.removeFirst(events.count - 12) }
643 lock.unlock()
644 }
645 init(
646 routes: AudioRoutes, module: RTCAudioDeviceModule, input: AudioInputSource?, pin: AudioEnginePin,
647 channels: InputChannelMapper
648 ) {
649 self.routes = routes
650 self.module = module
651 self.input = input
652 self.pin = pin
653 self.channels = channels
654 }
655
656 func audioDeviceModuleDidUpdateDevices(_ audioDeviceModule: RTCAudioDeviceModule) {
657 DispatchQueue.main.async { [weak self] in
658 MainActor.assumeIsolated { self?.routes?.refresh() }
659 }
660 }
661
662 func audioDeviceModule(
663 _ audioDeviceModule: RTCAudioDeviceModule, willEnableEngine engine: AVAudioEngine,
664 isPlayoutEnabled: Bool, isRecordingEnabled: Bool, isVoiceProcessingEnabled: Bool
665 ) -> Int {
666 if isRecordingEnabled {
667 lock.lock()
668 requested = true
669 lock.unlock()
670 // The harness's stand-in for a Bluetooth headset changing profile,
671 // which holds up the engine's start here, on WebRTC's worker
672 // thread. Zero in the app.
673 let delay = AudioStartProbe.engineStartDelay
674 if delay > 0, input == nil {
675 AudioLog.info("harness: holding the engine's start \(Int(delay * 1000)) ms, as a Bluetooth switch does")
676 Thread.sleep(forTimeInterval: delay)
677 }
678 }
679 note("will enable: playout \(isPlayoutEnabled), recording \(isRecordingEnabled), voice processing \(isVoiceProcessingEnabled)")
680 guard input == nil, !engine.isInManualRenderingMode else {
681 AudioLog.info(
682 "engine will enable (playout \(isPlayoutEnabled), recording \(isRecordingEnabled)): manual rendering, no device")
683 return 0
684 }
685 let outcome = pin.apply(to: engine, recording: isRecordingEnabled)
686 switch outcome {
687 case .applied(let target):
688 AudioLog.info(
689 "engine will enable (playout \(isPlayoutEnabled), recording \(isRecordingEnabled)): on \(target.description)")
690 case .failed(let target, let why):
691 AudioLog.error(
692 "engine will enable: could not put it on \(target.description): \(why)")
693 DispatchQueue.main.async { [weak self] in
694 MainActor.assumeIsolated { self?.routes?.engineFailed(target, reason: why) }
695 }
696 }
697 return 0
698 }
699 func audioDeviceModule(
700 _ audioDeviceModule: RTCAudioDeviceModule,
701 didReceiveSpeechActivityEvent speechActivityEvent: RTCSpeechActivityEvent
702 ) {}
703 func audioDeviceModule(_ audioDeviceModule: RTCAudioDeviceModule, didCreateEngine engine: AVAudioEngine)
704 -> Int
705 { 0 }
706 func audioDeviceModule(
707 _ audioDeviceModule: RTCAudioDeviceModule, willStartEngine engine: AVAudioEngine,
708 isPlayoutEnabled: Bool, isRecordingEnabled: Bool
709 ) -> Int {
710 // By now the engine has connected the input and set the unit's
711 // client format, so the map is sized to what it will run with.
712 // Only with the microphone in use: asking for `inputNode` makes one.
713 guard input == nil, !engine.isInManualRenderingMode else { return 0 }
714 if isRecordingEnabled { channels.apply(to: engine.inputNode) }
715 let unit = isRecordingEnabled ? engine.inputNode.audioUnit : engine.outputNode.audioUnit
716 if isPlayoutEnabled, let unit { pin.mapOutput(on: unit) }
717 let actual = unit.flatMap(pin.actualInput)
718 let target = pin.lastApplied ?? .systemDefault
719 let output = engine.outputNode.outputFormat(forBus: 0)
720 let device = unit.flatMap(CoreAudioDevices.current(of:))
721 let on = device.flatMap(CoreAudioDevices.uid) ?? "unknown"
722 let rate = device.map(CoreAudioDevices.sampleRate) ?? 0
723 let line: String
724 if isRecordingEnabled {
725 let format = engine.inputNode.inputFormat(forBus: 0)
726 line =
727 "engine starting on \(target.description) (unit on \(on), \(Int(rate)) Hz): microphone \(actual ?? "unknown"), \(format.channelCount) ch at \(Int(format.sampleRate)) Hz; output \(output.channelCount) ch at \(Int(output.sampleRate)) Hz"
728 lock.lock()
729 inputRate = format.sampleRate
730 lock.unlock()
731 } else {
732 line =
733 "engine starting on \(target.description) (unit on \(on), \(Int(rate)) Hz) for playout only; output \(output.channelCount) ch at \(Int(output.sampleRate)) Hz"
734 lock.lock()
735 inputRate = nil
736 lock.unlock()
737 }
738 AudioLog.info(line)
739 note(line)
740 lock.lock()
741 starts += 1
742 let seen = (starts, stops)
743 lock.unlock()
744 DispatchQueue.main.async { [weak self] in
745 MainActor.assumeIsolated {
746 if isRecordingEnabled { self?.routes?.engineStarted(input: actual) }
747 }
748 }
749 // The module retries a start a few times and then gives up without
750 // telling its observer. Looked at once it has had time: an engine
751 // that is not running, with no stop since, did not start.
752 // Asked on the audio queue: the module's answer waits out any start
753 // in progress.
754 AudioWork.queue.asyncAfter(deadline: .now() + 2) { [weak self] in
755 guard let self, let module = self.module else { return }
756 self.lock.lock()
757 let now = (self.starts, self.stops)
758 self.lock.unlock()
759 guard now == seen, !module.isEngineRunning else { return }
760 AudioLog.error("engine did not start on \(target.description)")
761 self.note("did not start on \(target.description)")
762 DispatchQueue.main.async { [weak self] in
763 MainActor.assumeIsolated {
764 self?.routes?.engineFailed(target, reason: "the audio engine would not start on it")
765 }
766 }
767 }
768 return 0
769 }
770 func audioDeviceModule(
771 _ audioDeviceModule: RTCAudioDeviceModule, didStopEngine engine: AVAudioEngine,
772 isPlayoutEnabled: Bool, isRecordingEnabled: Bool
773 ) -> Int {
774 note("stopped (playout \(isPlayoutEnabled), recording \(isRecordingEnabled) next)")
775 stopped()
776 return 0
777 }
778 func audioDeviceModule(
779 _ audioDeviceModule: RTCAudioDeviceModule, didDisableEngine engine: AVAudioEngine,
780 isPlayoutEnabled: Bool, isRecordingEnabled: Bool
781 ) -> Int { 0 }
782 func audioDeviceModule(
783 _ audioDeviceModule: RTCAudioDeviceModule, willReleaseEngine engine: AVAudioEngine
784 ) -> Int {
785 note("released")
786 stopped()
787 return 0
788 }
789 private func stopped() {
790 lock.lock()
791 stops += 1
792 lock.unlock()
793 }
794 func audioDeviceModule(
795 _ audioDeviceModule: RTCAudioDeviceModule, engine: AVAudioEngine,
796 configureInputFromSource source: AVAudioNode?, toDestination destination: AVAudioNode,
797 format: AVAudioFormat, context: [AnyHashable: Any]
798 ) -> Int {
799 // A real microphone: map the chosen input and connect nothing. With
800 // nothing connected to the mixer the module makes its own default
801 // connection, input node to mixer in mono (audio_engine_device.mm,
802 // "Nothing connected to input mixer, connecting input node").
803 guard let input else {
804 channels.apply(to: source)
805 return 0
806 }
807 guard let mono = AVAudioFormat(standardFormatWithSampleRate: format.sampleRate, channels: 1)
808 else { return 0 }
809 if let node { engine.detach(node) }
810 let rate = format.sampleRate
811 let node = AVAudioSourceNode(format: mono) { _, _, frames, buffers in
812 for buffer in UnsafeMutableAudioBufferListPointer(buffers) {
813 guard let data = buffer.mData else { continue }
814 input.render(data.assumingMemoryBound(to: Float.self), count: Int(frames), sampleRate: rate)
815 }
816 return noErr
817 }
818 self.node = node
819 engine.attach(node)
820 let mixer =
821 engine.isInManualRenderingMode
822 ? engine.mainMixerNode
823 : (context[kLKRTCAudioEngineInputMixerNodeKey] as? AVAudioMixerNode) ?? engine.mainMixerNode
824 engine.connect(node, to: mixer, format: mono)
825 return 0
826 }
827 func audioDeviceModule(
828 _ audioDeviceModule: RTCAudioDeviceModule, engine: AVAudioEngine,
829 configureOutputFromSource source: AVAudioNode, toDestination destination: AVAudioNode?,
830 format: AVAudioFormat, context: [AnyHashable: Any]
831 ) -> Int { 0 }
832 }
833#endif
834
835/// The audio device module's state, for the main thread to read.
836///
837/// Every property of LiveKit's module is a blocking hop to WebRTC's worker
838/// thread, and the worker holds on to it for the whole of an engine start
839/// or rebuild — a Bluetooth headset's profile switch included. Measured
840/// with that switch simulated at 1.5 s: `isRecording`, `isEngineRunning`,
841/// `isPlaying`, the device lists and the factory's processing state each
842/// held the main thread for 0.9 to 1.2 s. The meters read it ten times a
843/// second.
844///
845/// So the main thread never asks the module. It asks this, which reads the
846/// module on a queue of its own and waits at most a few milliseconds for
847/// the answer; if the module is busy it gets the last answer, marked stale,
848/// and the fresh one is there for the next look.
849final class ModuleState: @unchecked Sendable {
850 struct Snapshot: @unchecked Sendable {
851 var isRecording = false
852 var isPlaying = false
853 var isEngineRunning = false
854 var isManualRenderingMode = false
855 var isPlayoutInitialized = false
856 var isRecordingInitialized = false
857 var isMicrophoneMuted = false
858 var processing: RTCAudioProcessingState?
859 /// The module did not answer in time: this is its last answer.
860 var stale = false
861 }
862
863 private weak var factory: RTCPeerConnectionFactory?
864 private weak var module: RTCAudioDeviceModule?
865 private let queue = DispatchQueue(label: "net.amadan.oficina.calls.audio-state", qos: .userInitiated)
866 private let lock = NSLock()
867 private var latest = Snapshot()
868 private var reading: DispatchGroup?
869 /// How long a read on the main thread may wait for the module.
870 static let patience: TimeInterval = 0.005
871
872 /// Reads once, here and now: made off the main thread with the parts.
873 init(factory: RTCPeerConnectionFactory, module: RTCAudioDeviceModule) {
874 self.factory = factory
875 self.module = module
876 latest = ModuleState.snapshot(factory: factory, module: module)
877 }
878
879 func read(patience: TimeInterval = ModuleState.patience) -> Snapshot {
880 lock.lock()
881 let group: DispatchGroup
882 if let reading {
883 group = reading
884 } else {
885 group = DispatchGroup()
886 group.enter()
887 reading = group
888 queue.async { [self] in
889 let fresh = factory.flatMap { f in module.map { ModuleState.snapshot(factory: f, module: $0) } }
890 lock.lock()
891 if let fresh { latest = fresh }
892 reading = nil
893 lock.unlock()
894 group.leave()
895 }
896 }
897 lock.unlock()
898 let answered = group.wait(timeout: .now() + patience) == .success
899 lock.lock()
900 defer { lock.unlock() }
901 var out = latest
902 out.stale = !answered
903 return out
904 }
905
906 private static func snapshot(factory: RTCPeerConnectionFactory, module: RTCAudioDeviceModule) -> Snapshot {
907 var s = Snapshot()
908 s.isRecording = module.isRecording
909 s.isPlaying = module.isPlaying
910 #if os(macOS)
911 s.isEngineRunning = module.isEngineRunning
912 s.isManualRenderingMode = module.isManualRenderingMode
913 s.isPlayoutInitialized = module.isPlayoutInitialized
914 s.isRecordingInitialized = module.isRecordingInitialized
915 s.isMicrophoneMuted = module.isMicrophoneMuted
916 #endif
917 s.processing = factory.audioProcessingState
918 return s
919 }
920}
921