| 1 | import AppKit |
| 2 | import CallsKit |
| 3 | import Foundation |
| 4 | import SwiftUI |
| 5 | |
| 6 | /// `CallsInterop --audio-selftest`: the audio test's record-and-play-back |
| 7 | /// path, run end to end without a microphone. |
| 8 | /// |
| 9 | /// The test's own standalone capture is built as on the home screen — a |
| 10 | /// CallAudio on LiveKit's engine module, WebRTC's processing module, the |
| 11 | /// CaptureDenoiser in the capture hook — with the module in manual |
| 12 | /// rendering and the synthetic voice as its microphone (as the call gate |
| 13 | /// runs it). The microphone is started as the test starts it, by the |
| 14 | /// in-memory loopback (SelfTestLoopback), and the recording is taken from |
| 15 | /// the hook's sink. It must be |
| 16 | /// non-silent, the right length and rate, handed to the player once, and |
| 17 | /// gone afterwards. Run once with noise reduction on (whichever suppressor |
| 18 | /// the build has; DeepFilterNet with CALLS_DEEPFILTER_MODEL set) and once |
| 19 | /// with it off. Then a control, so a pass means something: a silent |
| 20 | /// microphone must be called silent, and its recording silent. Then the |
| 21 | /// start on a real device (deviceStart), with its own control. Prints one |
| 22 | /// PASS or FAIL line per run; exits 0 only if all five passed. |
| 23 | @MainActor enum AudioSelfTestHarness { |
| 24 | /// -40 dBFS RMS. The synthetic voice is about -20 dBFS; silence is |
| 25 | /// below -90. |
| 26 | static let minimumRMS: Float = 0.01 |
| 27 | |
| 28 | static func run() async -> Bool { |
| 29 | var passed = true |
| 30 | for reduceNoise in [true, false] { |
| 31 | passed = await runOnce(reduceNoise: reduceNoise) && passed |
| 32 | } |
| 33 | passed = await silentControl() && passed |
| 34 | passed = await deviceStart() && passed |
| 35 | return passed |
| 36 | } |
| 37 | |
| 38 | /// The test on the real audio device module, not manual rendering: it |
| 39 | /// must ask the module to record. Manual rendering runs its input |
| 40 | /// whenever its output runs, so the runs above pass whether or not the |
| 41 | /// test ever opens a microphone; on a device it has to be asked, and up |
| 42 | /// to 0.3.7 it never was (the loopback is never connected, and WebRTC |
| 43 | /// opens the microphone only for a send stream on a connection that has |
| 44 | /// been). This process has no microphone permission, so the module |
| 45 | /// refuses right after being asked; being asked is what is checked. |
| 46 | /// First the control, 0.3.7's start, which must come back not asked, |
| 47 | /// so a pass means the ask is what made the difference. |
| 48 | private static func deviceStart() async -> Bool { |
| 49 | var passed = true |
| 50 | let control = await AudioSelfTest.deviceStartProbe(askToRecord: false) |
| 51 | let controlLabel = "audio self-test control (0.3.7's start on a real device never opens the microphone)" |
| 52 | if control.asked { |
| 53 | print("FAIL \(controlLabel): the module was asked to record anyway; \(control.detail)") |
| 54 | passed = false |
| 55 | } else { |
| 56 | print("PASS \(controlLabel): \(control.detail)") |
| 57 | } |
| 58 | let fixed = await AudioSelfTest.deviceStartProbe(askToRecord: true) |
| 59 | let label = "audio self-test on a real device (asks the module to record)" |
| 60 | if fixed.asked { |
| 61 | print("PASS \(label): \(fixed.detail)") |
| 62 | } else { |
| 63 | print("FAIL \(label): the microphone was never asked for; \(fixed.detail)") |
| 64 | passed = false |
| 65 | } |
| 66 | return passed |
| 67 | } |
| 68 | |
| 69 | private static func silentControl() async -> Bool { |
| 70 | let label = "audio self-test control (a silent microphone)" |
| 71 | let player = CheckingPlayer() |
| 72 | let test = AudioSelfTest.harness(input: Silence(), player: player, reduceNoise: true, recordSeconds: 2) |
| 73 | await test.open() |
| 74 | defer { test.close() } |
| 75 | let deadline = Date().addingTimeInterval(8) |
| 76 | while test.micVerdict != .silent, Date() < deadline { |
| 77 | try? await Task.sleep(for: .milliseconds(100)) |
| 78 | } |
| 79 | var problems: [String] = [] |
| 80 | if test.micVerdict != .silent { problems.append("microphone verdict \(test.micVerdict), wanted silent") } |
| 81 | await test.recordAndPlayBack() |
| 82 | if test.recording != .played(silent: true) { problems.append("recording state \(test.recording), wanted played(silent: true)") } |
| 83 | if let summary = test.lastRecording, summary.rms >= minimumRMS { |
| 84 | problems.append("a silent microphone recorded RMS \(db(summary.rms)) dBFS") |
| 85 | } |
| 86 | if test.lastRecording == nil { problems.append("the capture ran no buffers at all") } |
| 87 | if problems.isEmpty { |
| 88 | print("PASS \(label): called silent, and its recording was silent") |
| 89 | return true |
| 90 | } |
| 91 | print("FAIL \(label): \(problems.joined(separator: "; "))") |
| 92 | return false |
| 93 | } |
| 94 | |
| 95 | private static func runOnce(reduceNoise: Bool) async -> Bool { |
| 96 | let label = "audio self-test (noise reduction \(reduceNoise ? "on" : "off"))" |
| 97 | let player = CheckingPlayer() |
| 98 | let test = AudioSelfTest.harness( |
| 99 | input: SyntheticVoice(), player: player, reduceNoise: reduceNoise, recordSeconds: 5) |
| 100 | await test.open() |
| 101 | defer { test.close() } |
| 102 | // The microphone verdict first, as the person would see it. |
| 103 | let deadline = Date().addingTimeInterval(8) |
| 104 | while test.micVerdict != .working, Date() < deadline { |
| 105 | try? await Task.sleep(for: .milliseconds(100)) |
| 106 | } |
| 107 | let verdict = test.micVerdict |
| 108 | await test.recordAndPlayBack() |
| 109 | // For looking at, not for passing: the test's own view, after the run. |
| 110 | if let dir = ProcessInfo.processInfo.environment["CALLS_SELFTEST_SNAPSHOT"] { |
| 111 | snapshot(test, to: URL(fileURLWithPath: dir).appendingPathComponent( |
| 112 | "audio-selftest-\(reduceNoise ? "on" : "off").png")) |
| 113 | } |
| 114 | var problems: [String] = [] |
| 115 | if let problem = test.micProblem { problems.append("microphone: \(problem)") } |
| 116 | if verdict != .working { problems.append("microphone verdict \(verdict), wanted working") } |
| 117 | guard let summary = test.lastRecording else { |
| 118 | print("FAIL \(label): nothing recorded (\(test.recording)) \(problems.joined(separator: "; "))") |
| 119 | return false |
| 120 | } |
| 121 | if test.recording != .played(silent: false) { problems.append("recording state \(test.recording)") } |
| 122 | if summary.rms < minimumRMS { problems.append("recording is silent (RMS \(db(summary.rms)) dBFS)") } |
| 123 | if abs(summary.seconds - test.recordSeconds) > 0.05 { |
| 124 | problems.append("recorded \(summary.seconds) s, wanted \(test.recordSeconds)") |
| 125 | } |
| 126 | if summary.rate != 48_000 { problems.append("recorded at \(summary.rate) Hz, wanted 48000") } |
| 127 | if player.plays != 1 { problems.append("played \(player.plays) times, wanted once") } |
| 128 | if abs(player.rms - summary.rms) > 1e-4 { problems.append("the player was not given the recording") } |
| 129 | if test.holdsRecording { problems.append("the recording was kept after playback") } |
| 130 | let suppressed = test.framesSuppressed |
| 131 | if reduceNoise, suppressed == 0 { problems.append("noise reduction was on but processed no frames") } |
| 132 | if !reduceNoise, suppressed != 0 { problems.append("noise reduction was off but processed \(suppressed) frames") } |
| 133 | let detail = |
| 134 | "\(String(format: "%.2f", summary.seconds)) s at \(summary.rate) Hz, RMS \(db(summary.rms)) dBFS, " |
| 135 | + "\(test.suppressorName) processed \(suppressed) frames" |
| 136 | if problems.isEmpty { |
| 137 | print("PASS \(label): \(detail)") |
| 138 | return true |
| 139 | } |
| 140 | print("FAIL \(label): \(problems.joined(separator: "; ")) [\(detail)]") |
| 141 | return false |
| 142 | } |
| 143 | |
| 144 | private static func snapshot(_ test: AudioSelfTest, to url: URL) { |
| 145 | NSApplication.shared.setActivationPolicy(.prohibited) |
| 146 | let view = NSHostingView( |
| 147 | rootView: AudioSelfTestView(test: test).padding(20).frame(width: 460) |
| 148 | .background(Color.white).tint(CallsAppearance.accent).environment(\.colorScheme, .light)) |
| 149 | view.frame = NSRect(x: 0, y: 0, width: 460, height: view.fittingSize.height) |
| 150 | view.layoutSubtreeIfNeeded() |
| 151 | guard let bitmap = view.bitmapImageRepForCachingDisplay(in: view.bounds) else { return } |
| 152 | view.cacheDisplay(in: view.bounds, to: bitmap) |
| 153 | try? FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) |
| 154 | try? bitmap.representation(using: .png, properties: [:])?.write(to: url) |
| 155 | } |
| 156 | |
| 157 | static func db(_ level: Float) -> String { String(format: "%.1f", SampleLevel.db(level)) } |
| 158 | } |
| 159 | |
| 160 | /// A microphone that delivers buffers of zeros: the empty second input |
| 161 | /// of an interface captured in mono. |
| 162 | private final class Silence: AudioInputSource, @unchecked Sendable { |
| 163 | func render(_ samples: UnsafeMutablePointer<Float>, count: Int, sampleRate: Double) { |
| 164 | samples.update(repeating: 0, count: count) |
| 165 | } |
| 166 | } |
| 167 | |
| 168 | /// Plays nothing: records what it was given, and feeds the meter as a |
| 169 | /// player would. |
| 170 | @MainActor private final class CheckingPlayer: AudioSelfTestPlayer { |
| 171 | var plays = 0 |
| 172 | var rms: Float = 0 |
| 173 | |
| 174 | func play(_ samples: [Float], sampleRate: Double, speaker: String?, meter: OutputMeter) async throws { |
| 175 | plays += 1 |
| 176 | rms = SampleLevel.rms(samples) |
| 177 | samples.withUnsafeBufferPointer { meter.record($0.baseAddress!, count: $0.count) } |
| 178 | try? await Task.sleep(for: .milliseconds(200)) |
| 179 | } |
| 180 | func stop() {} |
| 181 | } |
| 182 | |