Improvements and tests for live transcription.
This commit is contained in:
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,3 @@
|
||||
På Aarhus Festuges sekretariat har vi løbet rigtig stærkt de sidste måneder.
|
||||
Selvom du måske lige nu mest tænker på sol og sommerferie, så er Festugen lige rundt om hjørnet for os.
|
||||
Men nu trækker vi stikket, går på sommerferie og lader op til det sidste store ryk frem mod Festugen, så vi er helt klar, når det hele begynder fredag den 28. august.
|
||||
+250
@@ -0,0 +1,250 @@
|
||||
/**
|
||||
* Shared DSP helpers for the live-accuracy and resampler tests.
|
||||
*
|
||||
* The accuracy harness encodes "words" as pure tones at word-specific
|
||||
* frequencies and decodes them with a Goertzel filter bank ("oracle engine").
|
||||
* That makes transcript accuracy of the live *pipeline* measurable under
|
||||
* `bun test` with no model: if the segment loop cuts a word in half, drops
|
||||
* audio, duplicates a boundary, leaks stale text, or the resampler shifts /
|
||||
* aliases frequencies, the decoded word sequence changes and WER rises above
|
||||
* zero.
|
||||
*/
|
||||
|
||||
import type { LiveTranscribeResult } from "../src/lib/live";
|
||||
|
||||
export const RATE = 16_000;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Signal generation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function sine(
|
||||
freq: number,
|
||||
seconds: number,
|
||||
rate: number = RATE,
|
||||
amplitude = 0.2,
|
||||
): Float32Array {
|
||||
const out = new Float32Array(Math.round(seconds * rate));
|
||||
for (let i = 0; i < out.length; i++) {
|
||||
out[i] = amplitude * Math.sin((2 * Math.PI * freq * i) / rate);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function silence(seconds: number, rate: number = RATE): Float32Array {
|
||||
return new Float32Array(Math.round(seconds * rate));
|
||||
}
|
||||
|
||||
export function concatAudio(parts: readonly Float32Array[]): Float32Array {
|
||||
const out = new Float32Array(parts.reduce((n, p) => n + p.length, 0));
|
||||
let offset = 0;
|
||||
for (const part of parts) {
|
||||
out.set(part, offset);
|
||||
offset += part.length;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tone-word vocabulary and session builder
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const ORACLE_WORDS = [
|
||||
"alpha",
|
||||
"bravo",
|
||||
"charlie",
|
||||
"delta",
|
||||
"echo",
|
||||
"foxtrot",
|
||||
"golf",
|
||||
"hotel",
|
||||
] as const;
|
||||
|
||||
/** Word k ↔ tone at 500 + 250·k Hz (500–2250 Hz, well inside speech band). */
|
||||
export function wordFreq(index: number): number {
|
||||
return 500 + 250 * index;
|
||||
}
|
||||
|
||||
export const WORD_SECONDS = 0.25;
|
||||
export const GAP_SECONDS = 0.08;
|
||||
|
||||
export interface BuiltSession {
|
||||
audio: Float32Array;
|
||||
/** Ground truth: every word with its absolute onset time in seconds. */
|
||||
truth: { word: string; start: number }[];
|
||||
/** The reference transcript (all words, in order, space-joined). */
|
||||
reference: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lay out utterances (word lists) separated by `pauseSeconds` of silence,
|
||||
* with `GAP_SECONDS` between words inside an utterance.
|
||||
*/
|
||||
export function buildSession(
|
||||
utterances: readonly (readonly string[])[],
|
||||
options: {
|
||||
rate?: number;
|
||||
leadSeconds?: number;
|
||||
pauseSeconds?: number;
|
||||
/**
|
||||
* Duration of the very first word. Segments start relative to speech
|
||||
* onset, so stretching the first word is how a test shifts where a
|
||||
* max-length cap lands within the word/gap cycle (a longer lead-in
|
||||
* silence shifts onset and cap together and changes nothing).
|
||||
*/
|
||||
firstWordSeconds?: number;
|
||||
} = {},
|
||||
): BuiltSession {
|
||||
const rate = options.rate ?? RATE;
|
||||
const lead = options.leadSeconds ?? 0.5;
|
||||
const pause = options.pauseSeconds ?? 2.0;
|
||||
|
||||
const parts: Float32Array[] = [silence(lead, rate)];
|
||||
const truth: { word: string; start: number }[] = [];
|
||||
let t = lead;
|
||||
let first = true;
|
||||
utterances.forEach((words, u) => {
|
||||
words.forEach((word, w) => {
|
||||
const index = ORACLE_WORDS.indexOf(word as (typeof ORACLE_WORDS)[number]);
|
||||
if (index === -1) throw new Error(`unknown oracle word: ${word}`);
|
||||
const duration = first ? (options.firstWordSeconds ?? WORD_SECONDS) : WORD_SECONDS;
|
||||
first = false;
|
||||
truth.push({ word, start: t });
|
||||
parts.push(sine(wordFreq(index), duration, rate));
|
||||
t += duration;
|
||||
if (w < words.length - 1) {
|
||||
parts.push(silence(GAP_SECONDS, rate));
|
||||
t += GAP_SECONDS;
|
||||
}
|
||||
});
|
||||
if (u < utterances.length - 1) {
|
||||
parts.push(silence(pause, rate));
|
||||
t += pause;
|
||||
}
|
||||
});
|
||||
return {
|
||||
audio: concatAudio(parts),
|
||||
truth,
|
||||
reference: truth.map((entry) => entry.word).join(" "),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Goertzel filter bank + oracle decoder
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Normalized power of `freq` over samples[from, to). */
|
||||
export function goertzelPower(
|
||||
samples: Float32Array,
|
||||
from: number,
|
||||
to: number,
|
||||
freq: number,
|
||||
rate: number,
|
||||
): number {
|
||||
const coeff = 2 * Math.cos((2 * Math.PI * freq) / rate);
|
||||
let s1 = 0;
|
||||
let s2 = 0;
|
||||
for (let i = from; i < to; i++) {
|
||||
const s0 = samples[i]! + coeff * s1 - s2;
|
||||
s2 = s1;
|
||||
s1 = s0;
|
||||
}
|
||||
const n = to - from;
|
||||
return (s1 * s1 + s2 * s2 - coeff * s1 * s2) / (n * n);
|
||||
}
|
||||
|
||||
const ORACLE_FRAME_SECONDS = 0.02;
|
||||
/** Runs shorter than this are boundary noise and ignored. */
|
||||
const ORACLE_FRAGMENT_MIN_SECONDS = 0.06;
|
||||
/** Runs at least this long are a complete word (words are 0.25 s). */
|
||||
const ORACLE_WORD_MIN_SECONDS = 0.22;
|
||||
/** What a truncated word decodes as — a substitution, like real Whisper. */
|
||||
export const ORACLE_FRAGMENT = "▯";
|
||||
|
||||
/**
|
||||
* Decode tone-words from 16 kHz audio. Deterministic stand-in for Whisper:
|
||||
* frames are labeled by dominant vocabulary frequency; runs ≥ 220 ms decode
|
||||
* as the word, and truncated runs (60–220 ms — a word cut by a segment
|
||||
* boundary) decode as the garbage token `ORACLE_FRAGMENT`, because a real
|
||||
* model mis-transcribes a cut word rather than silently skipping it. Either
|
||||
* fragment of a mid-word cut therefore raises WER.
|
||||
* Output mimics Whisper: leading-space text, per-word timestamped chunks.
|
||||
*/
|
||||
export function oracleDecode(audio: Float32Array): LiveTranscribeResult {
|
||||
const hop = Math.round(RATE * ORACLE_FRAME_SECONDS);
|
||||
const fragmentRun = Math.round(ORACLE_FRAGMENT_MIN_SECONDS / ORACLE_FRAME_SECONDS);
|
||||
const wordRun = Math.round(ORACLE_WORD_MIN_SECONDS / ORACLE_FRAME_SECONDS);
|
||||
|
||||
const labels: number[] = [];
|
||||
for (let start = 0; start + hop <= audio.length; start += hop) {
|
||||
let energy = 0;
|
||||
for (let i = start; i < start + hop; i++) energy += audio[i]! * audio[i]!;
|
||||
if (Math.sqrt(energy / hop) < 0.05) {
|
||||
labels.push(-1);
|
||||
continue;
|
||||
}
|
||||
let best = -1;
|
||||
let bestPower = 0;
|
||||
for (let k = 0; k < ORACLE_WORDS.length; k++) {
|
||||
const power = goertzelPower(audio, start, start + hop, wordFreq(k), RATE);
|
||||
if (power > bestPower) {
|
||||
bestPower = power;
|
||||
best = k;
|
||||
}
|
||||
}
|
||||
labels.push(best);
|
||||
}
|
||||
|
||||
const words: string[] = [];
|
||||
const chunks: LiveTranscribeResult["chunks"] = [];
|
||||
let runLabel = -1;
|
||||
let runStart = 0;
|
||||
const endRun = (endFrame: number) => {
|
||||
const length = endFrame - runStart;
|
||||
if (runLabel < 0 || length < fragmentRun) return;
|
||||
const word = length >= wordRun ? ORACLE_WORDS[runLabel]! : ORACLE_FRAGMENT;
|
||||
words.push(word);
|
||||
chunks.push({
|
||||
text: ` ${word}`,
|
||||
timestamp: [runStart * ORACLE_FRAME_SECONDS, endFrame * ORACLE_FRAME_SECONDS],
|
||||
});
|
||||
};
|
||||
labels.forEach((label, frame) => {
|
||||
if (label !== runLabel) {
|
||||
endRun(frame);
|
||||
runLabel = label;
|
||||
runStart = frame;
|
||||
}
|
||||
});
|
||||
endRun(labels.length);
|
||||
|
||||
return { text: words.length > 0 ? ` ${words.join(" ")}` : "", chunks };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Word error rate
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Standard WER: word-level Levenshtein distance over the reference length.
|
||||
* 0 = perfect; insertions (duplicated boundary fragments), deletions
|
||||
* (dropped audio), and substitutions all count.
|
||||
*/
|
||||
export function wordErrorRate(reference: string, hypothesis: string): number {
|
||||
const ref = reference.trim().split(/\s+/).filter(Boolean);
|
||||
const hyp = hypothesis.trim().split(/\s+/).filter(Boolean);
|
||||
let previous = Array.from({ length: hyp.length + 1 }, (_, j) => j);
|
||||
for (let i = 1; i <= ref.length; i++) {
|
||||
const current = new Array<number>(hyp.length + 1);
|
||||
current[0] = i;
|
||||
for (let j = 1; j <= hyp.length; j++) {
|
||||
current[j] = Math.min(
|
||||
previous[j]! + 1,
|
||||
current[j - 1]! + 1,
|
||||
previous[j - 1]! + (ref[i - 1] === hyp[j - 1] ? 0 : 1),
|
||||
);
|
||||
}
|
||||
previous = current;
|
||||
}
|
||||
return previous[hyp.length]! / Math.max(1, ref.length);
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
import { describe, test, expect } from "bun:test";
|
||||
import { StreamingResampler } from "../src/lib/audio";
|
||||
import {
|
||||
LIVE_TUNING,
|
||||
LiveTranscriber,
|
||||
type LiveTranscribeFn,
|
||||
type LiveTranscribeResult,
|
||||
type LiveUpdate,
|
||||
} from "../src/lib/live";
|
||||
import {
|
||||
GAP_SECONDS,
|
||||
ORACLE_WORDS,
|
||||
RATE,
|
||||
WORD_SECONDS,
|
||||
buildSession,
|
||||
concatAudio,
|
||||
oracleDecode,
|
||||
silence,
|
||||
sine,
|
||||
wordErrorRate,
|
||||
wordFreq,
|
||||
} from "./dsp";
|
||||
|
||||
// End-to-end accuracy of the live pipeline (segmenter + scheduler + mic-path
|
||||
// resampler) measured as WER, using the deterministic tone-word oracle from
|
||||
// tests/dsp.ts instead of a model. Runs with the production LIVE_TUNING so
|
||||
// what we measure is what ships. See tests/live.md for what still needs a
|
||||
// real browser + real Whisper.
|
||||
|
||||
function tick(): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
interface SessionRun {
|
||||
result: LiveTranscribeResult;
|
||||
updates: LiveUpdate[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream `audio` through a LiveTranscriber in mic-sized chunks. The injected
|
||||
* clock advances with the audio fed, so the tentative-pass throttling behaves
|
||||
* exactly as it would in real time.
|
||||
*/
|
||||
async function runLiveSession(
|
||||
audio: Float32Array,
|
||||
transcribe: LiveTranscribeFn,
|
||||
chunkSeconds = 0.1,
|
||||
): Promise<SessionRun> {
|
||||
let fedSamples = 0;
|
||||
const updates: LiveUpdate[] = [];
|
||||
const live = new LiveTranscriber({
|
||||
...LIVE_TUNING,
|
||||
transcribe,
|
||||
onUpdate: (update) => updates.push(update),
|
||||
now: () => fedSamples / (RATE / 1000),
|
||||
});
|
||||
const chunk = Math.round(chunkSeconds * RATE);
|
||||
for (let i = 0; i < audio.length; i += chunk) {
|
||||
live.push(audio.slice(i, Math.min(i + chunk, audio.length)));
|
||||
fedSamples = Math.min(i + chunk, audio.length);
|
||||
await tick(); // let in-flight passes resolve, as they would in real time
|
||||
}
|
||||
const result = await live.stop();
|
||||
return { result, updates };
|
||||
}
|
||||
|
||||
const oracleEngine: LiveTranscribeFn = async (audio) => oracleDecode(audio);
|
||||
|
||||
describe("live pipeline accuracy (oracle engine, production tuning)", () => {
|
||||
test("dictation with pauses: zero WER and accurate absolute timestamps", async () => {
|
||||
const session = buildSession(
|
||||
[
|
||||
["alpha", "bravo", "charlie"],
|
||||
["delta", "echo", "foxtrot"],
|
||||
["golf", "hotel", "alpha", "bravo"],
|
||||
],
|
||||
{ pauseSeconds: LIVE_TUNING.segmenter.commitSilenceSeconds + 0.6 },
|
||||
);
|
||||
|
||||
const { result, updates } = await runLiveSession(session.audio, oracleEngine);
|
||||
|
||||
expect(wordErrorRate(session.reference, result.text)).toBe(0);
|
||||
|
||||
// Tentative text must have been visible at some point mid-utterance.
|
||||
expect(updates.some((u) => u.tentativeText.trim().length > 0)).toBe(true);
|
||||
|
||||
// One chunk per word, timestamped in absolute session time: the oracle
|
||||
// reports times relative to each segment's audio and the transcriber
|
||||
// offsets by the segment's session position — quantization is ~20 ms
|
||||
// (oracle frames) + 32 ms (segmenter frames).
|
||||
expect(result.chunks).toHaveLength(session.truth.length);
|
||||
result.chunks.forEach((chunk, i) => {
|
||||
expect(chunk.text.trim()).toBe(session.truth[i]!.word);
|
||||
expect(Math.abs(chunk.timestamp[0] - session.truth[i]!.start)).toBeLessThan(0.15);
|
||||
});
|
||||
});
|
||||
|
||||
test("continuous speech across the max-length cap: soft cuts keep every word intact", async () => {
|
||||
// ~40 s with only 80 ms inter-word gaps — silence never reaches the
|
||||
// 1.2 s commit threshold, so the session *must* cut at the 29 s cap at
|
||||
// least once. The soft cut has to land in a gap, not inside a word.
|
||||
//
|
||||
// Stretching the first word sweeps where the cap falls relative to the
|
||||
// word/gap cycle (segments start at speech onset, so a longer lead-in
|
||||
// would shift onset and cap together and test nothing). A hard cutter
|
||||
// can get lucky on one phase — the cap lands in a gap — but not on all
|
||||
// five: word+gap = 0.33 s, and the offsets cover the whole cycle.
|
||||
const words: string[] = [];
|
||||
const total = Math.ceil(40 / (WORD_SECONDS + GAP_SECONDS));
|
||||
for (let i = 0; i < total; i++) words.push(ORACLE_WORDS[i % ORACLE_WORDS.length]!);
|
||||
|
||||
for (let phase = 0; phase < 5; phase++) {
|
||||
const session = buildSession([words], {
|
||||
firstWordSeconds:
|
||||
WORD_SECONDS + phase * ((WORD_SECONDS + GAP_SECONDS) / 5),
|
||||
});
|
||||
expect(session.audio.length).toBeGreaterThan(
|
||||
LIVE_TUNING.segmenter.maxSegmentSeconds * RATE,
|
||||
);
|
||||
|
||||
const { result } = await runLiveSession(session.audio, oracleEngine, 0.5);
|
||||
|
||||
// A mid-word cut shows up as an insertion (two fragments) or a
|
||||
// deletion (both halves too short) — either way WER > 0.
|
||||
expect(wordErrorRate(session.reference, result.text)).toBe(0);
|
||||
expect(result.chunks).toHaveLength(words.length);
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
test("48 kHz mic path: StreamingResampler preserves accuracy end to end", async () => {
|
||||
// Same dictation, but generated at 48 kHz and resampled in ~100 ms chunks
|
||||
// exactly like src/lib/mic.ts does. Aliasing, pitch shift, or dropped
|
||||
// samples at chunk boundaries would decode as wrong or missing words.
|
||||
const session = buildSession(
|
||||
[
|
||||
["hotel", "golf", "foxtrot"],
|
||||
["echo", "delta", "charlie", "bravo"],
|
||||
],
|
||||
{ rate: 48_000, pauseSeconds: LIVE_TUNING.segmenter.commitSilenceSeconds + 0.6 },
|
||||
);
|
||||
|
||||
const resampler = new StreamingResampler(48_000, RATE);
|
||||
let fedSamples = 0;
|
||||
const live = new LiveTranscriber({
|
||||
...LIVE_TUNING,
|
||||
transcribe: oracleEngine,
|
||||
onUpdate: () => {},
|
||||
now: () => fedSamples / (RATE / 1000),
|
||||
});
|
||||
const chunk = 4800; // 100 ms at the native rate
|
||||
for (let i = 0; i < session.audio.length; i += chunk) {
|
||||
const out = resampler.process(
|
||||
session.audio.slice(i, Math.min(i + chunk, session.audio.length)),
|
||||
);
|
||||
if (out.length > 0) {
|
||||
live.push(out);
|
||||
fedSamples += out.length;
|
||||
}
|
||||
await tick();
|
||||
}
|
||||
const tail = resampler.flush();
|
||||
if (tail.length > 0) live.push(tail);
|
||||
const result = await live.stop();
|
||||
|
||||
expect(wordErrorRate(session.reference, result.text)).toBe(0);
|
||||
});
|
||||
|
||||
test("hallucinated text over a near-silent blip is dropped and cleared", async () => {
|
||||
// Whisper's classic failure mode: fed near-silence, it emits "Thank you."
|
||||
// The energy gate must keep that audio from ever reaching the committed
|
||||
// transcript, and the tentative flash of it must be cleared on drop.
|
||||
const hallucinating: LiveTranscribeFn = async () => ({
|
||||
text: " Thank you.",
|
||||
chunks: [],
|
||||
});
|
||||
const audio = concatAudio([
|
||||
silence(0.5),
|
||||
sine(wordFreq(0), 0.12), // 120 ms blip < minSpeechSeconds 0.25
|
||||
silence(2.5),
|
||||
]);
|
||||
|
||||
const { result, updates } = await runLiveSession(audio, hallucinating);
|
||||
|
||||
// The blip opened a segment ≥ minTentativeSeconds, so a tentative pass
|
||||
// really ran and "Thank you." was briefly tentative…
|
||||
expect(updates.some((u) => u.tentativeText === " Thank you.")).toBe(true);
|
||||
// …but the drop cleared it, and nothing was ever committed.
|
||||
expect(updates[updates.length - 1]!.tentativeText).toBe("");
|
||||
expect(result.text).toBe("");
|
||||
expect(result.chunks).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("stale tentative text from a dropped segment never leaks into the next commit", async () => {
|
||||
// Manual-resolution engine so the ordering is exact.
|
||||
const calls: {
|
||||
resolve: (result: LiveTranscribeResult) => void;
|
||||
}[] = [];
|
||||
const updates: LiveUpdate[] = [];
|
||||
let now = 0;
|
||||
const live = new LiveTranscriber({
|
||||
...LIVE_TUNING,
|
||||
transcribe: (audio) =>
|
||||
new Promise<LiveTranscribeResult>((resolve) => {
|
||||
void audio;
|
||||
calls.push({ resolve });
|
||||
}),
|
||||
onUpdate: (update) => updates.push(update),
|
||||
now: () => now,
|
||||
});
|
||||
const last = () => updates[updates.length - 1]!;
|
||||
|
||||
// A breathy blip keeps a segment open long enough for a tentative pass…
|
||||
live.push(silence(0.3));
|
||||
live.push(sine(wordFreq(0), 0.15));
|
||||
now += 450;
|
||||
live.push(silence(1.0));
|
||||
now += 1000;
|
||||
expect(calls).toHaveLength(1); // tentative pass over mostly-silence audio
|
||||
calls[0]!.resolve({ text: " Thank you.", chunks: [] });
|
||||
await tick();
|
||||
expect(last().tentativeText).toBe(" Thank you.");
|
||||
|
||||
// …then the segment is dropped as near-silence (0.15 s < 0.25 s speech).
|
||||
live.push(silence(0.5));
|
||||
now += 500;
|
||||
expect(last().tentativeText).toBe("");
|
||||
expect(last().committedText).toBe("");
|
||||
|
||||
// Real speech commits next: it must NOT adopt "Thank you." as its
|
||||
// provisional text while waiting for its final pass.
|
||||
live.push(sine(wordFreq(1), 0.6));
|
||||
now += 600;
|
||||
live.push(silence(1.5));
|
||||
now += 1500;
|
||||
expect(last().pendingFinalPasses).toBe(1);
|
||||
expect(last().committedText).toBe(""); // provisional text is empty, not stale
|
||||
|
||||
calls[1]!.resolve({ text: " bravo", chunks: [] });
|
||||
const result = await live.stop();
|
||||
expect(result.text).toBe("bravo");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,217 @@
|
||||
import { describe, test, expect } from "bun:test";
|
||||
import { existsSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { env, pipeline } from "@huggingface/transformers";
|
||||
import { StreamingResampler } from "../src/lib/audio";
|
||||
import {
|
||||
LIVE_TUNING,
|
||||
LiveTranscriber,
|
||||
type LiveTranscribeFn,
|
||||
} from "../src/lib/live";
|
||||
import { resolveDefaultDtype } from "../src/lib/models";
|
||||
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) →
|
||||
* LiveTranscriber with production LIVE_TUNING → the same Transformers.js
|
||||
* pipeline options the inference worker sends (src/worker/inference.worker.ts).
|
||||
*
|
||||
* 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
|
||||
*/
|
||||
|
||||
const MODEL_ID = "onnx-community/whisper-large-v3-turbo";
|
||||
const MIC_RATE = 48_000;
|
||||
const RATE = 16_000;
|
||||
|
||||
/**
|
||||
* WER normalization: casing and punctuation 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(/\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. */
|
||||
async function modelAvailable(id: string): Promise<boolean> {
|
||||
if (existsSync(join(env.cacheDir, id))) return true;
|
||||
try {
|
||||
const response = await fetch(
|
||||
`https://huggingface.co/${id}/resolve/main/config.json`,
|
||||
{ method: "HEAD", signal: AbortSignal.timeout(5_000) },
|
||||
);
|
||||
return response.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
const asr = await pipeline("automatic-speech-recognition", MODEL_ID, {
|
||||
dtype: resolveDefaultDtype(MODEL_ID, "wasm"),
|
||||
});
|
||||
try {
|
||||
const transcribe: LiveTranscribeFn = async (audio) => {
|
||||
// Mirrors src/worker/inference.worker.ts `transcribe` options with
|
||||
// the UI set to Danish.
|
||||
const output = await asr(audio, {
|
||||
chunk_length_s: 30,
|
||||
stride_length_s: 5,
|
||||
return_timestamps: true,
|
||||
language: "da",
|
||||
task: "transcribe",
|
||||
});
|
||||
const single = (Array.isArray(output) ? output[0] : output) as {
|
||||
text?: string;
|
||||
chunks?: { text: string; timestamp: [number, number | null] }[];
|
||||
};
|
||||
return { text: single.text ?? "", chunks: single.chunks ?? [] };
|
||||
};
|
||||
|
||||
// Resample in ~100 ms mic-sized blocks exactly like src/lib/mic.ts.
|
||||
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) {
|
||||
const out = resampler.process(
|
||||
wav.samples.slice(i, Math.min(i + chunk, wav.samples.length)),
|
||||
);
|
||||
if (out.length > 0) blocks.push(out);
|
||||
}
|
||||
const tail = resampler.flush();
|
||||
if (tail.length > 0) blocks.push(tail);
|
||||
const totalSamples = blocks.reduce((n, b) => n + b.length, 0);
|
||||
const durationSeconds = totalSamples / RATE;
|
||||
|
||||
// Whole-clip baseline: what the file-upload flow would produce from
|
||||
// the same audio and model. The live pipeline is then held to it.
|
||||
const whole = new Float32Array(totalSamples);
|
||||
let at = 0;
|
||||
for (const block of blocks) {
|
||||
whole.set(block, at);
|
||||
at += block.length;
|
||||
}
|
||||
const fileWer = wordErrorRate(
|
||||
normalize(reference),
|
||||
normalize((await transcribe(whole)).text),
|
||||
);
|
||||
|
||||
// The live session, with the injected clock advancing with the audio
|
||||
// fed so pass scheduling behaves as in real time.
|
||||
let fedSamples = 0;
|
||||
let sawTentative = false;
|
||||
const live = new LiveTranscriber({
|
||||
...LIVE_TUNING,
|
||||
transcribe,
|
||||
onUpdate: (update) => {
|
||||
if (update.tentativeText.trim().length > 0) sawTentative = true;
|
||||
},
|
||||
now: () => fedSamples / (RATE / 1000),
|
||||
});
|
||||
for (const block of blocks) {
|
||||
live.push(block);
|
||||
fedSamples += block.length;
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
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);
|
||||
// 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);
|
||||
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(last.timestamp[1] ?? last.timestamp[0]).toBeGreaterThan(
|
||||
durationSeconds - 5,
|
||||
);
|
||||
expect(last.timestamp[1] ?? last.timestamp[0]).toBeLessThanOrEqual(
|
||||
durationSeconds + 1,
|
||||
);
|
||||
} finally {
|
||||
await asr.dispose();
|
||||
}
|
||||
},
|
||||
900_000, // first run downloads ~600 MB of weights
|
||||
);
|
||||
});
|
||||
+26
-7
@@ -1,8 +1,23 @@
|
||||
# Manual live-transcription verification (M7, D-016)
|
||||
|
||||
The segment loop (`src/lib/live.ts`) and the protocol changes are covered by
|
||||
unit tests; microphone capture, the AudioWorklet path, and end-to-end live
|
||||
quality can only be exercised in a real browser. This is that procedure.
|
||||
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,
|
||||
the AudioWorklet path, and WebGPU/WASM execution in an actual browser can
|
||||
only be exercised manually. This is that procedure.
|
||||
|
||||
Known model behavior (not a pipeline bug): with language **auto**, Whisper
|
||||
misdetects the Festuge clip as English and returns a rough English
|
||||
translation — in whole-file mode as well as live. Danish (and likely other
|
||||
non-English) users should pick their language explicitly; the worker forwards
|
||||
it and accuracy is then as tested above.
|
||||
Re-run it after any change to `src/lib/live.ts`, `src/lib/mic.ts`,
|
||||
`src/components/LiveControls.tsx`, or the live wiring in `src/App.tsx`.
|
||||
|
||||
@@ -57,11 +72,15 @@ Re-run it after any change to `src/lib/live.ts`, `src/lib/mic.ts`,
|
||||
|
||||
## 4. Quality/latency knobs (only if needed)
|
||||
|
||||
If commits are too eager/lazy in real rooms, tune `LiveSegmenterOptions`
|
||||
(`speechRms` gate 0.015, `commitSilenceSeconds` 0.75, `maxSegmentSeconds` 25,
|
||||
`minSpeechSeconds` 0.25) and the `LiveTranscriber` throttle
|
||||
(`tentativeIntervalMs` 1500) — all injected from `src/App.tsx` defaults, all
|
||||
unit-tested in `tests/live.test.ts`. Record any tuned values in D-016.
|
||||
Production tuning lives in `LIVE_TUNING` (`src/lib/live.ts`) and is shared by
|
||||
`src/App.tsx` and the accuracy harness (`tests/live-accuracy.test.ts`), so a
|
||||
knob change is automatically re-validated for WER under `bun test`. If commits
|
||||
are too eager/lazy in real rooms, tune `LIVE_TUNING.segmenter`
|
||||
(`commitSilenceSeconds` 1.2, `maxSegmentSeconds` 29) or the
|
||||
`LiveSegmenterOptions` defaults (`speechRms` gate 0.015, `sustainRmsRatio`
|
||||
0.5, `softCutWindowSeconds` 3, `minSpeechSeconds` 0.25) and the throttle
|
||||
(`tentativeIntervalMs` 2000, `minTentativeSeconds` 1). Record any tuned
|
||||
values in D-016.
|
||||
|
||||
## 5. Offline live transcription
|
||||
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { describe, test, expect } from "bun:test";
|
||||
import { StreamingResampler, resampleLinear } from "../src/lib/audio";
|
||||
import { goertzelPower, sine } from "./dsp";
|
||||
|
||||
// Signal-quality tests for the mic-path resampler (D-016). Whisper accuracy
|
||||
// depends on the 48 kHz → 16 kHz decimation NOT folding ultrasonic/fricative
|
||||
// energy into the speech band, which is exactly what a plain linear resampler
|
||||
// does — these tests pin the anti-aliasing behavior down numerically.
|
||||
|
||||
function rmsOf(samples: Float32Array, trim = 0): number {
|
||||
let sum = 0;
|
||||
let count = 0;
|
||||
for (let i = trim; i < samples.length - trim; i++) {
|
||||
sum += samples[i]! * samples[i]!;
|
||||
count++;
|
||||
}
|
||||
return Math.sqrt(sum / Math.max(1, count));
|
||||
}
|
||||
|
||||
/** Run a whole signal through in one call + flush. */
|
||||
function resampleWhole(input: Float32Array, from: number, to: number): Float32Array {
|
||||
const resampler = new StreamingResampler(from, to);
|
||||
const head = resampler.process(input);
|
||||
const tail = resampler.flush();
|
||||
const out = new Float32Array(head.length + tail.length);
|
||||
out.set(head, 0);
|
||||
out.set(tail, head.length);
|
||||
return out;
|
||||
}
|
||||
|
||||
describe("StreamingResampler", () => {
|
||||
test("passthrough at equal rates returns the input unchanged", () => {
|
||||
const input = sine(440, 0.1, 16_000);
|
||||
const resampler = new StreamingResampler(16_000, 16_000);
|
||||
expect(resampler.process(input)).toBe(input);
|
||||
expect(resampler.flush().length).toBe(0);
|
||||
});
|
||||
|
||||
test("output length matches the rate ratio", () => {
|
||||
const out = resampleWhole(sine(1000, 1, 48_000), 48_000, 16_000);
|
||||
expect(Math.abs(out.length - 16_000)).toBeLessThanOrEqual(2);
|
||||
});
|
||||
|
||||
test("passband: a 1 kHz tone survives 48 kHz → 16 kHz at unity gain and pitch", () => {
|
||||
const input = sine(1000, 1, 48_000);
|
||||
const out = resampleWhole(input, 48_000, 16_000);
|
||||
// Amplitude preserved (edges trimmed: half a kernel of warm-up).
|
||||
expect(rmsOf(out, 100)).toBeCloseTo(rmsOf(input, 300), 2);
|
||||
// Pitch preserved: energy sits at 1 kHz, not at any shifted frequency.
|
||||
const at1k = goertzelPower(out, 200, out.length - 200, 1000, 16_000);
|
||||
const at1k2 = goertzelPower(out, 200, out.length - 200, 1200, 16_000);
|
||||
expect(at1k).toBeGreaterThan(100 * at1k2);
|
||||
});
|
||||
|
||||
test("stopband: energy above the target Nyquist is rejected, not aliased", () => {
|
||||
// 11 kHz is inaudible to a 16 kHz stream — it must vanish. Linear
|
||||
// resampling folds it to 5 kHz (16 − 11) at nearly full amplitude,
|
||||
// polluting the band Whisper reads. Require > 34 dB rejection.
|
||||
const input = sine(11_000, 0.5, 48_000);
|
||||
const out = resampleWhole(input, 48_000, 16_000);
|
||||
expect(rmsOf(out, 100)).toBeLessThan(0.02 * rmsOf(input));
|
||||
|
||||
// Document the motivation: the old path really did alias.
|
||||
const linear = resampleLinear(input, 48_000, 16_000);
|
||||
expect(rmsOf(linear)).toBeGreaterThan(0.1 * rmsOf(input));
|
||||
});
|
||||
|
||||
test("chunked processing is equivalent to whole-signal processing", () => {
|
||||
// Mixed deterministic signal, awkward chunk size, non-integer ratio.
|
||||
const input = new Float32Array(44_100);
|
||||
for (let i = 0; i < input.length; i++) {
|
||||
input[i] =
|
||||
0.3 * Math.sin((2 * Math.PI * 700 * i) / 44_100) +
|
||||
0.2 * Math.sin((2 * Math.PI * 3100 * i) / 44_100 + 1.3);
|
||||
}
|
||||
const whole = resampleWhole(input, 44_100, 16_000);
|
||||
|
||||
const resampler = new StreamingResampler(44_100, 16_000);
|
||||
const pieces: Float32Array[] = [];
|
||||
for (let i = 0; i < input.length; i += 333) {
|
||||
pieces.push(resampler.process(input.slice(i, Math.min(i + 333, input.length))));
|
||||
}
|
||||
pieces.push(resampler.flush());
|
||||
const chunked = new Float32Array(pieces.reduce((n, p) => n + p.length, 0));
|
||||
let offset = 0;
|
||||
for (const piece of pieces) {
|
||||
chunked.set(piece, offset);
|
||||
offset += piece.length;
|
||||
}
|
||||
|
||||
expect(chunked.length).toBe(whole.length);
|
||||
for (let i = 0; i < whole.length; i++) {
|
||||
if (Math.abs(chunked[i]! - whole[i]!) > 1e-6) {
|
||||
throw new Error(
|
||||
`chunked/whole mismatch at ${i}: ${chunked[i]} vs ${whole[i]}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("upsampling 8 kHz → 16 kHz preserves the signal", () => {
|
||||
const input = sine(1000, 0.5, 8_000);
|
||||
const out = resampleWhole(input, 8_000, 16_000);
|
||||
expect(Math.abs(out.length - 8_000)).toBeLessThanOrEqual(2);
|
||||
expect(rmsOf(out, 100)).toBeCloseTo(rmsOf(input, 50), 2);
|
||||
const at1k = goertzelPower(out, 100, out.length - 100, 1000, 16_000);
|
||||
const at1k5 = goertzelPower(out, 100, out.length - 100, 1500, 16_000);
|
||||
expect(at1k).toBeGreaterThan(100 * at1k5);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user