Files
whisper_web/tests/longform-vad.test.ts
T
Lucas WintherandClaude Opus 5.5 e0a4b2796f Add pure VAD speech-timestamp and chunking functions for the long-form pipeline
Port of faster-whisper 1.2.1 vad.py (checked identical on a random
20 000-window probability trace for both min-silence settings).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 21:43:18 +01:00

111 lines
4.1 KiB
TypeScript

import { describe, expect, test } from "bun:test";
import {
chunkAudio,
chunkTimeToOriginal,
collectChunks,
getSpeechTimestamps,
SAMPLE_RATE,
VAD_WINDOW_SAMPLES,
} from "../src/worker/longform/vad";
const W = VAD_WINDOW_SAMPLES;
const PAD = (400 / 1000) * SAMPLE_RATE; // speech_pad_ms 400 → 6400 samples
/** Probabilities for `seconds` of audio with speech in the given [start, end) second ranges. */
function probsFor(seconds: number, speech: [number, number][]): Float32Array {
const windows = Math.ceil((seconds * SAMPLE_RATE) / W);
const probs = new Float32Array(windows).fill(0.05);
for (const [start, end] of speech) {
const from = Math.floor((start * SAMPLE_RATE) / W);
const to = Math.floor((end * SAMPLE_RATE) / W);
for (let i = from; i < to; i++) probs[i] = 0.9;
}
return probs;
}
describe("getSpeechTimestamps (port of faster-whisper vad.py)", () => {
test("no window above the threshold → no speech", () => {
const probs = new Float32Array(500).fill(0.3);
expect(getSpeechTimestamps(probs, 500 * W)).toEqual([]);
});
test("one burst is padded by speech_pad_ms on both sides", () => {
const probs = new Float32Array(200).fill(0.05);
for (let i = 50; i < 80; i++) probs[i] = 0.9;
expect(getSpeechTimestamps(probs, 200 * W)).toEqual([
{ start: 50 * W - PAD, end: 80 * W + PAD },
]);
});
test("padding is clamped to the audio", () => {
const probs = new Float32Array(40).fill(0.9);
expect(getSpeechTimestamps(probs, 40 * W)).toEqual([{ start: 0, end: 40 * W }]);
});
test("a pause shorter than min_silence_duration_ms does not split", () => {
const spans = getSpeechTimestamps(probsFor(20, [[2, 6], [7, 11]]), 20 * SAMPLE_RATE);
expect(spans).toHaveLength(1);
});
test("a pause longer than min_silence_duration_ms splits; close spans share the gap", () => {
const spans = getSpeechTimestamps(probsFor(30, [[2, 6], [9, 12]]), 30 * SAMPLE_RATE);
expect(spans).toHaveLength(2);
// 3 s gap ≥ 2·pad → each side padded by the full 400 ms.
expect(spans[0]!.end - Math.floor((6 * SAMPLE_RATE) / W) * W).toBe(PAD);
const narrow = getSpeechTimestamps(probsFor(30, [[2, 6], [8.5, 12]]), 30 * SAMPLE_RATE, {
minSilenceDurationMs: 2000,
speechPadMs: 1500,
});
// 2.5 s gap < 2·1.5 s pad → the gap is split in half instead.
expect(narrow).toHaveLength(2);
expect(narrow[0]!.end).toBe(narrow[1]!.start);
});
test("continuous speech is cut at max_speech_duration_s", () => {
const spans = getSpeechTimestamps(probsFor(70, [[0, 70]]), 70 * SAMPLE_RATE, {
maxSpeechDurationS: 30,
});
expect(spans.length).toBeGreaterThanOrEqual(3);
for (const span of spans) {
expect(span.end - span.start).toBeLessThanOrEqual(30 * SAMPLE_RATE);
}
});
});
describe("collectChunks / chunk time mapping", () => {
const s = (sec: number) => sec * SAMPLE_RATE;
const spans = [
{ start: s(0), end: s(12) },
{ start: s(20), end: s(32) },
{ start: s(40), end: s(52) },
];
test("packs spans into chunks no longer than the maximum", () => {
const chunks = collectChunks(spans, s(30));
expect(chunks.map((chunk) => chunk.samples)).toEqual([s(24), s(12)]);
expect(chunks[0]!.spans).toEqual([spans[0]!, spans[1]!]);
});
test("no spans → no chunks", () => {
expect(collectChunks([], s(30))).toEqual([]);
});
test("chunk-relative seconds map back to the original timeline", () => {
const [chunk] = collectChunks(spans, s(30));
expect(chunkTimeToOriginal(chunk!, 0)).toBe(0);
expect(chunkTimeToOriginal(chunk!, 5)).toBe(5);
expect(chunkTimeToOriginal(chunk!, 12)).toBe(12); // splice → earlier span's end
expect(chunkTimeToOriginal(chunk!, 15)).toBe(23);
expect(chunkTimeToOriginal(chunk!, 99)).toBe(32); // clamps to the last span
});
test("chunkAudio copies exactly the chunk's spans", () => {
const audio = new Float32Array(s(60)).map((_, i) => i);
const [chunk] = collectChunks(spans, s(30));
const out = chunkAudio(audio, chunk!);
expect(out.length).toBe(chunk!.samples);
expect(out[0]).toBe(0);
expect(out[s(12)]).toBe(s(20));
});
});