import { describe, test, expect } from "bun:test"; import { StreamingResampler, resampleLinear } from "../src/lib/audio"; import { goertzelPower, sine } from "./dsp"; // Signal-quality tests for the mic-path resampler (D-016). Whisper accuracy // depends on the 48 kHz → 16 kHz decimation NOT folding ultrasonic/fricative // energy into the speech band, which is exactly what a plain linear resampler // does — these tests pin the anti-aliasing behavior down numerically. function rmsOf(samples: Float32Array, trim = 0): number { let sum = 0; let count = 0; for (let i = trim; i < samples.length - trim; i++) { sum += samples[i]! * samples[i]!; count++; } return Math.sqrt(sum / Math.max(1, count)); } /** Run a whole signal through in one call + flush. */ function resampleWhole(input: Float32Array, from: number, to: number): Float32Array { const resampler = new StreamingResampler(from, to); const head = resampler.process(input); const tail = resampler.flush(); const out = new Float32Array(head.length + tail.length); out.set(head, 0); out.set(tail, head.length); return out; } describe("StreamingResampler", () => { test("passthrough at equal rates returns the input unchanged", () => { const input = sine(440, 0.1, 16_000); const resampler = new StreamingResampler(16_000, 16_000); expect(resampler.process(input)).toBe(input); expect(resampler.flush().length).toBe(0); }); test("output length matches the rate ratio", () => { const out = resampleWhole(sine(1000, 1, 48_000), 48_000, 16_000); expect(Math.abs(out.length - 16_000)).toBeLessThanOrEqual(2); }); test("passband: a 1 kHz tone survives 48 kHz → 16 kHz at unity gain and pitch", () => { const input = sine(1000, 1, 48_000); const out = resampleWhole(input, 48_000, 16_000); // Amplitude preserved (edges trimmed: half a kernel of warm-up). expect(rmsOf(out, 100)).toBeCloseTo(rmsOf(input, 300), 2); // Pitch preserved: energy sits at 1 kHz, not at any shifted frequency. const at1k = goertzelPower(out, 200, out.length - 200, 1000, 16_000); const at1k2 = goertzelPower(out, 200, out.length - 200, 1200, 16_000); expect(at1k).toBeGreaterThan(100 * at1k2); }); test("stopband: energy above the target Nyquist is rejected, not aliased", () => { // 11 kHz is inaudible to a 16 kHz stream — it must vanish. Linear // resampling folds it to 5 kHz (16 − 11) at nearly full amplitude, // polluting the band Whisper reads. Require > 34 dB rejection. const input = sine(11_000, 0.5, 48_000); const out = resampleWhole(input, 48_000, 16_000); expect(rmsOf(out, 100)).toBeLessThan(0.02 * rmsOf(input)); // Document the motivation: the old path really did alias. const linear = resampleLinear(input, 48_000, 16_000); expect(rmsOf(linear)).toBeGreaterThan(0.1 * rmsOf(input)); }); test("chunked processing is equivalent to whole-signal processing", () => { // Mixed deterministic signal, awkward chunk size, non-integer ratio. const input = new Float32Array(44_100); for (let i = 0; i < input.length; i++) { input[i] = 0.3 * Math.sin((2 * Math.PI * 700 * i) / 44_100) + 0.2 * Math.sin((2 * Math.PI * 3100 * i) / 44_100 + 1.3); } const whole = resampleWhole(input, 44_100, 16_000); const resampler = new StreamingResampler(44_100, 16_000); const pieces: Float32Array[] = []; for (let i = 0; i < input.length; i += 333) { pieces.push(resampler.process(input.slice(i, Math.min(i + 333, input.length)))); } pieces.push(resampler.flush()); const chunked = new Float32Array(pieces.reduce((n, p) => n + p.length, 0)); let offset = 0; for (const piece of pieces) { chunked.set(piece, offset); offset += piece.length; } expect(chunked.length).toBe(whole.length); for (let i = 0; i < whole.length; i++) { if (Math.abs(chunked[i]! - whole[i]!) > 1e-6) { throw new Error( `chunked/whole mismatch at ${i}: ${chunked[i]} vs ${whole[i]}`, ); } } }); test("upsampling 8 kHz → 16 kHz preserves the signal", () => { const input = sine(1000, 0.5, 8_000); const out = resampleWhole(input, 8_000, 16_000); expect(Math.abs(out.length - 8_000)).toBeLessThanOrEqual(2); expect(rmsOf(out, 100)).toBeCloseTo(rmsOf(input, 50), 2); const at1k = goertzelPower(out, 100, out.length - 100, 1000, 16_000); const at1k5 = goertzelPower(out, 100, out.length - 100, 1500, 16_000); expect(at1k).toBeGreaterThan(100 * at1k5); }); });