import AppKit import CallsKit import Foundation import SwiftUI /// `CallsInterop --audio-selftest`: the audio test's record-and-play-back /// path, run end to end without a microphone. /// /// The test's own standalone capture is built as on the home screen — a /// CallAudio on LiveKit's engine module, WebRTC's processing module, the /// CaptureDenoiser in the capture hook — with the module in manual /// rendering and the synthetic voice as its microphone (as the call gate /// runs it). The microphone is started as the test starts it, by the /// in-memory loopback (SelfTestLoopback), and the recording is taken from /// the hook's sink. It must be /// non-silent, the right length and rate, handed to the player once, and /// gone afterwards. Run once with noise reduction on (whichever suppressor /// the build has; DeepFilterNet with CALLS_DEEPFILTER_MODEL set) and once /// with it off. Then a control, so a pass means something: a silent /// microphone must be called silent, and its recording silent. Then the /// start on a real device (deviceStart), with its own control. Prints one /// PASS or FAIL line per run; exits 0 only if all five passed. @MainActor enum AudioSelfTestHarness { /// -40 dBFS RMS. The synthetic voice is about -20 dBFS; silence is /// below -90. static let minimumRMS: Float = 0.01 static func run() async -> Bool { var passed = true for reduceNoise in [true, false] { passed = await runOnce(reduceNoise: reduceNoise) && passed } passed = await silentControl() && passed passed = await deviceStart() && passed return passed } /// The test on the real audio device module, not manual rendering: it /// must ask the module to record. Manual rendering runs its input /// whenever its output runs, so the runs above pass whether or not the /// test ever opens a microphone; on a device it has to be asked, and up /// to 0.3.7 it never was (the loopback is never connected, and WebRTC /// opens the microphone only for a send stream on a connection that has /// been). This process has no microphone permission, so the module /// refuses right after being asked; being asked is what is checked. /// First the control, 0.3.7's start, which must come back not asked, /// so a pass means the ask is what made the difference. private static func deviceStart() async -> Bool { var passed = true let control = await AudioSelfTest.deviceStartProbe(askToRecord: false) let controlLabel = "audio self-test control (0.3.7's start on a real device never opens the microphone)" if control.asked { print("FAIL \(controlLabel): the module was asked to record anyway; \(control.detail)") passed = false } else { print("PASS \(controlLabel): \(control.detail)") } let fixed = await AudioSelfTest.deviceStartProbe(askToRecord: true) let label = "audio self-test on a real device (asks the module to record)" if fixed.asked { print("PASS \(label): \(fixed.detail)") } else { print("FAIL \(label): the microphone was never asked for; \(fixed.detail)") passed = false } return passed } private static func silentControl() async -> Bool { let label = "audio self-test control (a silent microphone)" let player = CheckingPlayer() let test = AudioSelfTest.harness(input: Silence(), player: player, reduceNoise: true, recordSeconds: 2) await test.open() defer { test.close() } let deadline = Date().addingTimeInterval(8) while test.micVerdict != .silent, Date() < deadline { try? await Task.sleep(for: .milliseconds(100)) } var problems: [String] = [] if test.micVerdict != .silent { problems.append("microphone verdict \(test.micVerdict), wanted silent") } await test.recordAndPlayBack() if test.recording != .played(silent: true) { problems.append("recording state \(test.recording), wanted played(silent: true)") } if let summary = test.lastRecording, summary.rms >= minimumRMS { problems.append("a silent microphone recorded RMS \(db(summary.rms)) dBFS") } if test.lastRecording == nil { problems.append("the capture ran no buffers at all") } if problems.isEmpty { print("PASS \(label): called silent, and its recording was silent") return true } print("FAIL \(label): \(problems.joined(separator: "; "))") return false } private static func runOnce(reduceNoise: Bool) async -> Bool { let label = "audio self-test (noise reduction \(reduceNoise ? "on" : "off"))" let player = CheckingPlayer() let test = AudioSelfTest.harness( input: SyntheticVoice(), player: player, reduceNoise: reduceNoise, recordSeconds: 5) await test.open() defer { test.close() } // The microphone verdict first, as the person would see it. let deadline = Date().addingTimeInterval(8) while test.micVerdict != .working, Date() < deadline { try? await Task.sleep(for: .milliseconds(100)) } let verdict = test.micVerdict await test.recordAndPlayBack() // For looking at, not for passing: the test's own view, after the run. if let dir = ProcessInfo.processInfo.environment["CALLS_SELFTEST_SNAPSHOT"] { snapshot(test, to: URL(fileURLWithPath: dir).appendingPathComponent( "audio-selftest-\(reduceNoise ? "on" : "off").png")) } var problems: [String] = [] if let problem = test.micProblem { problems.append("microphone: \(problem)") } if verdict != .working { problems.append("microphone verdict \(verdict), wanted working") } guard let summary = test.lastRecording else { print("FAIL \(label): nothing recorded (\(test.recording)) \(problems.joined(separator: "; "))") return false } if test.recording != .played(silent: false) { problems.append("recording state \(test.recording)") } if summary.rms < minimumRMS { problems.append("recording is silent (RMS \(db(summary.rms)) dBFS)") } if abs(summary.seconds - test.recordSeconds) > 0.05 { problems.append("recorded \(summary.seconds) s, wanted \(test.recordSeconds)") } if summary.rate != 48_000 { problems.append("recorded at \(summary.rate) Hz, wanted 48000") } if player.plays != 1 { problems.append("played \(player.plays) times, wanted once") } if abs(player.rms - summary.rms) > 1e-4 { problems.append("the player was not given the recording") } if test.holdsRecording { problems.append("the recording was kept after playback") } let suppressed = test.framesSuppressed if reduceNoise, suppressed == 0 { problems.append("noise reduction was on but processed no frames") } if !reduceNoise, suppressed != 0 { problems.append("noise reduction was off but processed \(suppressed) frames") } let detail = "\(String(format: "%.2f", summary.seconds)) s at \(summary.rate) Hz, RMS \(db(summary.rms)) dBFS, " + "\(test.suppressorName) processed \(suppressed) frames" if problems.isEmpty { print("PASS \(label): \(detail)") return true } print("FAIL \(label): \(problems.joined(separator: "; ")) [\(detail)]") return false } private static func snapshot(_ test: AudioSelfTest, to url: URL) { NSApplication.shared.setActivationPolicy(.prohibited) let view = NSHostingView( rootView: AudioSelfTestView(test: test).padding(20).frame(width: 460) .background(Color.white).tint(CallsAppearance.accent).environment(\.colorScheme, .light)) view.frame = NSRect(x: 0, y: 0, width: 460, height: view.fittingSize.height) view.layoutSubtreeIfNeeded() guard let bitmap = view.bitmapImageRepForCachingDisplay(in: view.bounds) else { return } view.cacheDisplay(in: view.bounds, to: bitmap) try? FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) try? bitmap.representation(using: .png, properties: [:])?.write(to: url) } static func db(_ level: Float) -> String { String(format: "%.1f", SampleLevel.db(level)) } } /// A microphone that delivers buffers of zeros: the empty second input /// of an interface captured in mono. private final class Silence: AudioInputSource, @unchecked Sendable { func render(_ samples: UnsafeMutablePointer, count: Int, sampleRate: Double) { samples.update(repeating: 0, count: count) } } /// Plays nothing: records what it was given, and feeds the meter as a /// player would. @MainActor private final class CheckingPlayer: AudioSelfTestPlayer { var plays = 0 var rms: Float = 0 func play(_ samples: [Float], sampleRate: Double, speaker: String?, meter: OutputMeter) async throws { plays += 1 rms = SampleLevel.rms(samples) samples.withUnsafeBufferPointer { meter.record($0.baseAddress!, count: $0.count) } try? await Task.sleep(for: .milliseconds(200)) } func stop() {} }