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:
1 parent
19318a56f2
commit
0c3a83c23c
3 files changed
+356
No files matched your search
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user