Files
au_whisper_web/tests/resampler.test.ts
T

111 lines
4.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
});
});