Better tests and more user feedback while doing transcription; WIP.

This commit is contained in:
Lucas Winther
2026-07-14 14:44:01 +02:00
parent 18acead43c
commit be82ecfe57
19 changed files with 447 additions and 138 deletions
Binary file not shown.
Binary file not shown.
+2
View File
@@ -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
View File
@@ -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
View File
@@ -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.
+52
View File
@@ -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" };