Run Silero VAD and Whisper decodes for the long-form pipeline on Transformers.js

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas WintherandClaude Opus 5.5 committed 2026-10-29 22:02:47 +01:00
1 parent 19318a56f2
commit 0c3a83c23c
3 files changed
+356

No files matched your search

+49
View File
@@ -0,0 +1,49 @@
/**
* Test-time access to the recordings in tests/audio_files: decode with
* ffmpeg-static (trusted devDependency) and the WER text normalization used
* by tests/live-real-model.test.ts.
*/
import { join } from "node:path";
import ffmpegPath from "ffmpeg-static";
export const AUDIO_DIR = join(import.meta.dir, "audio_files");
/** Decode `tests/audio_files/<name>` to mono float32 PCM at `rate` (16 kHz by default). */
export async function decodeClip(name: string, rate = 16_000): Promise<Float32Array> {
const path = join(AUDIO_DIR, name);
const proc = Bun.spawn(
[
ffmpegPath as unknown as string,
...["-hide_banner", "-loglevel", "error"],
...["-i", path],
...["-ac", "1", "-ar", String(rate), "-f", "f32le", "-"],
],
{ stdout: "pipe", stderr: "pipe" },
);
const [bytes, errors, exitCode] = await Promise.all([
new Response(proc.stdout).arrayBuffer(),
new Response(proc.stderr).text(),
proc.exited,
]);
if (exitCode !== 0) throw new Error(`ffmpeg failed on ${path}: ${errors}`);
return new Float32Array(bytes);
}
/** Reference transcript of `tests/audio_files/<base>.txt`. */
export function readReference(base: string): Promise<string> {
return Bun.file(join(AUDIO_DIR, `${base}.txt`)).text();
}
/**
* WER normalization: casing, punctuation, and apostrophes are not
* transcription accuracy; "Århus"/"Aarhus" unify (same as the real-model test).
*/
export function normalize(text: string): string {
return text
.toLowerCase()
.replaceAll("århus", "aarhus")
.replace(/['’]/g, "")
.replace(/[.,!?;:"“”‘‚«»()\-–—…]/g, " ")
.replace(/\s+/g, " ")
.trim();
}
+75
View File
@@ -0,0 +1,75 @@
import { afterEach, describe, expect, test } from "bun:test";
import { existsSync } from "node:fs";
import { join } from "node:path";
import { env } from "@huggingface/transformers";
import { loadSileroVad, VAD_MODEL_ID } from "../src/worker/longform/transformers";
import { getSpeechTimestamps, SAMPLE_RATE } from "../src/worker/longform/vad";
import { decodeClip } from "./clips";
const realFetch = globalThis.fetch;
afterEach(() => {
globalThis.fetch = realFetch;
});
describe("loadSileroVad offline (plan 001-0 Review Focus 3)", () => {
test("localOnly load with nothing cached rejects without calling fetch", async () => {
let fetches = 0;
globalThis.fetch = (async () => {
fetches += 1;
throw new Error("network must not be touched");
}) as unknown as typeof fetch;
await expect(
loadSileroVad({ localOnly: true, isCached: async () => false }),
).rejects.toThrow(/not downloaded/);
expect(fetches).toBe(0);
});
});
/** Cached locally, or downloadable right now — otherwise the real-VAD tests skip. */
async function vadAvailable(): Promise<boolean> {
if (env.cacheDir && existsSync(join(env.cacheDir, VAD_MODEL_ID))) return true;
try {
const response = await fetch(
`https://huggingface.co/${VAD_MODEL_ID}/resolve/main/onnx/model.onnx`,
{ method: "HEAD", signal: AbortSignal.timeout(5_000) },
);
return response.ok;
} catch {
return false;
}
}
const available = await vadAvailable();
describe.skipIf(!available)("Silero VAD on the Transformers.js runtime (real model)", () => {
const seconds = 10;
const tone = new Float32Array(seconds * SAMPLE_RATE).map(
(_, i) => 0.3 * Math.sin((2 * Math.PI * 440 * i) / SAMPLE_RATE),
);
let seed = 1;
const noise = new Float32Array(seconds * SAMPLE_RATE).map(() => {
seed = (seed * 1_103_515_245 + 12_345) % 2 ** 31;
return 0.2 * (seed / 2 ** 30 - 1);
});
for (const [name, audio] of [
["silence", new Float32Array(seconds * SAMPLE_RATE)],
["a 440 Hz tone", tone],
["white noise", noise],
] as const) {
test(`${name} contains no speech`, async () => {
const vad = await loadSileroVad({ localOnly: false });
const probs = await vad.speechProbabilities(audio);
expect(probs.length).toBe(Math.ceil(audio.length / 512));
expect(getSpeechTimestamps(probs, audio.length)).toEqual([]);
});
}
test("en_aiproblem: speech is found and covers most of the clip", async () => {
const audio = await decodeClip("en_aiproblem.m4a");
const vad = await loadSileroVad({ localOnly: false });
const spans = getSpeechTimestamps(await vad.speechProbabilities(audio), audio.length);
expect(spans.length).toBeGreaterThan(0);
const covered = spans.reduce((n, s) => n + s.end - s.start, 0);
expect(covered / audio.length).toBeGreaterThan(0.8);
});
});