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