| 1 | import XCTest |
| 2 | |
| 3 | @testable import CallsKit |
| 4 | |
| 5 | /// AudioHealth is the one part of the device work with real logic in it, |
| 6 | /// and the one that has to be right: it is what a person reads when a |
| 7 | /// call sounds wrong, and on macOS it is the only instrument there is. |
| 8 | final class AudioHealthTests: XCTestCase { |
| 9 | func testNothingReceivedIsSaidPlainlyAndNeverDividedBy() { |
| 10 | let health = AudioHealth(concealed: 0, total: 0, inserted: 0, removed: 0, jitterBufferMs: 0) |
| 11 | XCTAssertTrue(health.silent) |
| 12 | // Not 0% clean: no audio is not clean audio, and a rate computed from |
| 13 | // an empty denominator would say the opposite of the truth. |
| 14 | XCTAssertEqual(health.concealmentRate, 0) |
| 15 | XCTAssertEqual(health.driftRate, 0) |
| 16 | XCTAssertEqual(health.summary, "No audio received yet.") |
| 17 | } |
| 18 | |
| 19 | func testDriftIsNamedAheadOfLossWhenBothAreUp() { |
| 20 | // The growl case. Loss is common, transient and widely understood; |
| 21 | // sustained drift is rarer, is what makes a voice sound *wrong* |
| 22 | // rather than broken, and is the one nobody thinks to look for. With |
| 23 | // both raised, the rarer diagnosis is the useful one to lead with. |
| 24 | let health = AudioHealth( |
| 25 | concealed: 20_000, total: 100_000, inserted: 4_000, removed: 1_000, jitterBufferMs: 300) |
| 26 | XCTAssertEqual(health.driftRate, 0.05, accuracy: 0.0001) |
| 27 | XCTAssertEqual(health.concealmentRate, 0.20, accuracy: 0.0001) |
| 28 | XCTAssertTrue(health.summary.contains("resampled"), health.summary) |
| 29 | XCTAssertTrue(health.summary.contains("growling"), health.summary) |
| 30 | } |
| 31 | |
| 32 | func testLossAloneReadsAsDropoutsAndNotAsDrift() { |
| 33 | let health = AudioHealth( |
| 34 | concealed: 10_000, total: 100_000, inserted: 0, removed: 0, jitterBufferMs: 40) |
| 35 | XCTAssertTrue(health.summary.contains("did not arrive"), health.summary) |
| 36 | XCTAssertTrue(health.summary.contains("dropouts"), health.summary) |
| 37 | XCTAssertFalse(health.summary.contains("growling"), health.summary) |
| 38 | } |
| 39 | |
| 40 | func testAHealthyCallSaysSoWithItsNumbers() { |
| 41 | let health = AudioHealth( |
| 42 | concealed: 500, total: 100_000, inserted: 100, removed: 100, jitterBufferMs: 30) |
| 43 | XCTAssertFalse(health.silent) |
| 44 | XCTAssertTrue(health.summary.contains("cleanly"), health.summary) |
| 45 | // The numbers ride along even when the verdict is good: someone |
| 46 | // comparing two calls needs them, and rounding to zero is honest at |
| 47 | // this resolution. |
| 48 | XCTAssertTrue(health.summary.contains("0%"), health.summary) |
| 49 | } |
| 50 | |
| 51 | func testDriftCountsBothDirections() { |
| 52 | // Inserting and removing are the same fault seen from two sides — the |
| 53 | // signal was rescaled either way. A reading that counted only one |
| 54 | // would miss a sender whose clock runs fast. |
| 55 | let fast = AudioHealth(concealed: 0, total: 1_000, inserted: 0, removed: 50, jitterBufferMs: 0) |
| 56 | let slow = AudioHealth(concealed: 0, total: 1_000, inserted: 50, removed: 0, jitterBufferMs: 0) |
| 57 | XCTAssertEqual(fast.driftRate, slow.driftRate) |
| 58 | XCTAssertTrue(fast.summary.contains("resampled"), fast.summary) |
| 59 | XCTAssertTrue(slow.summary.contains("resampled"), slow.summary) |
| 60 | } |
| 61 | |
| 62 | func testItReadsTheStatsDictionaryTheEngineActuallyProduces() { |
| 63 | // The field names are libwebrtc's, spelled once in MediaEngine and |
| 64 | // once here. This is the test that fails if they stop agreeing. |
| 65 | let health = AudioHealth([ |
| 66 | "concealedSamples": 7, "totalSamplesReceived": 70, |
| 67 | "insertedSamplesForDeceleration": 3, "removedSamplesForAcceleration": 4, |
| 68 | "jitterBufferDelayMs": 120, |
| 69 | ]) |
| 70 | XCTAssertEqual(health.concealed, 7) |
| 71 | XCTAssertEqual(health.total, 70) |
| 72 | XCTAssertEqual(health.inserted, 3) |
| 73 | XCTAssertEqual(health.removed, 4) |
| 74 | XCTAssertEqual(health.jitterBufferMs, 120) |
| 75 | for field in MediaEngine.audioHealthFields { |
| 76 | XCTAssertTrue( |
| 77 | ["concealedSamples", "totalSamplesReceived", "insertedSamplesForDeceleration", |
| 78 | "removedSamplesForAcceleration"].contains(field), |
| 79 | "MediaEngine gathers \(field), which AudioHealth does not read") |
| 80 | } |
| 81 | } |
| 82 | |
| 83 | func testAMissingFieldIsZeroAndNotACrash() { |
| 84 | // An older or newer libwebrtc that stops reporting one of these |
| 85 | // should cost the readout a number, not the call a panel. |
| 86 | let health = AudioHealth(["totalSamplesReceived": 1_000]) |
| 87 | XCTAssertEqual(health.concealed, 0) |
| 88 | XCTAssertFalse(health.silent) |
| 89 | XCTAssertTrue(health.summary.contains("cleanly"), health.summary) |
| 90 | } |
| 91 | } |
| 92 | |
| 93 | /// AudioRoutes on macOS reads and chooses devices on the call's own |
| 94 | /// WebRTC audio device module. These run against the real machine and a |
| 95 | /// real module (no recording is started), because the thing worth testing |
| 96 | /// is that the module's lists come through named and choosable. |
| 97 | final class AudioRoutesTests: XCTestCase { |
| 98 | @MainActor func testMacListsTheModuleDevicesAndCanChoose() throws { |
| 99 | #if os(macOS) |
| 100 | let suite = "calls-audio-module-\(UUID().uuidString)" |
| 101 | let store = UserDefaults(suiteName: suite)! |
| 102 | defer { store.removePersistentDomain(forName: suite) } |
| 103 | let routes = AudioRoutes(defaults: store) |
| 104 | XCTAssertTrue(routes.inputs.isEmpty, "no module yet, so nothing to list") |
| 105 | XCTAssertThrowsError(try routes.use(input: "anything")) |
| 106 | |
| 107 | let audio = CallAudio(routes: routes, audioDevice: nil) |
| 108 | XCTAssertTrue(routes.selectable) |
| 109 | // The regression behind Paul's "not picking up the Scarlett at |
| 110 | // launch": the engine module lists only its "default" entry until a |
| 111 | // connection initialises it. The pickers come from CoreAudio, which |
| 112 | // is complete now. |
| 113 | XCTAssertEqual( |
| 114 | audio.factory.audioDeviceModule.inputDevices.map(\.deviceId), ["default"], |
| 115 | "LiveKit's module has not enumerated before the first connection") |
| 116 | XCTAssertEqual( |
| 117 | routes.inputs.count, CoreAudioDevices.inventory().inputs.count + 1, |
| 118 | "every CoreAudio input, and System default, before any connection") |
| 119 | // What this machine lists, for the record: every input device macOS |
| 120 | // has, named as macOS names it, the default among them. |
| 121 | for device in routes.inputs { print("audio input: \(device.name) [\(device.id)]") } |
| 122 | for device in routes.outputs { print("audio output: \(device.name) [\(device.id)]") } |
| 123 | // A machine with no input at all is possible, so this asserts the |
| 124 | // shape rather than the presence. |
| 125 | for device in routes.inputs + routes.outputs { |
| 126 | XCTAssertFalse(device.name.isEmpty, "a listed device must have a name") |
| 127 | XCTAssertTrue(device.selectable) |
| 128 | } |
| 129 | if let current = routes.currentInput, !routes.inputs.isEmpty { |
| 130 | XCTAssertTrue(routes.inputs.contains { $0.id == current }, "the current input is one listed") |
| 131 | } |
| 132 | XCTAssertThrowsError(try routes.use(input: "no-such-device")) { error in |
| 133 | XCTAssertEqual((error as NSError).code, 5) |
| 134 | } |
| 135 | if let first = routes.inputs.first { |
| 136 | XCTAssertNoThrow(try routes.use(input: first.id)) |
| 137 | XCTAssertEqual(routes.currentInput, first.id) |
| 138 | } |
| 139 | // A device other than the default: the choice must hold. Choosing |
| 140 | // only records it; no engine is running, so nothing records. |
| 141 | if let other = routes.inputs.first(where: { !$0.name.hasPrefix("System default") }) { |
| 142 | XCTAssertNoThrow(try routes.use(input: other.id)) |
| 143 | XCTAssertEqual(routes.currentInput, other.id) |
| 144 | routes.reset() |
| 145 | XCTAssertEqual(routes.currentInput, other.id, "leaving keeps the choice for next time") |
| 146 | } |
| 147 | #else |
| 148 | throw XCTSkip("macOS only") |
| 149 | #endif |
| 150 | } |
| 151 | } |
| 152 | |
| 153 | /// The readout before the microphone has started. Paul's 0.3.1 screenshot, |
| 154 | /// alone in a call, read "The microphone runs at 16 kHz … Echo |
| 155 | /// cancellation: off. Noise suppression: off." Those are exactly what the |
| 156 | /// module says before any microphone opens: WebRTC opens it when the first |
| 157 | /// connection starts sending, and until then its processing sits at its |
| 158 | /// construction default of 16 kHz with nothing applied. |
| 159 | final class ProcessingReadoutTests: XCTestCase { |
| 160 | func testNotStartedSaysSoInsteadOfStaleValues() { |
| 161 | let report = ProcessingReport( |
| 162 | suppression: .unsupportedRate(16_000), echoCancellation: "off", noiseSuppression: "off", |
| 163 | gainControl: "off", refused: nil, started: false) |
| 164 | XCTAssertFalse(report.summary.contains("16 kHz")) |
| 165 | XCTAssertFalse(report.summary.contains("Echo cancellation: off")) |
| 166 | XCTAssertTrue(report.summary.hasPrefix("The microphone has not started yet.")) |
| 167 | } |
| 168 | |
| 169 | @MainActor func testAloneInACallTheMicrophoneHasNotStarted() throws { |
| 170 | #if os(macOS) |
| 171 | let suite = "calls-readout-\(UUID().uuidString)" |
| 172 | let store = UserDefaults(suiteName: suite)! |
| 173 | defer { store.removePersistentDomain(forName: suite) } |
| 174 | let audio = CallAudio(routes: AudioRoutes(defaults: store), audioDevice: nil) |
| 175 | let track = audio.makeTrack() |
| 176 | _ = track |
| 177 | let report = audio.report() |
| 178 | // The evidence: with a track made and nothing sending, the module is |
| 179 | // not recording, and the processing reports the 16 kHz default with |
| 180 | // echo cancellation off — the screenshot's numbers. |
| 181 | XCTAssertFalse(audio.capturing) |
| 182 | print("pre-start readout: suppression=\(audio.denoiser.state) ec=\(report.echoCancellation) ns=\(report.noiseSuppression) gain=\(report.gainControl)") |
| 183 | XCTAssertFalse(report.started) |
| 184 | XCTAssertTrue(report.summary.hasPrefix("The microphone has not started yet.")) |
| 185 | #else |
| 186 | throw XCTSkip("macOS only") |
| 187 | #endif |
| 188 | } |
| 189 | } |
| 190 | |