| 1 | import Foundation |
| 2 | import XCTest |
| 3 | |
| 4 | @testable import CallsKit |
| 5 | |
| 6 | /// Stands in for a model: records what it was handed and halves it, so a |
| 7 | /// test can see both that a frame reached it and that the result was |
| 8 | /// written back in place. |
| 9 | private final class FakeSuppressor: NoiseSuppressor { |
| 10 | let name = "Fake" |
| 11 | let frameLength = 480 |
| 12 | var calls = 0 |
| 13 | var seen: [Float] = [] |
| 14 | func process(_ frame: UnsafeMutablePointer<Float>) -> Float { |
| 15 | calls += 1 |
| 16 | if seen.isEmpty { seen = Array(UnsafeBufferPointer(start: frame, count: frameLength)) } |
| 17 | for i in 0..<frameLength { frame[i] *= 0.5 } |
| 18 | return 0 |
| 19 | } |
| 20 | } |
| 21 | |
| 22 | private final class Clock: @unchecked Sendable { |
| 23 | var now = 100.0 |
| 24 | } |
| 25 | |
| 26 | /// The frame adapter: what reaches the suppressor, when, and when nothing |
| 27 | /// does. |
| 28 | final class CaptureDenoiserTests: XCTestCase { |
| 29 | private func adapter() -> (CaptureDenoiser, FakeSuppressor, Clock) { |
| 30 | let fake = FakeSuppressor() |
| 31 | let clock = Clock() |
| 32 | return (CaptureDenoiser(suppressor: fake, clock: { clock.now }), fake, clock) |
| 33 | } |
| 34 | |
| 35 | private func run(_ denoiser: CaptureDenoiser, count: Int, value: Float = 1000) -> [Float] { |
| 36 | var samples = [Float](repeating: value, count: count) |
| 37 | samples.withUnsafeMutableBufferPointer { denoiser.process($0.baseAddress!, frames: count) } |
| 38 | return samples |
| 39 | } |
| 40 | |
| 41 | func testTenMillisecondsAt48kIsOneFrameAndIsProcessedInPlace() { |
| 42 | let (denoiser, fake, _) = adapter() |
| 43 | denoiser.audioProcessingInitialize(sampleRate: 48_000, channels: 1) |
| 44 | let out = run(denoiser, count: 480) |
| 45 | XCTAssertEqual(fake.calls, 1) |
| 46 | XCTAssertEqual(out, [Float](repeating: 500, count: 480), "the result is written back in place") |
| 47 | XCTAssertEqual(denoiser.framesProcessed, 1) |
| 48 | } |
| 49 | |
| 50 | func testSamplesReachTheSuppressorInWebRTCScaleUnconverted() { |
| 51 | let (denoiser, fake, _) = adapter() |
| 52 | denoiser.audioProcessingInitialize(sampleRate: 48_000, channels: 1) |
| 53 | _ = run(denoiser, count: 480, value: 32_000) |
| 54 | XCTAssertEqual(fake.seen.first, 32_000, "the adapter does not rescale; each suppressor owns its scale") |
| 55 | } |
| 56 | |
| 57 | func testAWholeNumberOfFramesIsSplitIntoFrames() { |
| 58 | let (denoiser, fake, _) = adapter() |
| 59 | denoiser.audioProcessingInitialize(sampleRate: 48_000, channels: 1) |
| 60 | _ = run(denoiser, count: 960) |
| 61 | XCTAssertEqual(fake.calls, 2) |
| 62 | } |
| 63 | |
| 64 | func testAPartFrameIsPassedThroughUntouched() { |
| 65 | let (denoiser, fake, _) = adapter() |
| 66 | denoiser.audioProcessingInitialize(sampleRate: 48_000, channels: 1) |
| 67 | let out = run(denoiser, count: 441) |
| 68 | XCTAssertEqual(fake.calls, 0) |
| 69 | XCTAssertEqual(out, [Float](repeating: 1000, count: 441)) |
| 70 | } |
| 71 | |
| 72 | func testOtherRatesPassThroughAndSaySo() { |
| 73 | for rate in [8_000, 16_000, 32_000, 44_100] { |
| 74 | let (denoiser, fake, _) = adapter() |
| 75 | denoiser.audioProcessingInitialize(sampleRate: rate, channels: 1) |
| 76 | let out = run(denoiser, count: rate / 100) |
| 77 | XCTAssertEqual(fake.calls, 0, "\(rate) Hz") |
| 78 | XCTAssertEqual(out, [Float](repeating: 1000, count: rate / 100)) |
| 79 | XCTAssertEqual(denoiser.state, .unsupportedRate(rate)) |
| 80 | } |
| 81 | } |
| 82 | |
| 83 | func testMoreThanOneChannelPassesThrough() { |
| 84 | let (denoiser, fake, _) = adapter() |
| 85 | denoiser.audioProcessingInitialize(sampleRate: 48_000, channels: 2) |
| 86 | _ = run(denoiser, count: 480) |
| 87 | XCTAssertEqual(fake.calls, 0) |
| 88 | } |
| 89 | |
| 90 | func testNothingIsProcessedBeforeInitialiseOrAfterRelease() { |
| 91 | let (denoiser, fake, _) = adapter() |
| 92 | _ = run(denoiser, count: 480) |
| 93 | XCTAssertEqual(fake.calls, 0, "before initialise") |
| 94 | denoiser.audioProcessingInitialize(sampleRate: 48_000, channels: 1) |
| 95 | _ = run(denoiser, count: 480) |
| 96 | denoiser.audioProcessingRelease() |
| 97 | _ = run(denoiser, count: 480) |
| 98 | XCTAssertEqual(fake.calls, 1, "after release") |
| 99 | // Released is not freed: WebRTC reinitialises whenever processing is |
| 100 | // reconfigured, and the same suppressor carries on. |
| 101 | denoiser.audioProcessingInitialize(sampleRate: 48_000, channels: 1) |
| 102 | _ = run(denoiser, count: 480) |
| 103 | XCTAssertEqual(fake.calls, 2) |
| 104 | } |
| 105 | |
| 106 | /// Every initialisation is reported, a repeat included: WebRTC's first |
| 107 | /// one is its 16 kHz construction default, before any microphone, and a |
| 108 | /// headset really capturing at 16 kHz repeats it. Reporting only changes |
| 109 | /// hid that second one, and the plan never put WebRTC's own suppressor |
| 110 | /// back on (Calls 0.3.1's "16 kHz … Noise suppression: off"). |
| 111 | func testEveryInitialisationIsReported() { |
| 112 | let (denoiser, _, _) = adapter() |
| 113 | let seen = LockedRates() |
| 114 | denoiser.rateChanged = { seen.append($0) } |
| 115 | denoiser.audioProcessingInitialize(sampleRate: 16_000, channels: 1) |
| 116 | denoiser.audioProcessingInitialize(sampleRate: 16_000, channels: 1) |
| 117 | denoiser.audioProcessingInitialize(sampleRate: 48_000, channels: 1) |
| 118 | XCTAssertEqual(seen.rates, [16_000, 16_000, 48_000]) |
| 119 | } |
| 120 | |
| 121 | func testTheToggleAndVoiceIsolationBothPassThrough() { |
| 122 | let (denoiser, fake, _) = adapter() |
| 123 | denoiser.audioProcessingInitialize(sampleRate: 48_000, channels: 1) |
| 124 | denoiser.enabled = false |
| 125 | XCTAssertEqual(run(denoiser, count: 480).first, 1000) |
| 126 | XCTAssertEqual(denoiser.state, .off) |
| 127 | denoiser.enabled = true |
| 128 | denoiser.standAside = true |
| 129 | XCTAssertEqual(run(denoiser, count: 480).first, 1000) |
| 130 | XCTAssertEqual(denoiser.state, .standingAside) |
| 131 | XCTAssertEqual(fake.calls, 0) |
| 132 | } |
| 133 | |
| 134 | /// A call starts on RNNoise and moves to DeepFilterNet once its model |
| 135 | /// has loaded; the next buffer goes to the new model. |
| 136 | func testTheModelCanBeReplacedBetweenBuffers() { |
| 137 | let (denoiser, first, _) = adapter() |
| 138 | denoiser.audioProcessingInitialize(sampleRate: 48_000, channels: 1) |
| 139 | _ = run(denoiser, count: 480) |
| 140 | let second = FakeSuppressor() |
| 141 | denoiser.replace(with: second) |
| 142 | _ = run(denoiser, count: 960) |
| 143 | XCTAssertEqual(first.calls, 1) |
| 144 | XCTAssertEqual(second.calls, 2) |
| 145 | XCTAssertEqual(denoiser.framesProcessed, 3) |
| 146 | } |
| 147 | |
| 148 | /// WebRTC stops calling the hook while the microphone is muted. The |
| 149 | /// readout must then stop claiming the suppressor is running. |
| 150 | func testMutedReadsAsIdleOnceFramesStop() { |
| 151 | let (denoiser, _, clock) = adapter() |
| 152 | denoiser.audioProcessingInitialize(sampleRate: 48_000, channels: 1) |
| 153 | XCTAssertEqual(denoiser.state, .idle(engine: "Fake")) |
| 154 | _ = run(denoiser, count: 480) |
| 155 | guard case .running(let engine, _) = denoiser.state else { |
| 156 | return XCTFail("expected running, got \(denoiser.state)") |
| 157 | } |
| 158 | XCTAssertEqual(engine, "Fake") |
| 159 | clock.now += 2 |
| 160 | XCTAssertEqual(denoiser.state, .idle(engine: "Fake")) |
| 161 | } |
| 162 | } |
| 163 | |
| 164 | private final class LockedRates: @unchecked Sendable { |
| 165 | private let lock = NSLock() |
| 166 | private var values: [Int] = [] |
| 167 | func append(_ rate: Int) { lock.withLock { values.append(rate) } } |
| 168 | var rates: [Int] { lock.withLock { values } } |
| 169 | } |
| 170 | |
| 171 | final class SpeechGainTests: XCTestCase { |
| 172 | private func frame(_ amplitude: Float) -> [Float] { |
| 173 | (0..<480).map { amplitude * sin(Float($0) * 2 * .pi * 200 / 48_000) } |
| 174 | } |
| 175 | |
| 176 | func testQuietSpeechIsLiftedSlowlyAndNoMoreThanTwelveDecibels() { |
| 177 | var gain = SpeechGain() |
| 178 | for _ in 0..<5_000 { |
| 179 | var f = frame(100) // about -53 dBFS |
| 180 | f.withUnsafeMutableBufferPointer { gain.apply($0.baseAddress!, count: 480, speech: 1) } |
| 181 | } |
| 182 | XCTAssertEqual(gain.gainDb, SpeechGain.maxGainDb, accuracy: 0.01) |
| 183 | var one = SpeechGain() |
| 184 | var f = frame(100) |
| 185 | f.withUnsafeMutableBufferPointer { one.apply($0.baseAddress!, count: 480, speech: 1) } |
| 186 | XCTAssertLessThanOrEqual(one.gainDb, 0.011, "one frame moves it by at most 0.01 dB") |
| 187 | } |
| 188 | |
| 189 | func testNoiseIsNeverLiftedAndNothingIsAttenuated() { |
| 190 | var gain = SpeechGain() |
| 191 | for _ in 0..<1_000 { |
| 192 | var f = frame(100) |
| 193 | f.withUnsafeMutableBufferPointer { gain.apply($0.baseAddress!, count: 480, speech: 0.1) } |
| 194 | } |
| 195 | XCTAssertEqual(gain.gainDb, 0) |
| 196 | var loud = SpeechGain() |
| 197 | var f = frame(30_000) |
| 198 | let before = f |
| 199 | f.withUnsafeMutableBufferPointer { loud.apply($0.baseAddress!, count: 480, speech: 1) } |
| 200 | XCTAssertEqual(f, before, "a loud voice is left alone") |
| 201 | } |
| 202 | |
| 203 | func testTheLiftNeverClips() { |
| 204 | var gain = SpeechGain() |
| 205 | for _ in 0..<5_000 { |
| 206 | var f = frame(100) |
| 207 | f.withUnsafeMutableBufferPointer { gain.apply($0.baseAddress!, count: 480, speech: 1) } |
| 208 | } |
| 209 | var burst = frame(29_000) |
| 210 | burst.withUnsafeMutableBufferPointer { gain.apply($0.baseAddress!, count: 480, speech: 1) } |
| 211 | XCTAssertLessThanOrEqual(burst.map(abs).max()!, 0.9 * 32_768 + 1) |
| 212 | } |
| 213 | } |
| 214 | |
| 215 | final class ProcessingPlanTests: XCTestCase { |
| 216 | func testMacRunsOursInPlaceOfWebRTCsSuppressorAndGain() { |
| 217 | let plan = ProcessingPlan.plan(iPhone: false, reduceNoise: true, rate: 48_000, voiceIsolation: false) |
| 218 | XCTAssertTrue(plan.suppressor) |
| 219 | XCTAssertEqual(plan.noiseSuppression, .off) |
| 220 | XCTAssertEqual(plan.gainControl, .off, "WebRTC's gain runs before ours and would pump the noise") |
| 221 | XCTAssertEqual(plan.echoCancellation, .software) |
| 222 | XCTAssertTrue(plan.highPass) |
| 223 | } |
| 224 | |
| 225 | func testAnUnsupportedRateHandsBackToWebRTCsSuppressor() { |
| 226 | let plan = ProcessingPlan.plan(iPhone: false, reduceNoise: true, rate: 16_000, voiceIsolation: false) |
| 227 | XCTAssertFalse(plan.suppressor) |
| 228 | XCTAssertEqual(plan.noiseSuppression, .software) |
| 229 | XCTAssertEqual(plan.gainControl, .software) |
| 230 | } |
| 231 | |
| 232 | func testOffMeansNoSuppressionButWebRTCsGain() { |
| 233 | let plan = ProcessingPlan.plan(iPhone: false, reduceNoise: false, rate: 48_000, voiceIsolation: false) |
| 234 | XCTAssertFalse(plan.suppressor) |
| 235 | XCTAssertEqual(plan.noiseSuppression, .off) |
| 236 | XCTAssertEqual(plan.gainControl, .software) |
| 237 | } |
| 238 | |
| 239 | func testVoiceIsolationIsNeverStackedWith() { |
| 240 | for iPhone in [false, true] { |
| 241 | let plan = ProcessingPlan.plan(iPhone: iPhone, reduceNoise: true, rate: 48_000, voiceIsolation: true) |
| 242 | XCTAssertFalse(plan.suppressor) |
| 243 | } |
| 244 | } |
| 245 | |
| 246 | func testIPhoneKeepsApplesCoupledEchoCancellerAndDropsItsGainForOurs() { |
| 247 | let plan = ProcessingPlan.plan(iPhone: true, reduceNoise: true, rate: 48_000, voiceIsolation: false) |
| 248 | XCTAssertTrue(plan.suppressor) |
| 249 | XCTAssertEqual(plan.echoCancellation, .platform) |
| 250 | XCTAssertEqual(plan.noiseSuppression, .platform) |
| 251 | XCTAssertEqual(plan.gainControl, .off) |
| 252 | } |
| 253 | |
| 254 | /// The rule, across every combination: ours never runs beside WebRTC's |
| 255 | /// software suppressor, and never behind WebRTC's gain control. |
| 256 | func testOursNeverStacksWithWebRTCsSuppressorOrFollowsItsGain() { |
| 257 | for iPhone in [false, true] { |
| 258 | for reduce in [false, true] { |
| 259 | for rate in [nil, 8_000, 16_000, 48_000] as [Int?] { |
| 260 | for isolation in [false, true] { |
| 261 | let plan = ProcessingPlan.plan( |
| 262 | iPhone: iPhone, reduceNoise: reduce, rate: rate, voiceIsolation: isolation) |
| 263 | if plan.suppressor { |
| 264 | XCTAssertNotEqual(plan.noiseSuppression, .software) |
| 265 | XCTAssertEqual(plan.gainControl, .off) |
| 266 | } |
| 267 | } |
| 268 | } |
| 269 | } |
| 270 | } |
| 271 | } |
| 272 | } |
| 273 | |
| 274 | /// Real speech (macOS's own synthesiser, normalised to -23 dBFS) with |
| 275 | /// noise mixed in, through each real suppressor. |
| 276 | /// |
| 277 | /// Two claims, measured separately because a suppressor can satisfy |
| 278 | /// either by failing the other: the noise-only lead-in is made much |
| 279 | /// quieter, and the speech is not. |
| 280 | final class SuppressorFixtureTests: XCTestCase { |
| 281 | static let rate = 48_000 |
| 282 | |
| 283 | static func speech() throws -> [Float] { |
| 284 | let url = try XCTUnwrap(Bundle.module.url(forResource: "Fixtures/speech-48k", withExtension: "wav")) |
| 285 | let data = try Data(contentsOf: url) |
| 286 | // A canonical 44-byte header, mono 16-bit at 48 kHz, as written. |
| 287 | XCTAssertEqual(data.count > 44, true) |
| 288 | return data.dropFirst(44).withUnsafeBytes { raw in |
| 289 | raw.bindMemory(to: Int16.self).map { Float(Int16(littleEndian: $0)) } |
| 290 | } |
| 291 | } |
| 292 | |
| 293 | /// Broadband hiss (a fan, a room) at about -35 dBFS, plus a key click |
| 294 | /// every 180 ms. Seeded, so every run hears the same room. |
| 295 | static func noise(count: Int) -> [Float] { |
| 296 | var seed: UInt32 = 0x2545_F491 |
| 297 | var low: Float = 0 |
| 298 | var out = [Float](repeating: 0, count: count) |
| 299 | for i in 0..<count { |
| 300 | seed = seed &* 1_664_525 &+ 1_013_904_223 |
| 301 | let white = Float(Int32(bitPattern: seed)) / Float(Int32.max) |
| 302 | low = 0.7 * low + 0.3 * white |
| 303 | var sample = low * 900 |
| 304 | let sinceClick = i % (rate * 18 / 100) |
| 305 | if sinceClick < 240 { |
| 306 | sample += 6_000 * white * exp(-Float(sinceClick) / 40) |
| 307 | } |
| 308 | out[i] = sample |
| 309 | } |
| 310 | return out |
| 311 | } |
| 312 | |
| 313 | static func db(_ samples: ArraySlice<Float>) -> Double { |
| 314 | let energy = samples.reduce(0.0) { $0 + Double($1) * Double($1) } / Double(max(samples.count, 1)) |
| 315 | return 10 * log10(energy / (32_768.0 * 32_768.0) + 1e-12) |
| 316 | } |
| 317 | |
| 318 | struct Result { |
| 319 | let noiseBefore: Double |
| 320 | let noiseAfter: Double |
| 321 | let speechClean: Double |
| 322 | let speechAfter: Double |
| 323 | let voicedKept: Double |
| 324 | let msPerFrame: Double |
| 325 | } |
| 326 | |
| 327 | static func measure(_ suppressor: NoiseSuppressor) throws -> Result { |
| 328 | let clean = try speech() |
| 329 | let noise = noise(count: clean.count) |
| 330 | var noisy = zip(clean, noise).map { $0 + $1 } |
| 331 | let length = suppressor.frameLength |
| 332 | let frames = noisy.count / length |
| 333 | let started = ProcessInfo.processInfo.systemUptime |
| 334 | noisy.withUnsafeMutableBufferPointer { buffer in |
| 335 | for f in 0..<frames { _ = suppressor.process(buffer.baseAddress! + f * length) } |
| 336 | } |
| 337 | let elapsed = ProcessInfo.processInfo.systemUptime - started |
| 338 | let out = noisy |
| 339 | // The lead-in is noise alone; skip the first 300 ms while the model |
| 340 | // settles. |
| 341 | let lead = (rate * 3 / 10)..<rate |
| 342 | let speechRange = rate..<(frames * length - rate / 2) |
| 343 | // Per-frame: of the frames where the clean voice is clearly present, |
| 344 | // how many came out within 10 dB of it. A model may delay its output |
| 345 | // (DeepFilterNet by three frames), so the best of 0 to 4 frames late |
| 346 | // is taken. |
| 347 | var voiced = 0 |
| 348 | var best = 0 |
| 349 | for lag in 0...4 { |
| 350 | var count = 0 |
| 351 | var kept = 0 |
| 352 | for f in stride(from: speechRange.lowerBound, to: speechRange.upperBound - 5 * length, by: length) { |
| 353 | let reference = db(clean[f..<(f + length)]) |
| 354 | guard reference > -40 else { continue } |
| 355 | count += 1 |
| 356 | let shifted = f + lag * length |
| 357 | if db(out[shifted..<(shifted + length)]) > reference - 10 { kept += 1 } |
| 358 | } |
| 359 | voiced = count |
| 360 | best = max(best, kept) |
| 361 | } |
| 362 | let kept = best |
| 363 | return Result( |
| 364 | noiseBefore: db(noise[lead]), noiseAfter: db(out[lead]), |
| 365 | speechClean: db(clean[speechRange]), speechAfter: db(out[speechRange]), |
| 366 | voicedKept: Double(kept) / Double(max(voiced, 1)), |
| 367 | msPerFrame: elapsed * 1000 / Double(frames)) |
| 368 | } |
| 369 | |
| 370 | static func check(_ result: Result, name: String, minimumReduction: Double) { |
| 371 | print( |
| 372 | "\(name): noise \(String(format: "%.1f", result.noiseBefore)) -> \(String(format: "%.1f", result.noiseAfter)) dBFS;" |
| 373 | + " speech \(String(format: "%.1f", result.speechClean)) -> \(String(format: "%.1f", result.speechAfter)) dBFS;" |
| 374 | + " voiced frames kept \(Int(result.voicedKept * 100))%;" |
| 375 | + " \(String(format: "%.3f", result.msPerFrame)) ms per 10 ms frame") |
| 376 | XCTAssertLessThan( |
| 377 | result.noiseAfter, result.noiseBefore - minimumReduction, |
| 378 | "\(name) should take at least \(minimumReduction) dB off noise alone") |
| 379 | XCTAssertGreaterThan( |
| 380 | result.speechAfter, result.speechClean - 6, "\(name) must not take the speech with it") |
| 381 | XCTAssertGreaterThan(result.voicedKept, 0.8, "\(name) must keep the voiced frames") |
| 382 | } |
| 383 | |
| 384 | func testRNNoiseQuietsNoiseAndKeepsSpeech() throws { |
| 385 | let result = try Self.measure(RNNoiseSuppressor()) |
| 386 | Self.check(result, name: "RNNoise", minimumReduction: 10) |
| 387 | XCTAssertGreaterThan(result.noiseAfter, result.noiseBefore - 40, "the 30 dB limit holds") |
| 388 | } |
| 389 | |
| 390 | #if canImport(DeepFilter) |
| 391 | /// The model is a build product (scripts/build-deepfilter.sh), found |
| 392 | /// beside the xcframework. |
| 393 | static var deepFilterModel: URL { |
| 394 | URL(fileURLWithPath: #filePath) |
| 395 | .deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent() |
| 396 | .deletingLastPathComponent().appendingPathComponent("build/DeepFilterNet3_onnx.tar.gz") |
| 397 | } |
| 398 | |
| 399 | func testDeepFilterQuietsNoiseAndKeepsSpeech() throws { |
| 400 | let started = ProcessInfo.processInfo.systemUptime |
| 401 | let deep = try XCTUnwrap(DeepFilterSuppressor(model: Self.deepFilterModel), "run build-deepfilter.sh") |
| 402 | print("DeepFilterNet 3 model loaded in \(Int((ProcessInfo.processInfo.systemUptime - started) * 1000)) ms") |
| 403 | XCTAssertEqual(deep.frameLength, 480) |
| 404 | let result = try Self.measure(deep) |
| 405 | Self.check(result, name: "DeepFilterNet 3", minimumReduction: 10) |
| 406 | XCTAssertGreaterThan(result.noiseAfter, result.noiseBefore - 40, "the 30 dB limit holds") |
| 407 | } |
| 408 | |
| 409 | /// libDF wants ±1; WebRTC hands over ±32768. A full-scale voice must |
| 410 | /// come back at full scale, not 32768 times louder or quieter. |
| 411 | func testDeepFilterScalesInAndOut() throws { |
| 412 | let deep = try XCTUnwrap(DeepFilterSuppressor(model: Self.deepFilterModel)) |
| 413 | let clean = try Self.speech() |
| 414 | var loudest: Float = 0 |
| 415 | var inputLoudest: Float = 0 |
| 416 | var samples = Array(clean.prefix(480 * 300)) |
| 417 | inputLoudest = samples.map(abs).max()! |
| 418 | samples.withUnsafeMutableBufferPointer { buffer in |
| 419 | for f in 0..<300 { _ = deep.process(buffer.baseAddress! + f * 480) } |
| 420 | } |
| 421 | loudest = samples.map(abs).max()! |
| 422 | XCTAssertGreaterThan(loudest, inputLoudest / 4, "output is in WebRTC's scale") |
| 423 | XCTAssertLessThan(loudest, inputLoudest * 4, "output is in WebRTC's scale") |
| 424 | } |
| 425 | |
| 426 | func testAMissingModelIsNoSuppressorRatherThanACrash() { |
| 427 | XCTAssertNil(DeepFilterSuppressor(model: URL(fileURLWithPath: "/nonexistent/model.tar.gz"))) |
| 428 | } |
| 429 | #endif |
| 430 | } |
| 431 | |
| 432 | /// The adapter in every case it does not process: the audio must leave it |
| 433 | /// bit for bit as it came, and the meters and the sink must still see it. |
| 434 | /// A meter blind to a passthrough is how a live microphone on a |
| 435 | /// multichannel interface, or at 44.1 kHz, would read as dead in the audio |
| 436 | /// test. |
| 437 | final class CaptureAdapterPassthroughTests: XCTestCase { |
| 438 | /// A 440 Hz tone at about -12 dBFS, in WebRTC's ±32768 scale. |
| 439 | private static func tone(_ frames: Int, rate: Int) -> [Float] { |
| 440 | (0..<frames).map { 8_000 * sin(Float($0) * 2 * .pi * 440 / Float(rate)) } |
| 441 | } |
| 442 | |
| 443 | private final class Tap: @unchecked Sendable { |
| 444 | private let lock = NSLock() |
| 445 | private var energy: Float = 0 |
| 446 | private var calls = 0 |
| 447 | private var rates: [Int] = [] |
| 448 | func add(_ samples: UnsafePointer<Float>, _ frames: Int, _ rate: Int) { |
| 449 | lock.withLock { |
| 450 | calls += 1 |
| 451 | rates.append(rate) |
| 452 | for i in 0..<frames { energy += samples[i] * samples[i] } |
| 453 | } |
| 454 | } |
| 455 | var seen: (calls: Int, energy: Float, rates: [Int]) { lock.withLock { (calls, energy, rates) } } |
| 456 | } |
| 457 | |
| 458 | /// Drives one buffer as `audioProcessingProcess` does (input meter, |
| 459 | /// process, observe), and checks the passthrough and the meters. |
| 460 | private func check( |
| 461 | rate: Int, channels: Int, frames: Int, enabled: Bool = true, |
| 462 | file: StaticString = #filePath, line: UInt = #line |
| 463 | ) -> CaptureDenoiser { |
| 464 | let fake = FakeSuppressor() |
| 465 | let denoiser = CaptureDenoiser(suppressor: fake, clock: { 1 }) |
| 466 | denoiser.enabled = enabled |
| 467 | denoiser.audioProcessingInitialize(sampleRate: rate, channels: channels) |
| 468 | let tap = Tap() |
| 469 | denoiser.sink = { tap.add($0, $1, $2) } |
| 470 | |
| 471 | for (name, source) in [("tone", Self.tone(frames, rate: rate)), ("silence", [Float](repeating: 0, count: frames))] { |
| 472 | var samples = source |
| 473 | samples.withUnsafeMutableBufferPointer { b in |
| 474 | denoiser.inputLevel.record(b.baseAddress!, frames: frames, at: 1) |
| 475 | denoiser.process(b.baseAddress!, frames: frames) |
| 476 | denoiser.observe(b.baseAddress!, frames: frames) |
| 477 | } |
| 478 | XCTAssertEqual( |
| 479 | samples.map(\.bitPattern), source.map(\.bitPattern), "\(name) passes through bit for bit", |
| 480 | file: file, line: line) |
| 481 | let after = denoiser.level.take() |
| 482 | let before = denoiser.inputLevel.take() |
| 483 | XCTAssertEqual(after.buffers, 1, "\(name) is metered after the hook", file: file, line: line) |
| 484 | XCTAssertEqual(before.buffers, 1, "\(name) is metered before the hook", file: file, line: line) |
| 485 | if name == "tone" { |
| 486 | XCTAssertGreaterThan(after.rms, 0.1, "the tone reads as a tone", file: file, line: line) |
| 487 | XCTAssertEqual(after.rms, before.rms, "nothing was done to it", file: file, line: line) |
| 488 | } else { |
| 489 | XCTAssertEqual(after.rms, 0, "silence reads as silence", file: file, line: line) |
| 490 | XCTAssertEqual(after.peak, 0, file: file, line: line) |
| 491 | } |
| 492 | } |
| 493 | XCTAssertEqual(fake.calls, 0, "the suppressor is never handed a passthrough", file: file, line: line) |
| 494 | let seen = tap.seen |
| 495 | XCTAssertEqual(seen.calls, 2, "the sink sees both buffers", file: file, line: line) |
| 496 | XCTAssertGreaterThan(seen.energy, 0, "and hears the tone", file: file, line: line) |
| 497 | XCTAssertEqual(Set(seen.rates), [rate], "at the rate WebRTC reported", file: file, line: line) |
| 498 | return denoiser |
| 499 | } |
| 500 | |
| 501 | func testTwoEightAndEighteenChannelsPassThroughAndAreMetered() { |
| 502 | for channels in [2, 8, 18] { |
| 503 | _ = check(rate: 48_000, channels: channels, frames: 480) |
| 504 | } |
| 505 | } |
| 506 | |
| 507 | func testFortyFourOneAndNinetySixKilohertzPassThroughAndSaySo() { |
| 508 | for rate in [44_100, 96_000] { |
| 509 | let denoiser = check(rate: rate, channels: 1, frames: rate / 100) |
| 510 | XCTAssertEqual(denoiser.state, .unsupportedRate(rate)) |
| 511 | } |
| 512 | } |
| 513 | |
| 514 | func testTheToggleOffPassesThroughAndIsMetered() { |
| 515 | let denoiser = check(rate: 48_000, channels: 1, frames: 480, enabled: false) |
| 516 | XCTAssertEqual(denoiser.state, .off) |
| 517 | } |
| 518 | |
| 519 | func testBuffersThatAreNotWholeFramesPassThroughAndAreMetered() { |
| 520 | for frames in [441, 479, 500, 1_000] { |
| 521 | _ = check(rate: 48_000, channels: 1, frames: frames) |
| 522 | } |
| 523 | } |
| 524 | |
| 525 | /// And where it does process, the meter after the hook reads what the |
| 526 | /// suppressor made of it (the fake halves it: 6 dB down). |
| 527 | func testTheMeterAfterTheHookReadsTheProcessedAudio() { |
| 528 | let denoiser = CaptureDenoiser(suppressor: FakeSuppressor(), clock: { 1 }) |
| 529 | denoiser.audioProcessingInitialize(sampleRate: 48_000, channels: 1) |
| 530 | var samples = Self.tone(480, rate: 48_000) |
| 531 | samples.withUnsafeMutableBufferPointer { b in |
| 532 | denoiser.inputLevel.record(b.baseAddress!, frames: 480, at: 1) |
| 533 | denoiser.process(b.baseAddress!, frames: 480) |
| 534 | denoiser.observe(b.baseAddress!, frames: 480) |
| 535 | } |
| 536 | let before = denoiser.inputLevel.take().rms |
| 537 | let after = denoiser.level.take().rms |
| 538 | XCTAssertEqual(after / before, 0.5, accuracy: 0.001) |
| 539 | } |
| 540 | |
| 541 | /// Real speech on a stereo capture: untouched, and at speech level on the |
| 542 | /// meter the audio test judges "working" by. |
| 543 | func testSpeechOnAStereoCaptureIsUntouchedAndReadsAsSpeech() throws { |
| 544 | let speech = try SuppressorFixtureTests.speech() |
| 545 | let denoiser = CaptureDenoiser(suppressor: FakeSuppressor(), clock: { 1 }) |
| 546 | denoiser.audioProcessingInitialize(sampleRate: 48_000, channels: 2) |
| 547 | var loudest: Float = 0 |
| 548 | for start in stride(from: 0, to: speech.count - 480, by: 480) { |
| 549 | var samples = Array(speech[start..<(start + 480)]) |
| 550 | let source = samples |
| 551 | samples.withUnsafeMutableBufferPointer { b in |
| 552 | denoiser.process(b.baseAddress!, frames: 480) |
| 553 | denoiser.observe(b.baseAddress!, frames: 480) |
| 554 | } |
| 555 | XCTAssertEqual(samples, source) |
| 556 | loudest = max(loudest, denoiser.level.take().rms) |
| 557 | } |
| 558 | XCTAssertGreaterThan(loudest, MicJudge.speechRMS) |
| 559 | } |
| 560 | } |
| 561 | |
| 562 | /// A Bluetooth headset used as a microphone runs its hands-free rate: 16 |
| 563 | /// kHz, 24 kHz on AirPods (8 kHz on old ones). The suppressor passes those |
| 564 | /// through, and the meters the audio test and the call read, and the |
| 565 | /// recording's sink, must see them anyway: a meter blind to them would |
| 566 | /// show every Bluetooth microphone as dead (Paul's AirPods, 2026-09-26). |
| 567 | /// Driven through `handle`, the hook's own body. |
| 568 | final class BluetoothRateCaptureTests: XCTestCase { |
| 569 | private final class Sink: @unchecked Sendable { |
| 570 | private let lock = NSLock() |
| 571 | private var got: [(frames: Int, rate: Int, peak: Float)] = [] |
| 572 | func add(_ samples: UnsafePointer<Float>, _ frames: Int, _ rate: Int) { |
| 573 | var peak: Float = 0 |
| 574 | for i in 0..<frames { peak = max(peak, abs(samples[i])) } |
| 575 | lock.withLock { got.append((frames, rate, peak)) } |
| 576 | } |
| 577 | var seen: [(frames: Int, rate: Int, peak: Float)] { lock.withLock { got } } |
| 578 | } |
| 579 | |
| 580 | private final class Reported: @unchecked Sendable { |
| 581 | private let lock = NSLock() |
| 582 | private var got: [Int] = [] |
| 583 | func add(_ rate: Int) { lock.withLock { got.append(rate) } } |
| 584 | var rates: [Int] { lock.withLock { got } } |
| 585 | } |
| 586 | |
| 587 | private func tone(_ frames: Int, rate: Int, amplitude: Float = 8_000) -> [Float] { |
| 588 | (0..<frames).map { amplitude * sin(Float($0) * 2 * .pi * 300 / Float(rate)) } |
| 589 | } |
| 590 | |
| 591 | private func feed(_ denoiser: CaptureDenoiser, _ samples: [Float]) -> [Float] { |
| 592 | var copy = samples |
| 593 | copy.withUnsafeMutableBufferPointer { denoiser.handle($0.baseAddress!, frames: $0.count, channels: 1) } |
| 594 | return copy |
| 595 | } |
| 596 | |
| 597 | func testHandsFreeRatesAreMeteredAndRecordedThoughNotSuppressed() { |
| 598 | for rate in [8_000, 16_000, 24_000, 32_000, 44_100] { |
| 599 | let fake = FakeSuppressor() |
| 600 | let denoiser = CaptureDenoiser(suppressor: fake, clock: { 1 }) |
| 601 | denoiser.audioProcessingInitialize(sampleRate: rate, channels: 1) |
| 602 | let sink = Sink() |
| 603 | denoiser.sink = { sink.add($0, $1, $2) } |
| 604 | let voice = tone(rate / 100, rate: rate) |
| 605 | XCTAssertEqual(feed(denoiser, voice), voice, "\(rate) Hz passes through untouched") |
| 606 | let before = denoiser.inputLevel.take() |
| 607 | let after = denoiser.level.take() |
| 608 | XCTAssertEqual(before.buffers, 1, "\(rate) Hz reaches the meter before the suppressor") |
| 609 | XCTAssertEqual(after.buffers, 1, "\(rate) Hz reaches the meter after it") |
| 610 | XCTAssertGreaterThan(after.peak, 0.2, "\(rate) Hz reads as sound, not silence") |
| 611 | XCTAssertEqual(sink.seen.map(\.rate), [rate], "the recording is told the rate") |
| 612 | XCTAssertEqual(fake.calls, 0) |
| 613 | XCTAssertEqual(denoiser.state, .unsupportedRate(rate)) |
| 614 | } |
| 615 | } |
| 616 | |
| 617 | /// HFP engages when the microphone opens, so WebRTC reinitialises the |
| 618 | /// hook mid-stream: 48 kHz (A2DP, before) to 24 kHz and back. The |
| 619 | /// meters never stop, the sink follows the rate, and the suppressor |
| 620 | /// runs only while the rate is 48 kHz. |
| 621 | func testAMidStreamRateChangeKeepsTheMetersAndFollowsTheRate() { |
| 622 | let fake = FakeSuppressor() |
| 623 | let denoiser = CaptureDenoiser(suppressor: fake, clock: { 1 }) |
| 624 | let sink = Sink() |
| 625 | denoiser.sink = { sink.add($0, $1, $2) } |
| 626 | let reported = Reported() |
| 627 | denoiser.rateChanged = { reported.add($0) } |
| 628 | |
| 629 | denoiser.audioProcessingInitialize(sampleRate: 48_000, channels: 1) |
| 630 | XCTAssertTrue(denoiser.countBuffer(), "the first buffer after an initialise is the one logged") |
| 631 | XCTAssertFalse(denoiser.countBuffer()) |
| 632 | _ = feed(denoiser, tone(480, rate: 48_000)) |
| 633 | XCTAssertEqual(fake.calls, 1, "48 kHz is suppressed") |
| 634 | |
| 635 | denoiser.audioProcessingRelease() |
| 636 | denoiser.audioProcessingInitialize(sampleRate: 24_000, channels: 1) |
| 637 | XCTAssertTrue(denoiser.countBuffer(), "a reinitialise starts the count again") |
| 638 | let voice = tone(240, rate: 24_000) |
| 639 | XCTAssertEqual(feed(denoiser, voice), voice) |
| 640 | XCTAssertEqual(fake.calls, 1, "24 kHz is not") |
| 641 | |
| 642 | denoiser.audioProcessingInitialize(sampleRate: 48_000, channels: 1) |
| 643 | _ = feed(denoiser, tone(480, rate: 48_000)) |
| 644 | XCTAssertEqual(fake.calls, 2, "and 48 kHz is again") |
| 645 | |
| 646 | XCTAssertEqual(denoiser.level.take().buffers, 3, "every buffer metered across both changes") |
| 647 | XCTAssertEqual(denoiser.inputLevel.take().buffers, 3) |
| 648 | XCTAssertEqual(sink.seen.map(\.rate), [48_000, 24_000, 48_000]) |
| 649 | XCTAssertEqual(sink.seen.map(\.frames), [480, 240, 480]) |
| 650 | XCTAssertTrue(sink.seen.allSatisfy { $0.peak > 1_000 }, "the sink hears sound at each rate") |
| 651 | XCTAssertEqual(reported.rates, [48_000, 24_000, 48_000]) |
| 652 | XCTAssertEqual(denoiser.format.rate, 48_000) |
| 653 | } |
| 654 | |
| 655 | /// A recording that spans the change starts again at the new rate, so |
| 656 | /// what plays back is one rate throughout. |
| 657 | func testTheRecordingStartsAgainWhenTheRateChanges() { |
| 658 | let recorder = SelfTestRecorder(seconds: 1) |
| 659 | recorder.arm() |
| 660 | let a = tone(480, rate: 48_000) |
| 661 | let b = tone(240, rate: 24_000) |
| 662 | a.withUnsafeBufferPointer { recorder.append($0.baseAddress!, frames: 480, rate: 48_000) } |
| 663 | b.withUnsafeBufferPointer { recorder.append($0.baseAddress!, frames: 240, rate: 24_000) } |
| 664 | let taken = recorder.take() |
| 665 | XCTAssertEqual(taken.rate, 24_000) |
| 666 | XCTAssertEqual(taken.samples.count, 240) |
| 667 | } |
| 668 | } |
| 669 | |