111 lines
4.5 KiB
TypeScript
111 lines
4.5 KiB
TypeScript
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);
|
||
});
|
||
});
|