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:
commit
30969e86b7
29 files changed
+1741
-54
No files matched your search
@@ -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]);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user