diff --git a/src/lib/models.ts b/src/lib/models.ts index ad82c0c..4e5607c 100644 --- a/src/lib/models.ts +++ b/src/lib/models.ts @@ -35,9 +35,32 @@ export interface DtypeDefault { note: string; } +/** + * Which inference path a catalog entry runs on (D-WS7a). Whisper models use + * the Transformers.js ASR pipeline; Parakeet uses the worker's own TDT path + * (src/worker/parakeetEngine.ts). Absent ⇒ "whisper", so the Whisper entries + * carry no field at all. + */ +export type ModelFamily = "whisper" | "parakeet"; + +/** Exact weight-file sizes per dtype, for entries whose files we know (D-WS7a). */ +export interface ExactModelBytes { + encoder: Partial>; + decoder: Partial>; + /** Tokenizer/vocab/config files. */ + aux: number; +} + export interface WhisperModel { /** Hugging Face Hub id, e.g. "onnx-community/whisper-tiny.en". */ id: string; + /** Inference path (D-WS7a); absent ⇒ "whisper". */ + family?: ModelFamily; + /** + * Real file sizes; when present they replace the parameter-count estimate + * in `estimateDownloadBytes` / `estimatePeakLoadBytes`. + */ + exactBytes?: ExactModelBytes; /** Human-readable name for the picker. */ displayName: string; /** False for `.en` (English-only) variants. */ @@ -54,8 +77,23 @@ export interface WhisperModel { * CTranslate2 conversions on the Hub (`Systran/faster-whisper-*`; * large-v3-turbo → `mobiuslabsgmbh/faster-whisper-large-v3-turbo`), so the * pinned library version stays the single authority on the exact repo. + * Absent for browser-only families (Parakeet, D-WS7a): the server engine + * answers 400 for them and the picker hides them while it is selected. */ - fasterWhisperModel: string; + fasterWhisperModel?: string; +} + +/** The entry's inference path; absent family (or unknown model) ⇒ "whisper". */ +export function modelFamily(model: WhisperModel | undefined): ModelFamily { + return model?.family ?? "whisper"; +} + +/** Bytes of one part at one dtype: the exact file size when known, else the estimate. */ +function partBytes(model: WhisperModel, part: "encoder" | "decoder", dtype: WhisperDtype): number { + const exact = model.exactBytes?.[part][dtype]; + if (exact !== undefined) return exact; + const params = part === "encoder" ? model.encoderParamsM : model.decoderParamsM; + return params * 1e6 * BYTES_PER_PARAM[dtype]; } // --------------------------------------------------------------------------- @@ -81,9 +119,9 @@ export function estimateDownloadBytes( const encDtype = typeof dtype === "string" ? dtype : dtype.encoder_model; const decDtype = typeof dtype === "string" ? dtype : dtype.decoder_model_merged; return Math.round( - model.encoderParamsM * 1e6 * BYTES_PER_PARAM[encDtype] + - model.decoderParamsM * 1e6 * BYTES_PER_PARAM[decDtype] + - AUX_FILES_BYTES, + partBytes(model, "encoder", encDtype) + + partBytes(model, "decoder", decDtype) + + (model.exactBytes?.aux ?? AUX_FILES_BYTES), ); } @@ -107,8 +145,8 @@ export function formatModelSize(bytes: number): string { export function estimatePeakLoadBytes(model: WhisperModel, dtype: DtypeSpec): number { const encDtype = typeof dtype === "string" ? dtype : dtype.encoder_model; const decDtype = typeof dtype === "string" ? dtype : dtype.decoder_model_merged; - const encoderBytes = model.encoderParamsM * 1e6 * BYTES_PER_PARAM[encDtype]; - const decoderBytes = model.decoderParamsM * 1e6 * BYTES_PER_PARAM[decDtype]; + const encoderBytes = partBytes(model, "encoder", encDtype); + const decoderBytes = partBytes(model, "decoder", decDtype); return Math.round(2 * Math.max(encoderBytes, decoderBytes)); } @@ -168,6 +206,10 @@ const ACCEL_Q4: DtypeDefault = { // The catalog (D-006: curated, not exhaustive) // --------------------------------------------------------------------------- +/** Parakeet TDT 0.6B v3 ONNX repo and the revision its file sizes were taken from (D-WS7a). */ +export const PARAKEET_MODEL_ID = "ysdede/parakeet-tdt-0.6b-v3-onnx"; +export const PARAKEET_REVISION = "e5356bf6e24bdb7a269536b0e9e7869bc034b893"; + export const MODELS: readonly WhisperModel[] = [ { id: "onnx-community/whisper-tiny.en", @@ -259,6 +301,46 @@ export const MODELS: readonly WhisperModel[] = [ decoderParamsM: 174, defaults: { wasm: WASM_Q4, webgpu: WEBGPU_FP16_Q4, webnn: WEBNN_FP16 }, }, + { + // NVIDIA Parakeet TDT 0.6B v3 (CC-BY-4.0), NeMo→ONNX conversion by + // ysdede, run through parakeet.js on our onnxruntime-web (D-WS7a). + // English-only in this app (the model itself knows 25 European + // languages, but only English is measured here). Desktop-only: its + // ~1.3 GB load peak is far past every phone budget (D-027–D-029), so + // the picker hides it on mobile instead of just warning. + id: PARAKEET_MODEL_ID, + family: "parakeet", + displayName: "Parakeet v3 (English)", + multilingual: false, + // Parameter counts only feed fallbacks; the sizes below are exact. + encoderParamsM: 609, + decoderParamsM: 17, + exactBytes: { + encoder: { q8: 652_183_999, fp16: 1_238_960_452 }, + decoder: { q8: 18_202_004, fp16: 36_266_140 }, + aux: 102_132, // vocab.txt + }, + defaults: { + wasm: { + dtype: "q8", + verified: false, + note: "int8 encoder + decoder: the measured WASM path (D-WS7a).", + }, + // Only used when WebGPU is chosen explicitly — "auto" never picks it + // for Parakeet (D-WS7a): int8 on WebGPU is reported broken, and fp16 + // is a separate 1.24 GB download. The decoder stays int8 on WASM. + webgpu: { + dtype: { encoder_model: "fp16", decoder_model_merged: "q8" }, + verified: false, + note: "fp16 encoder on WebGPU (needs shader-f16) + int8 decoder on WASM.", + }, + webnn: { + dtype: "q8", + verified: false, + note: "No WebNN path: runs the WASM int8 setup.", + }, + }, + }, ]; export function getModel(id: string): WhisperModel | undefined { diff --git a/tests/models.test.ts b/tests/models.test.ts index 1ef20c3..7ca095c 100644 --- a/tests/models.test.ts +++ b/tests/models.test.ts @@ -9,6 +9,8 @@ import { formatModelSize, getFasterWhisperModel, getModel, + modelFamily, + PARAKEET_MODEL_ID, resolveDefaultDtype, } from "../src/lib/models"; @@ -19,6 +21,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); @@ -32,12 +36,15 @@ describe("MODELS catalog", () => { "onnx-community/whisper-medium.en_timestamped", "onnx-community/whisper-medium-ONNX", "onnx-community/whisper-large-v3-turbo", + // 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")); @@ -69,8 +76,8 @@ 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)", () => { - for (const model of MODELS) { + test("every Whisper model maps to a faster-whisper name for the server engine (D-013)", () => { + for (const model of WHISPER_MODELS) { // 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 @@ -209,3 +216,41 @@ describe("WHISPER_LANGUAGES", () => { expect(WHISPER_LANGUAGES.some((entry) => entry.code === "auto")).toBe(false); }); }); + +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); + }); +});