76 lines
2.8 KiB
TypeScript
76 lines
2.8 KiB
TypeScript
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);
|
|
});
|
|
});
|