Better tests and more user feedback while doing transcription; WIP.
This commit is contained in:
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,2 @@
|
||||
The problem I find with AI is, other than its environmental impact, that it's probably a bit overhyped.
|
||||
But lets see what this can do, with an agentic framework, and the whisper model.
|
||||
+154
-94
@@ -1,6 +1,7 @@
|
||||
import { describe, test, expect } from "bun:test";
|
||||
import { existsSync } from "node:fs";
|
||||
import { afterAll, describe, test, expect } from "bun:test";
|
||||
import { existsSync, readdirSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import ffmpegPath from "ffmpeg-static";
|
||||
import { env, pipeline } from "@huggingface/transformers";
|
||||
import { StreamingResampler } from "../src/lib/audio";
|
||||
import {
|
||||
@@ -13,88 +14,135 @@ import { wordErrorRate } from "./dsp";
|
||||
|
||||
/**
|
||||
* Real-model, real-speech accuracy of the live pipeline, measured as WER over
|
||||
* a real Danish recording (tests/audio_files/Festuge.m4a, reference
|
||||
* transcript Festuge.txt). tests/live-accuracy.test.ts proves the pipeline
|
||||
* moves audio losslessly with a deterministic oracle; this test closes the
|
||||
* remaining gap by running actual Whisper weights end to end over the exact
|
||||
* mic path: 48 kHz PCM → StreamingResampler (as src/lib/mic.ts) →
|
||||
* every recording in tests/audio_files. tests/live-accuracy.test.ts proves
|
||||
* the pipeline moves audio losslessly with a deterministic oracle; this test
|
||||
* closes the remaining gap by running actual Whisper weights end to end over
|
||||
* the exact mic path: 48 kHz PCM → StreamingResampler (as src/lib/mic.ts) →
|
||||
* LiveTranscriber with production LIVE_TUNING → the same Transformers.js
|
||||
* pipeline options the inference worker sends (src/worker/inference.worker.ts).
|
||||
*
|
||||
* Adding a clip: drop `<lang>_<name>.<m4a|mp3|wav|…>` plus a reference
|
||||
* transcript `<lang>_<name>.txt` into tests/audio_files — it is discovered
|
||||
* and held to the same bar automatically. `<lang>` is the Whisper language
|
||||
* code the UI user would pick (e.g. `da`, `en`); it is pinned per clip
|
||||
* because "auto" can misdetect (the Danish clip comes back as a rough English
|
||||
* translation, in whole-file mode too — see tests/live.md). Audio is decoded
|
||||
* at test time with ffmpeg-static (trusted devDependency), so any container
|
||||
* ffmpeg reads is fine.
|
||||
*
|
||||
* Model: the catalog's Whisper Large v3 Turbo at its WASM-default dtype (q4,
|
||||
* D-006) — the model a Danish user should pick. Under bun test it runs on
|
||||
* onnxruntime-node (CPU), i.e. the same weights and quantization as the
|
||||
* browser WASM engine, just a different execution provider. `language` is
|
||||
* pinned to "da" exactly as the worker does when the user selects Danish in
|
||||
* the UI; "auto" misdetects this clip as English (whole-file mode too) and
|
||||
* comes back as a rough English translation — see tests/live.md.
|
||||
*
|
||||
* The first run downloads ~600 MB into @huggingface/transformers' cache
|
||||
* (node_modules/@huggingface/transformers/.cache/). When the model is neither
|
||||
* cached nor reachable on the Hub, the test skips so offline development
|
||||
* stays green (this repo is offline-first).
|
||||
*
|
||||
* Fixture: Festuge.48k.wav is Festuge.m4a decoded once, so the test needs no
|
||||
* audio decoder: ffmpeg -i Festuge.m4a -ac 1 -ar 48000 -sample_fmt s16 \
|
||||
* Festuge.48k.wav
|
||||
* D-006) — the model quality-sensitive users should pick. Under bun test it
|
||||
* runs on onnxruntime-node (CPU): the same weights and quantization as the
|
||||
* browser WASM engine, just a different execution provider. The first run
|
||||
* downloads ~600 MB into node_modules/@huggingface/transformers/.cache/;
|
||||
* when the model is neither cached nor reachable on the Hub the accuracy
|
||||
* tests skip, so offline development stays green (this repo is offline-first).
|
||||
*/
|
||||
|
||||
const MODEL_ID = "onnx-community/whisper-large-v3-turbo";
|
||||
const MIC_RATE = 48_000;
|
||||
const RATE = 16_000;
|
||||
/**
|
||||
* "Close enough" for a quantized model on real-world speech: the Danish clip
|
||||
* measures ≈ 0.11 live and the English one lower; the slack absorbs
|
||||
* onnxruntime version-to-version drift, not real regressions — one mid-word
|
||||
* cut or dropped segment on these short clips moves WER far more than this.
|
||||
*/
|
||||
const MAX_WER = 0.25;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Clip discovery: every reference transcript must have audio, and vice versa
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const AUDIO_DIR = join(import.meta.dir, "audio_files");
|
||||
|
||||
interface Clip {
|
||||
/** `<lang>_<name>`, e.g. "da_festuge". */
|
||||
base: string;
|
||||
language: string;
|
||||
audioFile: string;
|
||||
transcriptFile: string;
|
||||
}
|
||||
|
||||
function discoverClips(): { clips: Clip[]; problems: string[] } {
|
||||
const entries = readdirSync(AUDIO_DIR, { withFileTypes: true })
|
||||
.filter((entry) => entry.isFile())
|
||||
.map((entry) => entry.name);
|
||||
const clips: Clip[] = [];
|
||||
const problems: string[] = [];
|
||||
const claimed = new Set<string>();
|
||||
|
||||
for (const transcriptFile of entries.filter((name) => name.endsWith(".txt"))) {
|
||||
const base = transcriptFile.slice(0, -".txt".length);
|
||||
const language = /^([a-z]{2,3})_./.exec(base)?.[1];
|
||||
const audioFile = entries.find(
|
||||
(name) => name !== transcriptFile && name.startsWith(`${base}.`),
|
||||
);
|
||||
if (!language) {
|
||||
problems.push(
|
||||
`${transcriptFile}: name clips "<lang>_<name>.<ext>" (Whisper language code prefix, e.g. da_, en_) so the test knows which language to pin.`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (!audioFile) {
|
||||
problems.push(`${transcriptFile}: no matching audio file "${base}.<ext>".`);
|
||||
continue;
|
||||
}
|
||||
claimed.add(transcriptFile);
|
||||
claimed.add(audioFile);
|
||||
clips.push({ base, language, audioFile, transcriptFile });
|
||||
}
|
||||
|
||||
for (const name of entries) {
|
||||
if (!claimed.has(name) && !name.endsWith(".txt")) {
|
||||
problems.push(
|
||||
`${name}: audio without a reference transcript — add "${name.replace(/\.[^.]+$/, "")}.txt" so accuracy is regression-tested.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return { clips, problems };
|
||||
}
|
||||
|
||||
const { clips, problems } = discoverClips();
|
||||
|
||||
/** Decode any audio container to 48 kHz mono float32 PCM (the mic rate). */
|
||||
async function decodeAudio(path: string): Promise<Float32Array> {
|
||||
const proc = Bun.spawn(
|
||||
[
|
||||
ffmpegPath as unknown as string,
|
||||
...["-hide_banner", "-loglevel", "error"],
|
||||
...["-i", path],
|
||||
...["-ac", "1", "-ar", String(MIC_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);
|
||||
}
|
||||
|
||||
/**
|
||||
* WER normalization: casing and punctuation are not transcription accuracy.
|
||||
* "Århus"/"Aarhus" are the same word in Danish orthography, so they unify.
|
||||
* WER normalization: casing, punctuation, and apostrophes are not
|
||||
* transcription accuracy. "Århus"/"Aarhus" are the same word in Danish
|
||||
* orthography, so they unify.
|
||||
*/
|
||||
function normalize(text: string): string {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.replaceAll("århus", "aarhus")
|
||||
.replace(/[.,!?;:"“”‘’«»()\-–—…]/g, " ")
|
||||
.replace(/['’]/g, "")
|
||||
.replace(/[.,!?;:"“”‘‚«»()\-–—…]/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
/** Minimal RIFF/WAVE reader for the fixture (16-bit PCM mono). */
|
||||
function readWav(buffer: ArrayBuffer): { samples: Float32Array; rate: number } {
|
||||
const view = new DataView(buffer);
|
||||
const tag = (offset: number) =>
|
||||
String.fromCharCode(
|
||||
view.getUint8(offset),
|
||||
view.getUint8(offset + 1),
|
||||
view.getUint8(offset + 2),
|
||||
view.getUint8(offset + 3),
|
||||
);
|
||||
if (tag(0) !== "RIFF" || tag(8) !== "WAVE") throw new Error("not a WAV file");
|
||||
|
||||
let rate = 0;
|
||||
let offset = 12;
|
||||
while (offset + 8 <= view.byteLength) {
|
||||
const id = tag(offset);
|
||||
const size = view.getUint32(offset + 4, true);
|
||||
if (id === "fmt ") {
|
||||
const format = view.getUint16(offset + 8, true);
|
||||
const channels = view.getUint16(offset + 10, true);
|
||||
const bits = view.getUint16(offset + 22, true);
|
||||
if (format !== 1 || channels !== 1 || bits !== 16) {
|
||||
throw new Error("fixture must be 16-bit PCM mono");
|
||||
}
|
||||
rate = view.getUint32(offset + 12, true);
|
||||
} else if (id === "data") {
|
||||
const pcm = new Int16Array(buffer, offset + 8, size / 2);
|
||||
const samples = new Float32Array(pcm.length);
|
||||
for (let i = 0; i < pcm.length; i++) samples[i] = pcm[i]! / 32768;
|
||||
return { samples, rate };
|
||||
}
|
||||
offset += 8 + size + (size % 2);
|
||||
}
|
||||
throw new Error("WAV data chunk not found");
|
||||
}
|
||||
|
||||
/** Cached locally, or downloadable right now — otherwise the test skips. */
|
||||
/** Cached locally, or downloadable right now — otherwise accuracy tests skip. */
|
||||
async function modelAvailable(id: string): Promise<boolean> {
|
||||
if (existsSync(join(env.cacheDir, id))) return true;
|
||||
if (env.cacheDir && existsSync(join(env.cacheDir, id))) return true;
|
||||
try {
|
||||
const response = await fetch(
|
||||
`https://huggingface.co/${id}/resolve/main/config.json`,
|
||||
@@ -108,27 +156,45 @@ async function modelAvailable(id: string): Promise<boolean> {
|
||||
|
||||
const available = await modelAvailable(MODEL_ID);
|
||||
|
||||
describe("live pipeline accuracy (real model, real Danish speech)", () => {
|
||||
test.skipIf(!available)(
|
||||
"Festuge.m4a over the mic path stays close to the reference transcript",
|
||||
async () => {
|
||||
const dir = join(import.meta.dir, "audio_files");
|
||||
const reference = await Bun.file(join(dir, "Festuge.txt")).text();
|
||||
const wav = readWav(await Bun.file(join(dir, "Festuge.48k.wav")).arrayBuffer());
|
||||
expect(wav.rate).toBe(MIC_RATE);
|
||||
// The pipeline is loaded lazily by the first accuracy test and shared.
|
||||
type Asr = Awaited<ReturnType<typeof pipeline<"automatic-speech-recognition">>>;
|
||||
let asrPromise: Promise<Asr> | null = null;
|
||||
function loadAsr(): Promise<Asr> {
|
||||
asrPromise ??= pipeline("automatic-speech-recognition", MODEL_ID, {
|
||||
dtype: resolveDefaultDtype(MODEL_ID, "wasm"),
|
||||
});
|
||||
return asrPromise;
|
||||
}
|
||||
|
||||
const asr = await pipeline("automatic-speech-recognition", MODEL_ID, {
|
||||
dtype: resolveDefaultDtype(MODEL_ID, "wasm"),
|
||||
});
|
||||
try {
|
||||
afterAll(async () => {
|
||||
if (asrPromise) await (await asrPromise).dispose();
|
||||
});
|
||||
|
||||
describe("live pipeline accuracy (real model, real recordings)", () => {
|
||||
test("every file in tests/audio_files forms a <lang>_<name> audio+transcript pair", () => {
|
||||
expect(problems).toEqual([]);
|
||||
expect(clips.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
for (const clip of clips) {
|
||||
test.skipIf(!available)(
|
||||
`${clip.base} (${clip.language}): live mic-path transcript stays close to the reference`,
|
||||
async () => {
|
||||
const reference = await Bun.file(
|
||||
join(AUDIO_DIR, clip.transcriptFile),
|
||||
).text();
|
||||
const micAudio = await decodeAudio(join(AUDIO_DIR, clip.audioFile));
|
||||
expect(micAudio.length).toBeGreaterThan(MIC_RATE); // ≥ 1 s of speech
|
||||
|
||||
const asr = await loadAsr();
|
||||
const transcribe: LiveTranscribeFn = async (audio) => {
|
||||
// Mirrors src/worker/inference.worker.ts `transcribe` options with
|
||||
// the UI set to Danish.
|
||||
// the UI language set to the clip's language.
|
||||
const output = await asr(audio, {
|
||||
chunk_length_s: 30,
|
||||
stride_length_s: 5,
|
||||
return_timestamps: true,
|
||||
language: "da",
|
||||
language: clip.language,
|
||||
task: "transcribe",
|
||||
});
|
||||
const single = (Array.isArray(output) ? output[0] : output) as {
|
||||
@@ -142,9 +208,9 @@ describe("live pipeline accuracy (real model, real Danish speech)", () => {
|
||||
const resampler = new StreamingResampler(MIC_RATE, RATE);
|
||||
const blocks: Float32Array[] = [];
|
||||
const chunk = MIC_RATE / 10;
|
||||
for (let i = 0; i < wav.samples.length; i += chunk) {
|
||||
for (let i = 0; i < micAudio.length; i += chunk) {
|
||||
const out = resampler.process(
|
||||
wav.samples.slice(i, Math.min(i + chunk, wav.samples.length)),
|
||||
micAudio.slice(i, Math.min(i + chunk, micAudio.length)),
|
||||
);
|
||||
if (out.length > 0) blocks.push(out);
|
||||
}
|
||||
@@ -186,32 +252,26 @@ describe("live pipeline accuracy (real model, real Danish speech)", () => {
|
||||
const result = await live.stop();
|
||||
const liveWer = wordErrorRate(normalize(reference), normalize(result.text));
|
||||
|
||||
// "Close enough": measured 0.11 (live) / 0.14 (whole-file) on this
|
||||
// clip; the margin absorbs onnxruntime version-to-version drift, not
|
||||
// real regressions — a single mid-word cut or dropped segment on a
|
||||
// 64-word reference moves WER by far more than the slack here.
|
||||
expect(liveWer).toBeLessThanOrEqual(0.25);
|
||||
expect(liveWer).toBeLessThanOrEqual(MAX_WER);
|
||||
// Segmentation must not cost accuracy relative to whole-file mode.
|
||||
expect(liveWer).toBeLessThanOrEqual(fileWer + 0.1);
|
||||
|
||||
// Live UX invariants, with a real model: tentative text streamed, and
|
||||
// the final chunks carry absolute session timestamps spanning the clip.
|
||||
expect(sawTentative).toBe(true);
|
||||
expect(result.chunks.length).toBeGreaterThanOrEqual(3);
|
||||
expect(result.chunks.length).toBeGreaterThanOrEqual(1);
|
||||
const first = result.chunks[0]!;
|
||||
const last = result.chunks[result.chunks.length - 1]!;
|
||||
expect(first.timestamp[0]).toBeGreaterThan(0.5); // speech starts ~1.8 s in
|
||||
expect(first.timestamp[0]).toBeLessThan(5);
|
||||
expect(first.timestamp[0]).toBeGreaterThanOrEqual(0);
|
||||
expect(first.timestamp[0]).toBeLessThan(durationSeconds / 2);
|
||||
expect(last.timestamp[1] ?? last.timestamp[0]).toBeGreaterThan(
|
||||
durationSeconds - 5,
|
||||
durationSeconds / 2,
|
||||
);
|
||||
expect(last.timestamp[1] ?? last.timestamp[0]).toBeLessThanOrEqual(
|
||||
durationSeconds + 1,
|
||||
);
|
||||
} finally {
|
||||
await asr.dispose();
|
||||
}
|
||||
},
|
||||
900_000, // first run downloads ~600 MB of weights
|
||||
);
|
||||
},
|
||||
900_000, // first run downloads ~600 MB of weights
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
+9
-5
@@ -5,11 +5,15 @@ unit tests, and transcript *accuracy* of the pipeline (segmentation, pass
|
||||
scheduling, mic-path resampling) is regression-tested as WER by
|
||||
`tests/live-accuracy.test.ts` + `tests/resampler.test.ts`. End-to-end quality
|
||||
with real Whisper weights and real speech is regression-tested too:
|
||||
`tests/live-real-model.test.ts` runs the Danish clip
|
||||
`tests/audio_files/Festuge.m4a` through the full mic path with
|
||||
`whisper-large-v3-turbo` (q4) and holds live WER against the reference
|
||||
transcript (≤ 0.25; measured ≈ 0.11) and against whole-file mode (it skips
|
||||
itself when the model is neither cached nor downloadable). Microphone capture,
|
||||
`tests/live-real-model.test.ts` runs **every** recording in
|
||||
`tests/audio_files/` through the full mic path with `whisper-large-v3-turbo`
|
||||
(q4) and holds live WER against the reference transcript (≤ 0.25; measured
|
||||
≈ 0.11 for `da_festuge`, ≈ 0.06 for `en_aiproblem`) and against whole-file
|
||||
mode (it skips itself when the model is neither cached nor downloadable).
|
||||
To cover a new recording, drop `<lang>_<name>.<m4a|mp3|wav|…>` plus its
|
||||
reference transcript `<lang>_<name>.txt` into `tests/audio_files/` — the test
|
||||
discovers it, decodes it with ffmpeg-static, pins `<lang>` as the Whisper
|
||||
language, and fails loudly on unpaired or unprefixed files. Microphone capture,
|
||||
the AudioWorklet path, and WebGPU/WASM execution in an actual browser can
|
||||
only be exercised manually. This is that procedure.
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, test, expect } from "bun:test";
|
||||
import {
|
||||
applyLoadProgress,
|
||||
deviceAttempts,
|
||||
initialLoadProgress,
|
||||
isUiToWorkerMessage,
|
||||
isWorkerToUiMessage,
|
||||
@@ -103,6 +104,57 @@ describe("isWorkerToUiMessage", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("deviceAttempts (D-004, D-017)", () => {
|
||||
test("auto resolves WebNN → WebGPU → WASM when everything is available", () => {
|
||||
expect(deviceAttempts("auto", { webnn: true, webgpu: true })).toEqual([
|
||||
"webnn",
|
||||
"webgpu",
|
||||
"wasm",
|
||||
]);
|
||||
});
|
||||
|
||||
test("auto skips backends the probe ruled out, in order", () => {
|
||||
expect(deviceAttempts("auto", { webnn: false, webgpu: true })).toEqual([
|
||||
"webgpu",
|
||||
"wasm",
|
||||
]);
|
||||
expect(deviceAttempts("auto", { webnn: true, webgpu: false })).toEqual([
|
||||
"webnn",
|
||||
"wasm",
|
||||
]);
|
||||
expect(deviceAttempts("auto", { webnn: false, webgpu: false })).toEqual(["wasm"]);
|
||||
});
|
||||
|
||||
test("explicit webgpu falls back to WASM only", () => {
|
||||
expect(deviceAttempts("webgpu", { webnn: true, webgpu: true })).toEqual([
|
||||
"webgpu",
|
||||
"wasm",
|
||||
]);
|
||||
expect(deviceAttempts("webgpu", { webnn: true, webgpu: false })).toEqual(["wasm"]);
|
||||
});
|
||||
|
||||
test("explicit webnn falls back to WASM only (no WebGPU detour)", () => {
|
||||
expect(deviceAttempts("webnn", { webnn: true, webgpu: true })).toEqual([
|
||||
"webnn",
|
||||
"wasm",
|
||||
]);
|
||||
expect(deviceAttempts("webnn", { webnn: false, webgpu: true })).toEqual(["wasm"]);
|
||||
});
|
||||
|
||||
test("wasm is honored as-is and every chain ends in WASM", () => {
|
||||
expect(deviceAttempts("wasm", { webnn: true, webgpu: true })).toEqual(["wasm"]);
|
||||
for (const preference of ["auto", "webnn", "webgpu", "wasm"] as const) {
|
||||
for (const webnn of [true, false]) {
|
||||
for (const webgpu of [true, false]) {
|
||||
const attempts = deviceAttempts(preference, { webnn, webgpu });
|
||||
expect(attempts.length).toBeGreaterThan(0);
|
||||
expect(attempts.at(-1)).toBe("wasm");
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyLoadProgress", () => {
|
||||
test("tracks per-file byte progress without mutating previous state", () => {
|
||||
const p1: LoadProgressMessage = { type: "load-progress", status: "initiate", file: "a.onnx" };
|
||||
|
||||
Reference in New Issue
Block a user