Merge feat/parakeet-engine: Parakeet TDT 0.6B v3 English engine (spec 001 workstream 7)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

# Conflicts:
#	AGENTS.md
#	bun.lock
#	docs/Decisions.md
#	src/lib/models.ts
#	src/worker/inference.worker.ts
#	src/worker/protocol.ts
#	tests/models.test.ts
This commit is contained in:
Lucas Winther committed 2026-10-30 01:46:51 +01:00
commit 30969e86b7
29 files changed
+1741 -54

No files matched your search

+31 -1
View File
@@ -1,5 +1,11 @@
import { describe, test, expect } from "bun:test";
import { isLikelyMobile, shouldWarnBeforeLoad } from "../src/lib/mobileMemoryGuard";
import {
isLikelyMobile,
isModelAllowedOnDevice,
modelsForDevice,
shouldWarnBeforeLoad,
} from "../src/lib/mobileMemoryGuard";
import { MODELS, PARAKEET_MODEL_ID } from "../src/lib/models";
// Pure UA/heuristic logic (D-027) — no browser, no navigator.
@@ -99,3 +105,27 @@ describe("shouldWarnBeforeLoad", () => {
).toBe(false);
});
});
describe("desktop-only models (D-WS7a)", () => {
const parakeet = MODELS.find((model) => model.id === PARAKEET_MODEL_ID)!;
const phone = { userAgent: ANDROID_UA, maxTouchPoints: 5, deviceMemoryGB: 8 };
const ipad = { userAgent: IPAD_DESKTOP_UA, maxTouchPoints: 5, deviceMemoryGB: undefined };
const desktop = { userAgent: DESKTOP_WINDOWS_UA, maxTouchPoints: 0, deviceMemoryGB: 8 };
test("Parakeet is never offered on phones or tablets, even high-memory ones", () => {
expect(isModelAllowedOnDevice(parakeet, phone)).toBe(false);
expect(isModelAllowedOnDevice(parakeet, ipad)).toBe(false);
expect(isModelAllowedOnDevice(parakeet, desktop)).toBe(true);
});
test("Whisper models stay allowed everywhere (they get the D-027 confirm instead)", () => {
for (const model of MODELS.filter((m) => m.id !== PARAKEET_MODEL_ID)) {
expect(isModelAllowedOnDevice(model, phone)).toBe(true);
}
});
test("modelsForDevice filters the list, keeping order", () => {
expect(modelsForDevice(MODELS, phone)).toEqual(MODELS.filter((m) => m !== parakeet));
expect(modelsForDevice(MODELS, desktop)).toEqual([...MODELS]);
});
});
+121
View File
@@ -0,0 +1,121 @@
import { describe, expect, test } from "bun:test";
import { fetchModelFile, type ModelFileCacheStorage } from "../src/worker/modelFiles";
import type { LoadProgressMessage } from "../src/worker/protocol";
// Cache-API-backed weight download for the Parakeet path (D-WS7a, D-005).
const URL_A = "https://huggingface.co/org/model/resolve/rev/encoder.onnx";
function fakeCaches(initial: Record<string, Uint8Array> = {}) {
const stored = new Map<string, Uint8Array>(Object.entries(initial));
const opened: string[] = [];
const storage: ModelFileCacheStorage = {
async open(name) {
opened.push(name);
return {
async match(url) {
const bytes = stored.get(url);
return bytes ? new Response(bytes as Uint8Array<ArrayBuffer>) : undefined;
},
async put(url, response) {
stored.set(url, new Uint8Array(await response.arrayBuffer()));
},
};
},
};
return { storage, stored, opened };
}
function streamingFetch(bytes: Uint8Array, pieces: number, status = 200) {
const calls: string[] = [];
const fetchFn = async (url: string) => {
calls.push(url);
if (status !== 200) return new Response("nope", { status });
const size = Math.ceil(bytes.length / pieces);
const body = new ReadableStream<Uint8Array>({
start(controller) {
for (let i = 0; i < bytes.length; i += size) controller.enqueue(bytes.slice(i, i + size));
controller.close();
},
});
return new Response(body, { headers: { "Content-Length": String(bytes.length) } });
};
return { fetchFn, calls };
}
describe("fetchModelFile", () => {
test("a cached file is returned without touching the network", async () => {
const cached = new Uint8Array([1, 2, 3]);
const { storage, opened } = fakeCaches({ [URL_A]: cached });
const { fetchFn, calls } = streamingFetch(new Uint8Array(), 1);
const events: LoadProgressMessage[] = [];
const bytes = await fetchModelFile(URL_A, "encoder.onnx", {
caches: storage,
fetch: fetchFn,
onProgress: (event) => events.push(event),
});
expect([...bytes]).toEqual([1, 2, 3]);
expect(calls).toEqual([]);
expect(opened).toEqual(["transformers-cache"]);
expect(events.map((event) => event.status)).toEqual(["initiate", "done"]);
});
test("a miss downloads with byte progress and stores the complete file", async () => {
const payload = new Uint8Array(1000).map((_, i) => i % 251);
const { storage, stored } = fakeCaches();
const { fetchFn, calls } = streamingFetch(payload, 4);
const events: LoadProgressMessage[] = [];
const bytes = await fetchModelFile(URL_A, "encoder.onnx", {
caches: storage,
fetch: fetchFn,
onProgress: (event) => events.push(event),
});
expect(calls).toEqual([URL_A]);
expect([...bytes]).toEqual([...payload]);
expect([...stored.get(URL_A)!]).toEqual([...payload]);
const progress = events.filter((event) => event.status === "progress");
expect(progress.length).toBe(4);
const last = progress[progress.length - 1]!;
expect(last).toMatchObject({ file: "encoder.onnx", loaded: 1000, total: 1000, progress: 100 });
expect(events[0]!.status).toBe("initiate");
expect(events[events.length - 1]!.status).toBe("done");
});
test("an HTTP error throws a plain message and stores nothing", async () => {
const { storage, stored } = fakeCaches();
const { fetchFn } = streamingFetch(new Uint8Array(), 1, 404);
await expect(
fetchModelFile(URL_A, "encoder.onnx", { caches: storage, fetch: fetchFn, onProgress: () => {} }),
).rejects.toThrow("Could not download encoder.onnx (HTTP 404)");
expect(stored.size).toBe(0);
});
test("an interrupted download stores nothing (no false 'available offline')", async () => {
const { storage, stored } = fakeCaches();
const fetchFn = async () =>
new Response(
new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new Uint8Array(10));
controller.error(new Error("network down"));
},
}),
{ headers: { "Content-Length": "100" } },
);
await expect(
fetchModelFile(URL_A, "encoder.onnx", { caches: storage, fetch: fetchFn, onProgress: () => {} }),
).rejects.toThrow();
expect(stored.size).toBe(0);
});
test("without Cache Storage it still downloads", async () => {
const { fetchFn, calls } = streamingFetch(new Uint8Array([9]), 1);
const bytes = await fetchModelFile(URL_A, "encoder.onnx", {
caches: undefined,
fetch: fetchFn,
onProgress: () => {},
});
expect([...bytes]).toEqual([9]);
expect(calls).toEqual([URL_A]);
});
});
+48 -3
View File
@@ -10,6 +10,8 @@ import {
formatModelSize,
getFasterWhisperModel,
getModel,
modelFamily,
PARAKEET_MODEL_ID,
resolveDefaultDtype,
taskForModel,
} from "../src/lib/models";
@@ -21,6 +23,8 @@ function stripRepoDecorations(id: string): string {
return id.replace(/(_timestamped|-ONNX)$/, "");
}
const WHISPER_MODELS = MODELS.filter((model) => modelFamily(model) === "whisper");
describe("MODELS catalog", () => {
test("contains the curated D-006 lineup from the onnx-community org", () => {
const ids = MODELS.map((model) => model.id);
@@ -35,12 +39,15 @@ describe("MODELS catalog", () => {
"onnx-community/whisper-medium-ONNX",
"onnx-community/whisper-large-v3-turbo",
`${DANISH_MODELS_HF_OWNER}/whisper-small-danish-ONNX`,
// Not Whisper: the D-WS7a Parakeet engine path, last so it never
// becomes anyone's accidental default.
"ysdede/parakeet-tdt-0.6b-v3-onnx",
]);
expect(new Set(ids).size).toBe(ids.length);
});
test(".en variants are English-only; the rest are multilingual", () => {
for (const model of MODELS) {
for (const model of WHISPER_MODELS) {
// Hub repo names may carry decorations (e.g. "_timestamped", "-ONNX")
// after the size suffix — judge English-ness on the undecorated name.
expect(model.multilingual).toBe(!stripRepoDecorations(model.id).endsWith(".en"));
@@ -72,10 +79,10 @@ describe("MODELS catalog", () => {
expect(getModel("onnx-community/whisper-nonexistent")).toBeUndefined();
});
test("every model maps to a faster-whisper name for the server engine (D-013)", () => {
test("every Whisper model maps to a faster-whisper name for the server engine (D-013)", () => {
// The Danish conversions (D-WS6a) have no faster-whisper alias; their
// server-engine name is a Hub repo id — covered by the Danish suite below.
for (const model of MODELS.filter((m) => !m.id.startsWith(`${DANISH_MODELS_HF_OWNER}/`))) {
for (const model of WHISPER_MODELS.filter((m) => !m.id.startsWith(`${DANISH_MODELS_HF_OWNER}/`))) {
// The onnx-community ids and faster-whisper's aliases share the same
// size suffix ("tiny.en", …, "large-v3-turbo") once repo-name
// decorations ("_timestamped", "-ONNX") are stripped; keeping them
@@ -298,3 +305,41 @@ describe("taskForModel (D-WS4a)", () => {
expect(taskForModel("someone/unknown", "translate")).toBe("transcribe");
});
});
describe("Parakeet catalog entry (D-WS7a)", () => {
const parakeet = getModel(PARAKEET_MODEL_ID)!;
test("family defaults to whisper when absent", () => {
expect(modelFamily(getModel(DEFAULT_MODEL_ID))).toBe("whisper");
expect(modelFamily(undefined)).toBe("whisper");
expect(modelFamily(parakeet)).toBe("parakeet");
});
test("is English-only and browser-only (no faster-whisper name)", () => {
expect(parakeet.multilingual).toBe(false);
expect(parakeet.fasterWhisperModel).toBeUndefined();
expect(getFasterWhisperModel(PARAKEET_MODEL_ID)).toBeUndefined();
});
test("WASM runs int8 for both parts; WebGPU is fp16 encoder + int8 decoder", () => {
expect(resolveDefaultDtype(PARAKEET_MODEL_ID, "wasm")).toBe("q8");
expect(resolveDefaultDtype(PARAKEET_MODEL_ID, "webgpu")).toEqual({
encoder_model: "fp16",
decoder_model_merged: "q8",
});
});
test("download size is the real file sizes, not a parameter estimate", () => {
expect(estimateDefaultDownloadBytes(parakeet, "wasm")).toBe(
652_183_999 + 18_202_004 + 102_132,
);
expect(estimateDefaultDownloadBytes(parakeet, "webgpu")).toBe(
1_238_960_452 + 18_202_004 + 102_132,
);
expect(formatModelSize(estimateDefaultDownloadBytes(parakeet, "wasm"))).toBe("670 MB");
});
test("peak load memory is twice the larger weight file", () => {
expect(estimatePeakLoadBytes(parakeet, "q8")).toBe(2 * 652_183_999);
});
});
+157
View File
@@ -0,0 +1,157 @@
import { afterAll, describe, expect, test } from "bun:test";
import { existsSync, mkdirSync } from "node:fs";
import { join } from "node:path";
import ffmpegPath from "ffmpeg-static";
import * as ort from "onnxruntime-web";
import { LIVE_TUNING, LiveTranscriber, type LiveTranscribeFn } from "../src/lib/live";
import { PARAKEET_MODEL_ID, resolveDefaultDtype } from "../src/lib/models";
import { PARAKEET_VOCAB_FILE, parakeetFileName, parakeetFileUrl } from "../src/lib/parakeet";
import {
createParakeetEngine,
parakeetSessionOptions,
type OrtLike,
type ParakeetEngine,
} from "../src/worker/parakeetEngine";
import { wordErrorRate } from "./dsp";
/**
* Real-model accuracy of the Parakeet engine path (D-WS7a), opt-in:
*
* PARAKEET_REAL=1 bun test tests/parakeet-real-model.test.ts
*
* Runs the worker's own `createParakeetEngine` on onnxruntime-web's WASM
* backend (the browser engine's baseline runtime, here under Bun) with the
* catalog's WASM int8 weights, over the English clips in tests/audio_files:
* whole-file mode, and live mode through the production LiveTranscriber.
* Held to the same bars as Whisper Large v3 Turbo in live-real-model.test.ts.
* First run downloads ~670 MB into node_modules/@huggingface/transformers/.cache/.
* Opt-in (not in `bun test` by default) so the default suite's runtime and
* network use don't change; run it when touching src/lib/parakeet.ts or
* src/worker/parakeetEngine.ts.
*/
const ENABLED = process.env.PARAKEET_REAL === "1";
const RATE = 16_000;
const AUDIO_DIR = join(import.meta.dir, "audio_files");
const MODEL_DIR = join(
import.meta.dir,
"../node_modules/@huggingface/transformers/.cache",
PARAKEET_MODEL_ID,
);
const CLIPS = [
{ base: "en_aiproblem", file: "en_aiproblem.m4a", maxWer: 0.25 },
{ base: "en_alien", file: "en_alien.opus", maxWer: 0.35 },
];
function normalize(text: string): string {
return text
.toLowerCase()
.replace(/['’]/g, "")
.replace(/[.,!?;:"“”‘‚«»()\-–—…]/g, " ")
.replace(/\s+/g, " ")
.trim();
}
async function decode16k(path: string): Promise<Float32Array> {
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, exitCode] = await Promise.all([new Response(proc.stdout).arrayBuffer(), proc.exited]);
if (exitCode !== 0) throw new Error(`ffmpeg failed on ${path}`);
return new Float32Array(bytes);
}
async function modelFile(file: string): Promise<Uint8Array> {
const path = join(MODEL_DIR, file);
if (!existsSync(path)) {
mkdirSync(MODEL_DIR, { recursive: true });
const response = await fetch(parakeetFileUrl(file));
if (!response.ok) throw new Error(`download ${file}: HTTP ${response.status}`);
await Bun.write(path, response);
}
return new Uint8Array(await Bun.file(path).arrayBuffer());
}
let enginePromise: Promise<ParakeetEngine> | null = null;
function loadEngine(): Promise<ParakeetEngine> {
enginePromise ??= (async () => {
ort.env.wasm.numThreads = 8;
const dtype = resolveDefaultDtype(PARAKEET_MODEL_ID, "wasm");
if (typeof dtype !== "string") throw new Error("expected one WASM dtype");
return createParakeetEngine(
ort as unknown as OrtLike,
{
encoder: await modelFile(parakeetFileName("encoder", dtype)),
decoder: await modelFile(parakeetFileName("decoder", dtype)),
vocab: new TextDecoder().decode(await modelFile(PARAKEET_VOCAB_FILE)),
},
parakeetSessionOptions("wasm"),
);
})();
return enginePromise;
}
afterAll(async () => {
if (enginePromise) await (await enginePromise).dispose();
});
describe("Parakeet engine accuracy (real model, onnxruntime-web WASM)", () => {
for (const clip of CLIPS) {
test.skipIf(!ENABLED)(
`${clip.base}: whole-file and live transcripts stay close to the reference`,
async () => {
const reference = await Bun.file(join(AUDIO_DIR, `${clip.base}.txt`)).text();
const audio = await decode16k(join(AUDIO_DIR, clip.file));
const duration = audio.length / RATE;
const engine = await loadEngine();
const fileStart = performance.now();
let lastProgress = 0;
const whole = await engine.transcribe(audio.slice(), {
onWindow: (_chunks, processed) => {
expect(processed).toBeGreaterThan(lastProgress);
lastProgress = processed;
},
});
const fileMs = performance.now() - fileStart;
const fileWer = wordErrorRate(normalize(reference), normalize(whole.text));
expect(lastProgress).toBeCloseTo(duration, 3);
expect(whole.chunks[0]!.timestamp[0]).toBeLessThan(duration / 2);
expect(whole.chunks.at(-1)!.timestamp[1]!).toBeGreaterThan(duration / 2);
expect(whole.chunks.at(-1)!.timestamp[1]!).toBeLessThanOrEqual(duration + 1);
// Live: production segmenter, clock advancing with the audio fed.
const transcribe: LiveTranscribeFn = (segment) => engine.transcribe(segment);
let fed = 0;
const liveStart = performance.now();
const live = new LiveTranscriber({
...LIVE_TUNING,
transcribe,
onUpdate: () => {},
now: () => fed / (RATE / 1000),
});
const block = RATE / 10;
for (let i = 0; i < audio.length; i += block) {
live.push(audio.slice(i, Math.min(i + block, audio.length)));
fed += Math.min(block, audio.length - i);
await new Promise((resolve) => setTimeout(resolve, 0));
}
const liveResult = await live.stop();
const liveMs = performance.now() - liveStart;
const liveWer = wordErrorRate(normalize(reference), normalize(liveResult.text));
console.info(
`[parakeet-real] ${clip.base}: ${duration.toFixed(1)} s audio — file WER ` +
`${fileWer.toFixed(3)} in ${(fileMs / 1000).toFixed(1)} s; live WER ` +
`${liveWer.toFixed(3)} in ${(liveMs / 1000).toFixed(1)} s`,
);
expect(fileWer).toBeLessThanOrEqual(clip.maxWer);
expect(liveWer).toBeLessThanOrEqual(clip.maxWer);
expect(liveWer).toBeLessThanOrEqual(fileWer + 0.1);
},
1_800_000,
);
}
});
+201
View File
@@ -0,0 +1,201 @@
import { describe, expect, test } from "bun:test";
import {
ParakeetVocabTokenizer,
parakeetFileName,
parakeetFileUrl,
parseVocab,
planAudioChunks,
planChunks,
transcribeWindows,
wordsToChunks,
type ParakeetWord,
} from "../src/lib/parakeet";
import { transcriptToSrt, transcriptToTimestampedTxt } from "../src/lib/export";
// Pure Parakeet engine logic (D-WS7a) — no model, no runtime.
const RATE = 16_000;
function word(text: string, start: number, end: number): ParakeetWord {
return { text, start_time: start, end_time: end };
}
function expectContiguous(windows: Array<[number, number]>, length: number) {
expect(windows[0]![0]).toBe(0);
expect(windows[windows.length - 1]![1]).toBe(length);
for (let i = 1; i < windows.length; i++) {
expect(windows[i]![0]).toBe(windows[i - 1]![1]);
}
}
describe("planChunks", () => {
const flat = () => 1;
test("audio up to the window length is a single window", () => {
expect(planChunks(10 * RATE, flat)).toEqual([[0, 10 * RATE]]);
expect(planChunks(30 * RATE, flat)).toEqual([[0, 30 * RATE]]);
expect(planChunks(0, flat)).toEqual([]);
});
test("windows never exceed 30 s, are contiguous, and cover everything", () => {
const length = 95 * RATE + 123;
const windows = planChunks(length, flat);
expectContiguous(windows, length);
for (const [start, end] of windows) {
expect(end - start).toBeLessThanOrEqual(30 * RATE);
expect(end).toBeGreaterThan(start);
}
});
test("a 90-minute file plans ≥ 180 bounded windows (no full-length copies needed)", () => {
const length = 90 * 60 * RATE;
const windows = planChunks(length, flat);
expect(windows.length).toBeGreaterThanOrEqual(180);
expectContiguous(windows, length);
for (const [start, end] of windows) expect(end - start).toBeLessThanOrEqual(30 * RATE);
});
test("cuts in the quietest 100 ms of the last 6 s before the limit", () => {
// Loud everywhere except a pause at 27.0–27.1 s.
const quietStart = 27 * RATE;
const energy = (start: number, end: number) =>
start >= quietStart && end <= quietStart + 0.1 * RATE ? 0 : 1;
const windows = planChunks(60 * RATE, energy);
const firstCut = windows[0]![1];
expect(firstCut).toBeGreaterThanOrEqual(quietStart);
expect(firstCut).toBeLessThanOrEqual(quietStart + 0.1 * RATE);
});
test("planAudioChunks finds a real silent gap in PCM", () => {
const audio = new Float32Array(50 * RATE);
for (let i = 0; i < audio.length; i++) audio[i] = Math.sin(i / 7) * 0.5;
audio.fill(0, 26 * RATE, 26.5 * RATE);
const windows = planAudioChunks(audio);
expect(windows[0]![1]).toBeGreaterThanOrEqual(26 * RATE);
expect(windows[0]![1]).toBeLessThanOrEqual(26.5 * RATE);
expectContiguous(windows, audio.length);
});
});
describe("wordsToChunks", () => {
test("splits after sentence-ending punctuation, keeping absolute timestamps", () => {
const chunks = wordsToChunks([
word("Hello", 0.5, 0.8),
word("there.", 0.9, 1.2),
word("How", 1.5, 1.7),
word("are", 1.7, 1.8),
word("you?", 1.8, 2.1),
]);
expect(chunks).toEqual([
{ text: " Hello there.", timestamp: [0.5, 1.2] },
{ text: " How are you?", timestamp: [1.5, 2.1] },
]);
});
test("chunks export to .srt and timestamped .txt like Whisper's", () => {
const chunks = wordsToChunks([word("Hello", 0.5, 0.8), word("there.", 0.9, 1.2), word("Bye.", 61.5, 62.25)]);
expect(transcriptToSrt(chunks)).toBe(
"1\n00:00:00,500 --> 00:00:01,200\nHello there.\n\n2\n00:01:01,500 --> 00:01:02,250\nBye.\n",
);
expect(transcriptToTimestampedTxt(chunks)).toContain("Bye.");
});
test("closing quotes after the full stop still end the sentence", () => {
const chunks = wordsToChunks([word('"Yes."', 0, 0.4), word("Then", 0.5, 0.7)]);
expect(chunks.map((chunk) => chunk.text)).toEqual([' "Yes."', " Then"]);
});
test("a pause of at least 1 s starts a new chunk", () => {
const chunks = wordsToChunks([word("one", 0, 0.3), word("two", 1.4, 1.6)]);
expect(chunks).toHaveLength(2);
});
test("a run-on sentence is split once it reaches 12 s", () => {
const words = Array.from({ length: 40 }, (_, i) => word(`w${i}`, i * 0.5, i * 0.5 + 0.4));
const chunks = wordsToChunks(words);
expect(chunks.length).toBeGreaterThan(1);
for (const chunk of chunks) {
expect((chunk.timestamp[1] ?? 0) - chunk.timestamp[0]).toBeLessThanOrEqual(12.5);
}
expect(chunks.map((c) => c.text.trim()).join(" ").split(" ")).toHaveLength(40);
});
test("no words → no chunks", () => {
expect(wordsToChunks([])).toEqual([]);
});
});
describe("vocab tokenizer", () => {
const vocab = "<unk> 0\n▁the 1\n▁cat 2\ns 3\n. 4\n<blk> 5\n";
test("parseVocab indexes tokens by id", () => {
expect(parseVocab(vocab)).toEqual(["<unk>", "▁the", "▁cat", "s", ".", "<blk>"]);
});
test("decode joins pieces, skips blank, and attaches punctuation", () => {
const tokenizer = new ParakeetVocabTokenizer(parseVocab(vocab));
expect(tokenizer.blankId).toBe(5);
expect(tokenizer.blankToken).toBe("<blk>");
expect(tokenizer.decode([1, 5, 2, 3, 4])).toBe("the cats.");
});
});
describe("Parakeet files", () => {
test("int8 and fp16 file names; other dtypes are not offered", () => {
expect(parakeetFileName("encoder", "q8")).toBe("encoder-model.int8.onnx");
expect(parakeetFileName("decoder", "q8")).toBe("decoder_joint-model.int8.onnx");
expect(parakeetFileName("encoder", "fp16")).toBe("encoder-model.fp16.onnx");
expect(() => parakeetFileName("encoder", "q4")).toThrow();
});
test("URLs are pinned to the measured revision under the model id", () => {
expect(parakeetFileUrl("vocab.txt")).toBe(
"https://huggingface.co/ysdede/parakeet-tdt-0.6b-v3-onnx/resolve/" +
"e5356bf6e24bdb7a269536b0e9e7869bc034b893/vocab.txt",
);
});
});
describe("transcribeWindows", () => {
test("recognizes each window with its offset and reports progress + new chunks", async () => {
const audio = new Float32Array(70 * RATE).fill(0.1);
const offsets: number[] = [];
const sizes: number[] = [];
const progress: number[] = [];
const streamed: string[] = [];
const result = await transcribeWindows(
audio,
async (window, offset) => {
offsets.push(offset);
sizes.push(window.length);
return [word(`at${Math.round(offset)}.`, offset + 0.1, offset + 0.5)];
},
{
onWindow: (chunks, processed, total) => {
progress.push(processed);
expect(total).toBe(70);
streamed.push(...chunks.map((chunk) => chunk.text));
},
},
);
expect(offsets[0]).toBe(0);
expect(offsets.length).toBe(3);
for (const size of sizes) expect(size).toBeLessThanOrEqual(30 * RATE);
expect(progress[progress.length - 1]).toBe(70);
expect(progress).toEqual([...progress].sort((a, b) => a - b));
expect(result.chunks.map((c) => c.text)).toEqual(streamed);
expect(result.text).toBe(streamed.join("").trim());
expect(result.chunks[1]!.timestamp[0]).toBeCloseTo(offsets[1]! + 0.1, 5);
});
test("silence-only audio: empty transcript, progress still reaches the end", async () => {
const progress: number[] = [];
const result = await transcribeWindows(
new Float32Array(40 * RATE),
async () => [],
{ onWindow: (_chunks, processed) => progress.push(processed) },
);
expect(result).toEqual({ text: "", chunks: [] });
expect(progress[progress.length - 1]).toBe(40);
});
});
+24
View File
@@ -2,6 +2,7 @@ import { describe, test, expect } from "bun:test";
import {
applyLoadProgress,
deviceAttempts,
parakeetDeviceAttempts,
dtypeRequiresF16,
initialLoadProgress,
choosePipeline,
@@ -247,6 +248,29 @@ describe("deviceAttempts (D-004, D-017)", () => {
});
});
describe("parakeetDeviceAttempts (D-WS7a)", () => {
const everything = { webnn: true, webgpu: true, webgpuF16: true };
test("auto never picks an accelerator for Parakeet", () => {
expect(parakeetDeviceAttempts("auto", everything)).toEqual(["wasm"]);
});
test("explicit WebGPU is tried (then WASM) only with shader-f16", () => {
expect(parakeetDeviceAttempts("webgpu", everything)).toEqual(["webgpu", "wasm"]);
expect(parakeetDeviceAttempts("webgpu", { ...everything, webgpuF16: false })).toEqual([
"wasm",
]);
expect(parakeetDeviceAttempts("webgpu", { ...everything, webgpu: false })).toEqual([
"wasm",
]);
});
test("WebNN and WASM preferences run WASM", () => {
expect(parakeetDeviceAttempts("webnn", everything)).toEqual(["wasm"]);
expect(parakeetDeviceAttempts("wasm", everything)).toEqual(["wasm"]);
});
});
describe("TranscribeProgressTracker", () => {
// Production shape: 30 s windows advancing by 20 s (30 − 2·5, the worker's
// chunk/stride settings), like the chunked ASR pipeline.
+31
View File
@@ -0,0 +1,31 @@
import { describe, expect, test } from "bun:test";
import { join } from "node:path";
import { ortAliasPlugin } from "../scripts/ortAlias";
// D-WS7a: the Parakeet path must not bring a second onnxruntime into the
// inference worker. parakeet.js depends on its own onnxruntime-web (1.24.1);
// the app runs the one Transformers.js pins. Unminified bundles carry a
// `// node_modules/<path>` comment per bundled module, so count runtimes.
const WORKER = join(import.meta.dir, "../src/worker/inference.worker.ts");
async function bundledRuntimes(plugins: Bun.BunPlugin[]): Promise<string[]> {
const build = await Bun.build({ entrypoints: [WORKER], target: "browser", format: "esm", plugins });
expect(build.success).toBe(true);
const code = await build.outputs[0]!.text();
return [...code.matchAll(/^\/\/ (node_modules\/\S*onnxruntime-web\/dist\/\S+)$/gm)].map((m) => m[1]!);
}
describe("inference worker bundle", () => {
test("with the alias plugin it holds only the Transformers.js onnxruntime", async () => {
expect(await bundledRuntimes([ortAliasPlugin])).toEqual([
"node_modules/onnxruntime-web/dist/ort.webgpu.bundle.min.mjs",
]);
});
test("without it, parakeet.js's own runtime would be bundled too (the plugin is load-bearing)", async () => {
const runtimes = await bundledRuntimes([]);
expect(runtimes).toHaveLength(2);
expect(runtimes.some((path) => path.includes("parakeet.js/node_modules/"))).toBe(true);
});
});