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 } }