import Foundation import XCTest @testable import CallsKit /// Stands in for a model: records what it was handed and halves it, so a /// test can see both that a frame reached it and that the result was /// written back in place. private final class FakeSuppressor: NoiseSuppressor { let name = "Fake" let frameLength = 480 var calls = 0 var seen: [Float] = [] func process(_ frame: UnsafeMutablePointer) -> Float { calls += 1 if seen.isEmpty { seen = Array(UnsafeBufferPointer(start: frame, count: frameLength)) } for i in 0.. (CaptureDenoiser, FakeSuppressor, Clock) { let fake = FakeSuppressor() let clock = Clock() return (CaptureDenoiser(suppressor: fake, clock: { clock.now }), fake, clock) } private func run(_ denoiser: CaptureDenoiser, count: Int, value: Float = 1000) -> [Float] { var samples = [Float](repeating: value, count: count) samples.withUnsafeMutableBufferPointer { denoiser.process($0.baseAddress!, frames: count) } return samples } func testTenMillisecondsAt48kIsOneFrameAndIsProcessedInPlace() { let (denoiser, fake, _) = adapter() denoiser.audioProcessingInitialize(sampleRate: 48_000, channels: 1) let out = run(denoiser, count: 480) XCTAssertEqual(fake.calls, 1) XCTAssertEqual(out, [Float](repeating: 500, count: 480), "the result is written back in place") XCTAssertEqual(denoiser.framesProcessed, 1) } func testSamplesReachTheSuppressorInWebRTCScaleUnconverted() { let (denoiser, fake, _) = adapter() denoiser.audioProcessingInitialize(sampleRate: 48_000, channels: 1) _ = run(denoiser, count: 480, value: 32_000) XCTAssertEqual(fake.seen.first, 32_000, "the adapter does not rescale; each suppressor owns its scale") } func testAWholeNumberOfFramesIsSplitIntoFrames() { let (denoiser, fake, _) = adapter() denoiser.audioProcessingInitialize(sampleRate: 48_000, channels: 1) _ = run(denoiser, count: 960) XCTAssertEqual(fake.calls, 2) } func testAPartFrameIsPassedThroughUntouched() { let (denoiser, fake, _) = adapter() denoiser.audioProcessingInitialize(sampleRate: 48_000, channels: 1) let out = run(denoiser, count: 441) XCTAssertEqual(fake.calls, 0) XCTAssertEqual(out, [Float](repeating: 1000, count: 441)) } func testOtherRatesPassThroughAndSaySo() { for rate in [8_000, 16_000, 32_000, 44_100] { let (denoiser, fake, _) = adapter() denoiser.audioProcessingInitialize(sampleRate: rate, channels: 1) let out = run(denoiser, count: rate / 100) XCTAssertEqual(fake.calls, 0, "\(rate) Hz") XCTAssertEqual(out, [Float](repeating: 1000, count: rate / 100)) XCTAssertEqual(denoiser.state, .unsupportedRate(rate)) } } func testMoreThanOneChannelPassesThrough() { let (denoiser, fake, _) = adapter() denoiser.audioProcessingInitialize(sampleRate: 48_000, channels: 2) _ = run(denoiser, count: 480) XCTAssertEqual(fake.calls, 0) } func testNothingIsProcessedBeforeInitialiseOrAfterRelease() { let (denoiser, fake, _) = adapter() _ = run(denoiser, count: 480) XCTAssertEqual(fake.calls, 0, "before initialise") denoiser.audioProcessingInitialize(sampleRate: 48_000, channels: 1) _ = run(denoiser, count: 480) denoiser.audioProcessingRelease() _ = run(denoiser, count: 480) XCTAssertEqual(fake.calls, 1, "after release") // Released is not freed: WebRTC reinitialises whenever processing is // reconfigured, and the same suppressor carries on. denoiser.audioProcessingInitialize(sampleRate: 48_000, channels: 1) _ = run(denoiser, count: 480) XCTAssertEqual(fake.calls, 2) } /// Every initialisation is reported, a repeat included: WebRTC's first /// one is its 16 kHz construction default, before any microphone, and a /// headset really capturing at 16 kHz repeats it. Reporting only changes /// hid that second one, and the plan never put WebRTC's own suppressor /// back on (Calls 0.3.1's "16 kHz … Noise suppression: off"). func testEveryInitialisationIsReported() { let (denoiser, _, _) = adapter() let seen = LockedRates() denoiser.rateChanged = { seen.append($0) } denoiser.audioProcessingInitialize(sampleRate: 16_000, channels: 1) denoiser.audioProcessingInitialize(sampleRate: 16_000, channels: 1) denoiser.audioProcessingInitialize(sampleRate: 48_000, channels: 1) XCTAssertEqual(seen.rates, [16_000, 16_000, 48_000]) } func testTheToggleAndVoiceIsolationBothPassThrough() { let (denoiser, fake, _) = adapter() denoiser.audioProcessingInitialize(sampleRate: 48_000, channels: 1) denoiser.enabled = false XCTAssertEqual(run(denoiser, count: 480).first, 1000) XCTAssertEqual(denoiser.state, .off) denoiser.enabled = true denoiser.standAside = true XCTAssertEqual(run(denoiser, count: 480).first, 1000) XCTAssertEqual(denoiser.state, .standingAside) XCTAssertEqual(fake.calls, 0) } /// A call starts on RNNoise and moves to DeepFilterNet once its model /// has loaded; the next buffer goes to the new model. func testTheModelCanBeReplacedBetweenBuffers() { let (denoiser, first, _) = adapter() denoiser.audioProcessingInitialize(sampleRate: 48_000, channels: 1) _ = run(denoiser, count: 480) let second = FakeSuppressor() denoiser.replace(with: second) _ = run(denoiser, count: 960) XCTAssertEqual(first.calls, 1) XCTAssertEqual(second.calls, 2) XCTAssertEqual(denoiser.framesProcessed, 3) } /// WebRTC stops calling the hook while the microphone is muted. The /// readout must then stop claiming the suppressor is running. func testMutedReadsAsIdleOnceFramesStop() { let (denoiser, _, clock) = adapter() denoiser.audioProcessingInitialize(sampleRate: 48_000, channels: 1) XCTAssertEqual(denoiser.state, .idle(engine: "Fake")) _ = run(denoiser, count: 480) guard case .running(let engine, _) = denoiser.state else { return XCTFail("expected running, got \(denoiser.state)") } XCTAssertEqual(engine, "Fake") clock.now += 2 XCTAssertEqual(denoiser.state, .idle(engine: "Fake")) } } private final class LockedRates: @unchecked Sendable { private let lock = NSLock() private var values: [Int] = [] func append(_ rate: Int) { lock.withLock { values.append(rate) } } var rates: [Int] { lock.withLock { values } } } final class SpeechGainTests: XCTestCase { private func frame(_ amplitude: Float) -> [Float] { (0..<480).map { amplitude * sin(Float($0) * 2 * .pi * 200 / 48_000) } } func testQuietSpeechIsLiftedSlowlyAndNoMoreThanTwelveDecibels() { var gain = SpeechGain() for _ in 0..<5_000 { var f = frame(100) // about -53 dBFS f.withUnsafeMutableBufferPointer { gain.apply($0.baseAddress!, count: 480, speech: 1) } } XCTAssertEqual(gain.gainDb, SpeechGain.maxGainDb, accuracy: 0.01) var one = SpeechGain() var f = frame(100) f.withUnsafeMutableBufferPointer { one.apply($0.baseAddress!, count: 480, speech: 1) } XCTAssertLessThanOrEqual(one.gainDb, 0.011, "one frame moves it by at most 0.01 dB") } func testNoiseIsNeverLiftedAndNothingIsAttenuated() { var gain = SpeechGain() for _ in 0..<1_000 { var f = frame(100) f.withUnsafeMutableBufferPointer { gain.apply($0.baseAddress!, count: 480, speech: 0.1) } } XCTAssertEqual(gain.gainDb, 0) var loud = SpeechGain() var f = frame(30_000) let before = f f.withUnsafeMutableBufferPointer { loud.apply($0.baseAddress!, count: 480, speech: 1) } XCTAssertEqual(f, before, "a loud voice is left alone") } func testTheLiftNeverClips() { var gain = SpeechGain() for _ in 0..<5_000 { var f = frame(100) f.withUnsafeMutableBufferPointer { gain.apply($0.baseAddress!, count: 480, speech: 1) } } var burst = frame(29_000) burst.withUnsafeMutableBufferPointer { gain.apply($0.baseAddress!, count: 480, speech: 1) } XCTAssertLessThanOrEqual(burst.map(abs).max()!, 0.9 * 32_768 + 1) } } final class ProcessingPlanTests: XCTestCase { func testMacRunsOursInPlaceOfWebRTCsSuppressorAndGain() { let plan = ProcessingPlan.plan(iPhone: false, reduceNoise: true, rate: 48_000, voiceIsolation: false) XCTAssertTrue(plan.suppressor) XCTAssertEqual(plan.noiseSuppression, .off) XCTAssertEqual(plan.gainControl, .off, "WebRTC's gain runs before ours and would pump the noise") XCTAssertEqual(plan.echoCancellation, .software) XCTAssertTrue(plan.highPass) } func testAnUnsupportedRateHandsBackToWebRTCsSuppressor() { let plan = ProcessingPlan.plan(iPhone: false, reduceNoise: true, rate: 16_000, voiceIsolation: false) XCTAssertFalse(plan.suppressor) XCTAssertEqual(plan.noiseSuppression, .software) XCTAssertEqual(plan.gainControl, .software) } func testOffMeansNoSuppressionButWebRTCsGain() { let plan = ProcessingPlan.plan(iPhone: false, reduceNoise: false, rate: 48_000, voiceIsolation: false) XCTAssertFalse(plan.suppressor) XCTAssertEqual(plan.noiseSuppression, .off) XCTAssertEqual(plan.gainControl, .software) } func testVoiceIsolationIsNeverStackedWith() { for iPhone in [false, true] { let plan = ProcessingPlan.plan(iPhone: iPhone, reduceNoise: true, rate: 48_000, voiceIsolation: true) XCTAssertFalse(plan.suppressor) } } func testIPhoneKeepsApplesCoupledEchoCancellerAndDropsItsGainForOurs() { let plan = ProcessingPlan.plan(iPhone: true, reduceNoise: true, rate: 48_000, voiceIsolation: false) XCTAssertTrue(plan.suppressor) XCTAssertEqual(plan.echoCancellation, .platform) XCTAssertEqual(plan.noiseSuppression, .platform) XCTAssertEqual(plan.gainControl, .off) } /// The rule, across every combination: ours never runs beside WebRTC's /// software suppressor, and never behind WebRTC's gain control. func testOursNeverStacksWithWebRTCsSuppressorOrFollowsItsGain() { for iPhone in [false, true] { for reduce in [false, true] { for rate in [nil, 8_000, 16_000, 48_000] as [Int?] { for isolation in [false, true] { let plan = ProcessingPlan.plan( iPhone: iPhone, reduceNoise: reduce, rate: rate, voiceIsolation: isolation) if plan.suppressor { XCTAssertNotEqual(plan.noiseSuppression, .software) XCTAssertEqual(plan.gainControl, .off) } } } } } } } /// Real speech (macOS's own synthesiser, normalised to -23 dBFS) with /// noise mixed in, through each real suppressor. /// /// Two claims, measured separately because a suppressor can satisfy /// either by failing the other: the noise-only lead-in is made much /// quieter, and the speech is not. final class SuppressorFixtureTests: XCTestCase { static let rate = 48_000 static func speech() throws -> [Float] { let url = try XCTUnwrap(Bundle.module.url(forResource: "Fixtures/speech-48k", withExtension: "wav")) let data = try Data(contentsOf: url) // A canonical 44-byte header, mono 16-bit at 48 kHz, as written. XCTAssertEqual(data.count > 44, true) return data.dropFirst(44).withUnsafeBytes { raw in raw.bindMemory(to: Int16.self).map { Float(Int16(littleEndian: $0)) } } } /// Broadband hiss (a fan, a room) at about -35 dBFS, plus a key click /// every 180 ms. Seeded, so every run hears the same room. static func noise(count: Int) -> [Float] { var seed: UInt32 = 0x2545_F491 var low: Float = 0 var out = [Float](repeating: 0, count: count) for i in 0..) -> Double { let energy = samples.reduce(0.0) { $0 + Double($1) * Double($1) } / Double(max(samples.count, 1)) return 10 * log10(energy / (32_768.0 * 32_768.0) + 1e-12) } struct Result { let noiseBefore: Double let noiseAfter: Double let speechClean: Double let speechAfter: Double let voicedKept: Double let msPerFrame: Double } static func measure(_ suppressor: NoiseSuppressor) throws -> Result { let clean = try speech() let noise = noise(count: clean.count) var noisy = zip(clean, noise).map { $0 + $1 } let length = suppressor.frameLength let frames = noisy.count / length let started = ProcessInfo.processInfo.systemUptime noisy.withUnsafeMutableBufferPointer { buffer in for f in 0.. -40 else { continue } count += 1 let shifted = f + lag * length if db(out[shifted..<(shifted + length)]) > reference - 10 { kept += 1 } } voiced = count best = max(best, kept) } let kept = best return Result( noiseBefore: db(noise[lead]), noiseAfter: db(out[lead]), speechClean: db(clean[speechRange]), speechAfter: db(out[speechRange]), voicedKept: Double(kept) / Double(max(voiced, 1)), msPerFrame: elapsed * 1000 / Double(frames)) } static func check(_ result: Result, name: String, minimumReduction: Double) { print( "\(name): noise \(String(format: "%.1f", result.noiseBefore)) -> \(String(format: "%.1f", result.noiseAfter)) dBFS;" + " speech \(String(format: "%.1f", result.speechClean)) -> \(String(format: "%.1f", result.speechAfter)) dBFS;" + " voiced frames kept \(Int(result.voicedKept * 100))%;" + " \(String(format: "%.3f", result.msPerFrame)) ms per 10 ms frame") XCTAssertLessThan( result.noiseAfter, result.noiseBefore - minimumReduction, "\(name) should take at least \(minimumReduction) dB off noise alone") XCTAssertGreaterThan( result.speechAfter, result.speechClean - 6, "\(name) must not take the speech with it") XCTAssertGreaterThan(result.voicedKept, 0.8, "\(name) must keep the voiced frames") } func testRNNoiseQuietsNoiseAndKeepsSpeech() throws { let result = try Self.measure(RNNoiseSuppressor()) Self.check(result, name: "RNNoise", minimumReduction: 10) XCTAssertGreaterThan(result.noiseAfter, result.noiseBefore - 40, "the 30 dB limit holds") } #if canImport(DeepFilter) /// The model is a build product (scripts/build-deepfilter.sh), found /// beside the xcframework. static var deepFilterModel: URL { URL(fileURLWithPath: #filePath) .deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent() .deletingLastPathComponent().appendingPathComponent("build/DeepFilterNet3_onnx.tar.gz") } func testDeepFilterQuietsNoiseAndKeepsSpeech() throws { let started = ProcessInfo.processInfo.systemUptime let deep = try XCTUnwrap(DeepFilterSuppressor(model: Self.deepFilterModel), "run build-deepfilter.sh") print("DeepFilterNet 3 model loaded in \(Int((ProcessInfo.processInfo.systemUptime - started) * 1000)) ms") XCTAssertEqual(deep.frameLength, 480) let result = try Self.measure(deep) Self.check(result, name: "DeepFilterNet 3", minimumReduction: 10) XCTAssertGreaterThan(result.noiseAfter, result.noiseBefore - 40, "the 30 dB limit holds") } /// libDF wants ±1; WebRTC hands over ±32768. A full-scale voice must /// come back at full scale, not 32768 times louder or quieter. func testDeepFilterScalesInAndOut() throws { let deep = try XCTUnwrap(DeepFilterSuppressor(model: Self.deepFilterModel)) let clean = try Self.speech() var loudest: Float = 0 var inputLoudest: Float = 0 var samples = Array(clean.prefix(480 * 300)) inputLoudest = samples.map(abs).max()! samples.withUnsafeMutableBufferPointer { buffer in for f in 0..<300 { _ = deep.process(buffer.baseAddress! + f * 480) } } loudest = samples.map(abs).max()! XCTAssertGreaterThan(loudest, inputLoudest / 4, "output is in WebRTC's scale") XCTAssertLessThan(loudest, inputLoudest * 4, "output is in WebRTC's scale") } func testAMissingModelIsNoSuppressorRatherThanACrash() { XCTAssertNil(DeepFilterSuppressor(model: URL(fileURLWithPath: "/nonexistent/model.tar.gz"))) } #endif } /// The adapter in every case it does not process: the audio must leave it /// bit for bit as it came, and the meters and the sink must still see it. /// A meter blind to a passthrough is how a live microphone on a /// multichannel interface, or at 44.1 kHz, would read as dead in the audio /// test. final class CaptureAdapterPassthroughTests: XCTestCase { /// A 440 Hz tone at about -12 dBFS, in WebRTC's ±32768 scale. private static func tone(_ frames: Int, rate: Int) -> [Float] { (0.., _ frames: Int, _ rate: Int) { lock.withLock { calls += 1 rates.append(rate) for i in 0.. CaptureDenoiser { let fake = FakeSuppressor() let denoiser = CaptureDenoiser(suppressor: fake, clock: { 1 }) denoiser.enabled = enabled denoiser.audioProcessingInitialize(sampleRate: rate, channels: channels) let tap = Tap() denoiser.sink = { tap.add($0, $1, $2) } for (name, source) in [("tone", Self.tone(frames, rate: rate)), ("silence", [Float](repeating: 0, count: frames))] { var samples = source samples.withUnsafeMutableBufferPointer { b in denoiser.inputLevel.record(b.baseAddress!, frames: frames, at: 1) denoiser.process(b.baseAddress!, frames: frames) denoiser.observe(b.baseAddress!, frames: frames) } XCTAssertEqual( samples.map(\.bitPattern), source.map(\.bitPattern), "\(name) passes through bit for bit", file: file, line: line) let after = denoiser.level.take() let before = denoiser.inputLevel.take() XCTAssertEqual(after.buffers, 1, "\(name) is metered after the hook", file: file, line: line) XCTAssertEqual(before.buffers, 1, "\(name) is metered before the hook", file: file, line: line) if name == "tone" { XCTAssertGreaterThan(after.rms, 0.1, "the tone reads as a tone", file: file, line: line) XCTAssertEqual(after.rms, before.rms, "nothing was done to it", file: file, line: line) } else { XCTAssertEqual(after.rms, 0, "silence reads as silence", file: file, line: line) XCTAssertEqual(after.peak, 0, file: file, line: line) } } XCTAssertEqual(fake.calls, 0, "the suppressor is never handed a passthrough", file: file, line: line) let seen = tap.seen XCTAssertEqual(seen.calls, 2, "the sink sees both buffers", file: file, line: line) XCTAssertGreaterThan(seen.energy, 0, "and hears the tone", file: file, line: line) XCTAssertEqual(Set(seen.rates), [rate], "at the rate WebRTC reported", file: file, line: line) return denoiser } func testTwoEightAndEighteenChannelsPassThroughAndAreMetered() { for channels in [2, 8, 18] { _ = check(rate: 48_000, channels: channels, frames: 480) } } func testFortyFourOneAndNinetySixKilohertzPassThroughAndSaySo() { for rate in [44_100, 96_000] { let denoiser = check(rate: rate, channels: 1, frames: rate / 100) XCTAssertEqual(denoiser.state, .unsupportedRate(rate)) } } func testTheToggleOffPassesThroughAndIsMetered() { let denoiser = check(rate: 48_000, channels: 1, frames: 480, enabled: false) XCTAssertEqual(denoiser.state, .off) } func testBuffersThatAreNotWholeFramesPassThroughAndAreMetered() { for frames in [441, 479, 500, 1_000] { _ = check(rate: 48_000, channels: 1, frames: frames) } } /// And where it does process, the meter after the hook reads what the /// suppressor made of it (the fake halves it: 6 dB down). func testTheMeterAfterTheHookReadsTheProcessedAudio() { let denoiser = CaptureDenoiser(suppressor: FakeSuppressor(), clock: { 1 }) denoiser.audioProcessingInitialize(sampleRate: 48_000, channels: 1) var samples = Self.tone(480, rate: 48_000) samples.withUnsafeMutableBufferPointer { b in denoiser.inputLevel.record(b.baseAddress!, frames: 480, at: 1) denoiser.process(b.baseAddress!, frames: 480) denoiser.observe(b.baseAddress!, frames: 480) } let before = denoiser.inputLevel.take().rms let after = denoiser.level.take().rms XCTAssertEqual(after / before, 0.5, accuracy: 0.001) } /// Real speech on a stereo capture: untouched, and at speech level on the /// meter the audio test judges "working" by. func testSpeechOnAStereoCaptureIsUntouchedAndReadsAsSpeech() throws { let speech = try SuppressorFixtureTests.speech() let denoiser = CaptureDenoiser(suppressor: FakeSuppressor(), clock: { 1 }) denoiser.audioProcessingInitialize(sampleRate: 48_000, channels: 2) var loudest: Float = 0 for start in stride(from: 0, to: speech.count - 480, by: 480) { var samples = Array(speech[start..<(start + 480)]) let source = samples samples.withUnsafeMutableBufferPointer { b in denoiser.process(b.baseAddress!, frames: 480) denoiser.observe(b.baseAddress!, frames: 480) } XCTAssertEqual(samples, source) loudest = max(loudest, denoiser.level.take().rms) } XCTAssertGreaterThan(loudest, MicJudge.speechRMS) } } /// A Bluetooth headset used as a microphone runs its hands-free rate: 16 /// kHz, 24 kHz on AirPods (8 kHz on old ones). The suppressor passes those /// through, and the meters the audio test and the call read, and the /// recording's sink, must see them anyway: a meter blind to them would /// show every Bluetooth microphone as dead (Paul's AirPods, 2026-09-26). /// Driven through `handle`, the hook's own body. final class BluetoothRateCaptureTests: XCTestCase { private final class Sink: @unchecked Sendable { private let lock = NSLock() private var got: [(frames: Int, rate: Int, peak: Float)] = [] func add(_ samples: UnsafePointer, _ frames: Int, _ rate: Int) { var peak: Float = 0 for i in 0.. [Float] { (0.. [Float] { var copy = samples copy.withUnsafeMutableBufferPointer { denoiser.handle($0.baseAddress!, frames: $0.count, channels: 1) } return copy } func testHandsFreeRatesAreMeteredAndRecordedThoughNotSuppressed() { for rate in [8_000, 16_000, 24_000, 32_000, 44_100] { let fake = FakeSuppressor() let denoiser = CaptureDenoiser(suppressor: fake, clock: { 1 }) denoiser.audioProcessingInitialize(sampleRate: rate, channels: 1) let sink = Sink() denoiser.sink = { sink.add($0, $1, $2) } let voice = tone(rate / 100, rate: rate) XCTAssertEqual(feed(denoiser, voice), voice, "\(rate) Hz passes through untouched") let before = denoiser.inputLevel.take() let after = denoiser.level.take() XCTAssertEqual(before.buffers, 1, "\(rate) Hz reaches the meter before the suppressor") XCTAssertEqual(after.buffers, 1, "\(rate) Hz reaches the meter after it") XCTAssertGreaterThan(after.peak, 0.2, "\(rate) Hz reads as sound, not silence") XCTAssertEqual(sink.seen.map(\.rate), [rate], "the recording is told the rate") XCTAssertEqual(fake.calls, 0) XCTAssertEqual(denoiser.state, .unsupportedRate(rate)) } } /// HFP engages when the microphone opens, so WebRTC reinitialises the /// hook mid-stream: 48 kHz (A2DP, before) to 24 kHz and back. The /// meters never stop, the sink follows the rate, and the suppressor /// runs only while the rate is 48 kHz. func testAMidStreamRateChangeKeepsTheMetersAndFollowsTheRate() { let fake = FakeSuppressor() let denoiser = CaptureDenoiser(suppressor: fake, clock: { 1 }) let sink = Sink() denoiser.sink = { sink.add($0, $1, $2) } let reported = Reported() denoiser.rateChanged = { reported.add($0) } denoiser.audioProcessingInitialize(sampleRate: 48_000, channels: 1) XCTAssertTrue(denoiser.countBuffer(), "the first buffer after an initialise is the one logged") XCTAssertFalse(denoiser.countBuffer()) _ = feed(denoiser, tone(480, rate: 48_000)) XCTAssertEqual(fake.calls, 1, "48 kHz is suppressed") denoiser.audioProcessingRelease() denoiser.audioProcessingInitialize(sampleRate: 24_000, channels: 1) XCTAssertTrue(denoiser.countBuffer(), "a reinitialise starts the count again") let voice = tone(240, rate: 24_000) XCTAssertEqual(feed(denoiser, voice), voice) XCTAssertEqual(fake.calls, 1, "24 kHz is not") denoiser.audioProcessingInitialize(sampleRate: 48_000, channels: 1) _ = feed(denoiser, tone(480, rate: 48_000)) XCTAssertEqual(fake.calls, 2, "and 48 kHz is again") XCTAssertEqual(denoiser.level.take().buffers, 3, "every buffer metered across both changes") XCTAssertEqual(denoiser.inputLevel.take().buffers, 3) XCTAssertEqual(sink.seen.map(\.rate), [48_000, 24_000, 48_000]) XCTAssertEqual(sink.seen.map(\.frames), [480, 240, 480]) XCTAssertTrue(sink.seen.allSatisfy { $0.peak > 1_000 }, "the sink hears sound at each rate") XCTAssertEqual(reported.rates, [48_000, 24_000, 48_000]) XCTAssertEqual(denoiser.format.rate, 48_000) } /// A recording that spans the change starts again at the new rate, so /// what plays back is one rate throughout. func testTheRecordingStartsAgainWhenTheRateChanges() { let recorder = SelfTestRecorder(seconds: 1) recorder.arm() let a = tone(480, rate: 48_000) let b = tone(240, rate: 24_000) a.withUnsafeBufferPointer { recorder.append($0.baseAddress!, frames: 480, rate: 48_000) } b.withUnsafeBufferPointer { recorder.append($0.baseAddress!, frames: 240, rate: 24_000) } let taken = recorder.take() XCTAssertEqual(taken.rate, 24_000) XCTAssertEqual(taken.samples.count, 240) } }