| 1 | import Combine |
| 2 | import Foundation |
| 3 | import LiveKitWebRTC |
| 4 | |
| 5 | /// The main-thread stall harness's view into the audio start |
| 6 | /// (`CallsInterop --audio-start-stall`). It runs the start the app runs — |
| 7 | /// Test audio before a call, and a call's audio as a join builds it — on |
| 8 | /// the real engine module, not manual rendering, while the harness |
| 9 | /// watches the main thread. A test process has no microphone permission, |
| 10 | /// so the module refuses right after it is asked; everything before that |
| 11 | /// (building the factory, negotiating, the engine's start up to the |
| 12 | /// microphone) runs as in the app. A Bluetooth headset's profile switch is |
| 13 | /// stood in for by `engineStartDelay`, held on WebRTC's worker thread in |
| 14 | /// the engine's "will enable" hook, which is where a real switch holds it. |
| 15 | @_spi(Fixtures) public enum AudioStartProbe { |
| 16 | /// Seconds the engine's start with a microphone is held, as a Bluetooth |
| 17 | /// headset's switch to its hands-free profile holds it. Zero in the app. |
| 18 | nonisolated(unsafe) public static var engineStartDelay: TimeInterval = 0 |
| 19 | |
| 20 | public struct Report: Sendable { |
| 21 | /// What the test window said while it started, and when (seconds from |
| 22 | /// the start of the run). |
| 23 | public var phases: [(at: Double, phase: MicStartPhase)] = [] |
| 24 | /// Seconds until `open` (or the join's audio start) returned. |
| 25 | public var startSeconds: Double = 0 |
| 26 | /// Whether the module was asked for the microphone. |
| 27 | public var asked = false |
| 28 | /// Whether the module was still recording once it had all settled. |
| 29 | public var recordingAfter = false |
| 30 | public var problem: String? |
| 31 | public var detail = "" |
| 32 | } |
| 33 | |
| 34 | /// Test audio before a call, on the device module: opened, and closed |
| 35 | /// once started — or, with `closeAfter`, closed partway through. |
| 36 | @MainActor public static func testAudio(closeAfter: Double? = nil) async -> Report { |
| 37 | let began = CaptureDenoiser.now() |
| 38 | var report = Report() |
| 39 | #if os(macOS) |
| 40 | let routes = AudioRoutes(defaults: UserDefaults(suiteName: "calls-stall-\(UUID().uuidString)")!) |
| 41 | #else |
| 42 | let routes = AudioRoutes() |
| 43 | #endif |
| 44 | let capture = StandaloneCapture(routes: routes, input: nil, reduceNoise: true, assumePermitted: true) |
| 45 | let test = AudioSelfTest(capture: capture, player: EnginePlayer(), routes: routes) |
| 46 | var phases: [(Double, MicStartPhase)] = [] |
| 47 | let watch = test.$startPhase.sink { phases.append((CaptureDenoiser.now() - began, $0)) } |
| 48 | if let closeAfter { |
| 49 | Task { @MainActor in |
| 50 | try? await Task.sleep(for: .milliseconds(Int(closeAfter * 1000))) |
| 51 | test.close() |
| 52 | } |
| 53 | } |
| 54 | await test.open() |
| 55 | report.startSeconds = CaptureDenoiser.now() - began |
| 56 | report.problem = test.micProblem |
| 57 | test.close() |
| 58 | // The stop is queued behind the start; let it run. |
| 59 | await AudioWork.drain() |
| 60 | try? await Task.sleep(for: .milliseconds(200)) |
| 61 | await AudioWork.drain() |
| 62 | watch.cancel() |
| 63 | report.phases = phases |
| 64 | report.asked = capture.audio?.recordingRequested ?? false |
| 65 | let module = capture.audio?.module |
| 66 | report.recordingAfter = await AudioWork.run { module?.isRecording ?? false } |
| 67 | report.detail = "suppressor \(test.suppressorName)" |
| 68 | return report |
| 69 | } |
| 70 | |
| 71 | /// A call's audio as a join starts it: the media built as `Call.join` |
| 72 | /// builds it (MediaEngine.make), the camera and microphone tracks made |
| 73 | /// (`prepare`), the first sending and receiving streams negotiated on |
| 74 | /// the call's factory through PeerCalls, as MediaPeer negotiates them, |
| 75 | /// and then WebRTC's worker thread opening the microphone for the first |
| 76 | /// sending stream (AudioState::AddSendingStream), as it does once a |
| 77 | /// connection is up. While the worker is held there, the main thread |
| 78 | /// does what a call's does: reads the meters and the readout ten times |
| 79 | /// a second, and — half a second in — negotiates a second person's |
| 80 | /// connection. |
| 81 | @MainActor public static func callJoin(camera: VideoCapture) async -> Report { |
| 82 | let began = CaptureDenoiser.now() |
| 83 | var report = Report() |
| 84 | #if os(macOS) |
| 85 | let call = Call(playAudio: false, audioInput: nil) |
| 86 | #else |
| 87 | let call = Call(playAudio: false) |
| 88 | #endif |
| 89 | let media = await MediaEngine.make(call: call) |
| 90 | do { |
| 91 | try await media.prepare(capture: camera) |
| 92 | } catch { |
| 93 | report.problem = "prepare: \(error.localizedDescription)" |
| 94 | } |
| 95 | guard let track = media.localAudio else { |
| 96 | report.problem = "no microphone track" |
| 97 | return report |
| 98 | } |
| 99 | var loops: [SelfTestLoopback] = [] |
| 100 | do { |
| 101 | loops.append(try await SelfTestLoopback.make(factory: media.factory, track: track)) |
| 102 | } catch { |
| 103 | report.problem = "negotiation: \(error.localizedDescription)" |
| 104 | } |
| 105 | let module = media.audio.module |
| 106 | let opened = DispatchSemaphore(value: 0) |
| 107 | // WebRTC's own start, on its own thread: not the app's to call. |
| 108 | DispatchQueue.global(qos: .userInitiated).async { |
| 109 | if module.initRecording() == 0 { _ = module.startRecording() } |
| 110 | opened.signal() |
| 111 | } |
| 112 | let deadline = CaptureDenoiser.now() + AudioStartProbe.engineStartDelay + 1 |
| 113 | var tick = 0 |
| 114 | while CaptureDenoiser.now() < deadline { |
| 115 | try? await Task.sleep(for: .milliseconds(100)) |
| 116 | tick += 1 |
| 117 | // The call's main-thread reads each tick (sampleMic, the readout). |
| 118 | _ = media.audio.capturing |
| 119 | _ = media.audio.report() |
| 120 | _ = media.audio.denoiser.level.take() |
| 121 | if tick == 3 { _ = media.audio.diagnostics() } |
| 122 | if tick == 5 { |
| 123 | do { |
| 124 | loops.append(try await SelfTestLoopback.make(factory: media.factory, track: track)) |
| 125 | } catch { |
| 126 | report.problem = "second negotiation: \(error.localizedDescription)" |
| 127 | } |
| 128 | } |
| 129 | } |
| 130 | await AudioWork.run { opened.wait() } |
| 131 | report.startSeconds = CaptureDenoiser.now() - began |
| 132 | report.asked = media.audio.recordingRequested |
| 133 | for loop in loops { loop.close() } |
| 134 | AudioWork.post { _ = module.stopRecording() } |
| 135 | media.close() |
| 136 | await AudioWork.drain() |
| 137 | report.recordingAfter = await AudioWork.run { module.isRecording } |
| 138 | report.detail = "\(loops.count) connections negotiated" |
| 139 | return report |
| 140 | } |
| 141 | } |
| 142 | |