oficina / meet Public

Clone
git clone https://amadan.net/oficina/meet

Plain git — no account needed to clone.

Download

Download this file

1import Foundation
2import 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.
9private 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
22private 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.
28final 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
164private 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
171final 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
215final 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.
280final 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.
437final 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.
568final 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