diff --git a/components/TranscriptPanel.tsx b/components/TranscriptPanel.tsx
index fde58d2..766c704 100644
--- a/components/TranscriptPanel.tsx
+++ b/components/TranscriptPanel.tsx
@@ -419,7 +419,7 @@ export default function TranscriptPanel() {
)}
{status === "error" && (
-
+
{error}
)}
diff --git a/lib/models.ts b/lib/models.ts
index 3756943..48f28d7 100644
--- a/lib/models.ts
+++ b/lib/models.ts
@@ -1,7 +1,12 @@
-import type { TranscriptLanguage } from "./languages";
-
/** Local speech models offered on the upload screen. */
-export type WhisperModel = "base" | "small";
+export type WhisperModel =
+ | "base"
+ | "small"
+ // | "medium"
+ /** CrisperWhisper 2.0 Small, exported by tools/crisperwhisper-onnx. */
+ // | "crisperSmall"
+ /** CrisperWhisper 2.0 Turbo, published ONNX export. */
+ // | "crisperTurbo";
/** NVIDIA Parakeet TDT 0.6B v3 via parakeet.js (ONNX / WebGPU). */
export type ParakeetModel = "parakeet";
export type ModelId = WhisperModel | ParakeetModel;
@@ -26,14 +31,27 @@ export type WhisperModelInfo = ModelDisplay & {
wasm: Record
;
};
/**
- * When true, condition Whisper on a short filler-rich initial prompt so
- * "Remove fillers" has tokens to act on. See {@link whisperFillerPrompt}.
+ * A CrisperWhisper checkpoint, whose vocabulary extends past Whisper's
+ * timestamp block: `[UM]`, `[UH]`, vocal events and the prompt scaffolding all
+ * sit above it.
+ *
+ * These models only work at all because of
+ * patches/@huggingface+transformers+4.2.0.patch, which bounds the timestamp
+ * range at both ends. Unpatched, transformers.js reads every token above the
+ * block as a timestamp: word-timestamp collation crashes on the first `[UM]`,
+ * and the logits processor suppresses all text after the first `[UH]`, cutting
+ * the transcript off mid-sentence with no error. See patches/README.md.
*
- * (A dedicated verbatim model — CrisperWhisper — was evaluated, but its
- * only browser-runnable ONNX export lacks the cross-attention outputs
- * required for word-level timestamps, which this editor depends on.)
+ * The flag itself drives `markCrisperPromptTokensSpecial`, keeping the mode
+ * tags out of transcript text even though we never send them — see the note on
+ * decoder-prefix conditioning above {@link MODELS}.
+ */
+ crisper?: boolean;
+ /**
+ * Load from `public/models//` instead of the Hub. Used for exports that
+ * have not been published yet — see tools/crisperwhisper-onnx.
*/
- keepFillers?: boolean;
+ local?: boolean;
};
export type ParakeetModelInfo = ModelDisplay & {
@@ -47,57 +65,124 @@ export type ParakeetModelInfo = ModelDisplay & {
export type ModelInfo = WhisperModelInfo | ParakeetModelInfo;
/** Display order for model rows in the source dropdown. */
-export const MODEL_ORDER: ModelId[] = ["base", "small", "parakeet"];
+export const MODEL_ORDER: ModelId[] = [
+ "base",
+ "small",
+ // "medium",
+ "parakeet",
+ // "crisperSmall",
+ // "crisperTurbo",
+];
+
+const WHISPER_DTYPE = {
+ // q4 decoder: q8 fails session creation on onnxruntime-web 1.26
+ // (Missing required scale … MatMulNBits).
+ webgpu: { encoder_model: "fp32", decoder_model_merged: "q4" },
+ wasm: { encoder_model: "fp32", decoder_model_merged: "q4" },
+} satisfies WhisperModelInfo["dtype"];
/**
- * Hard cap for Whisper filler prompts. Longer `<|startofprev|>` strings
- * forced via `decoder_input_ids` have truncated multi-speaker / long-form
- * transcripts (second speaker dropped). Keep prompts to a short filler list.
+ * Medium cannot share {@link WHISPER_DTYPE}: its fp32 encoder export is 1.2 GB,
+ * which no browser tab survives instantiating. Splits per device the same way
+ * {@link MODELS.parakeet} already does — fp16 encoder on WebGPU, int8 on WASM —
+ * and keeps the q4 merged decoder that Base and Small are proven on.
*/
-export const MAX_VERBATIM_PROMPT_LENGTH = 32;
+// const WHISPER_MEDIUM_DTYPE = {
+// webgpu: { encoder_model: "fp16", decoder_model_merged: "q4" },
+// wasm: { encoder_model: "int8", decoder_model_merged: "q4" },
+// } satisfies WhisperModelInfo["dtype"];
/**
- * Short, language-specific filler prompts for Whisper. Deliberately tiny and
- * free of "I'm …" openers (those seeded "I'm sorry" hallucination loops).
- * The classic OpenAI example prompt is too long for our chunked decoder path.
+ * The local Small export ships only q4 for the merged decoder: int8 cannot
+ * reach weights inside the decoder's control-flow subgraphs, so
+ * `quantize_dynamic` silently emits an un-quantised file and the export tooling
+ * refuses it (see tools/crisperwhisper-onnx/README.md). This q4 pair is the
+ * combination verified end-to-end — encoder and decoder loaded in onnxruntime,
+ * cross-attentions returned at the right shape.
*/
-const WHISPER_FILLER_PROMPTS: Record = {
- en: "Um, uh, hmm, er, ah.",
- es: "Em, emm, eee.",
- fr: "Euh, heu, euhm.",
- de: "Äh, ähm, öhm, mhh.",
- zh: "嗯, 呃, 额, 唔.",
-};
+// const CRISPER_SMALL_DTYPE = {
+// webgpu: { encoder_model: "q4", decoder_model_merged: "q4" },
+// wasm: { encoder_model: "q4", decoder_model_merged: "q4" },
+// } satisfies WhisperModelInfo["dtype"];
/**
- * Filler-bias prompt for Whisper when the selected model opts into
- * {@link WhisperModelInfo.keepFillers}. Returns null when prompting is off.
+ * Turbo takes fp16 for the decoder rather than q4.
+ *
+ * Turbo collapsed after the first VAD segment on q4/q4. Bisecting against the
+ * fp32 checkpoint cleared everything else: fp32 transcribes the clip in full,
+ * and the q4 encoder is faithful — swapping it in under an fp32 decoder gives a
+ * byte-identical transcript (cosine 0.93 against fp32 hidden states, but no
+ * effect on output). That leaves the merged decoder as the only component not
+ * exonerated, so it gets the precision.
+ *
+ * It is also cheaper: fp16 is 477 MB against q4's 600 MB, because q4 leaves the
+ * embedding and lm_head — most of a 4-layer decoder's weight — unquantised.
+ * fp32 would be better still but ships as a 953 MB external-data sidecar, which
+ * transformers.js only fetches when `use_external_data_format` is declared, and
+ * this repo's config.json does not declare it.
*/
-export function whisperFillerPrompt(
- model: ModelId,
- language: TranscriptLanguage
-): string | null {
- const info = MODELS[model];
- if (info.backend !== "whisper" || !info.keepFillers) return null;
- return WHISPER_FILLER_PROMPTS[language];
-}
-
-const WHISPER_DTYPE = {
- // q4 decoder: q8 fails session creation on onnxruntime-web 1.26
- // (Missing required scale … MatMulNBits).
- webgpu: { encoder_model: "fp32", decoder_model_merged: "q4" },
- wasm: { encoder_model: "fp32", decoder_model_merged: "q4" },
-} satisfies WhisperModelInfo["dtype"];
+// const CRISPER_TURBO_DTYPE = {
+// webgpu: { encoder_model: "q4", decoder_model_merged: "fp16" },
+// wasm: { encoder_model: "q4", decoder_model_merged: "fp16" },
+// } satisfies WhisperModelInfo["dtype"];
/**
+ * No model conditions the decoder on a prefix, and that is a measured decision
+ * rather than an omission. Two mechanisms were tried and both were removed:
+ *
+ * **Whisper's `<|startofprev|>` filler prompt** (a short filler list — "Um, uh,
+ * hmm, er, ah." — forced via `decoder_input_ids` to give "Remove fillers"
+ * something to act on). Measured on an 11.5 s clip, decoding each VAD segment
+ * the way the worker does, Whisper Small:
+ *
+ * | slice | plain | + prompt |
+ * |-------------|---------------------------|--------------------------|
+ * | full 11.5 s | complete, includes "uh" | "Nice. How does it, uh," |
+ * | 2.5–5.0 s | "Nice. How does it work?" | "Nice. How does it" |
+ * | 5.0–11.5 s | complete sentence | **"Um,"** |
+ *
+ * The long tail segment collapses into an echo of the prompt. Medium is worse
+ * (the whole clip truncates to "Nice. How does it, uh…"), Base only marginally
+ * more robust. A length cap was tried first and does not help — a 20-character
+ * prompt still triggers it.
+ *
+ * **CrisperWhisper's `[verbatim_N]` mode prefix** (its trained-in verbatim
+ * selector, from `crisperwhisper==2.0.1`, `crisperwhisper/prompt.py`). Same
+ * failure, same cause — the worker decodes short VAD segments, and prefix
+ * conditioning collapses them. Measured against the fp32 checkpoints:
+ *
+ * | slice | plain | + [verbatim_1..5] |
+ * |-------------|-----------------------------|------------------------|
+ * | full 11.5 s | complete, includes "[UH]" | Turbo drops "Nice." |
+ * | 2.5–5.0 s | "Nice. How does it work?" | "Nice. How does it-" |
+ *
+ * On Small the prefix also emitted `[breath]` where the speaker hesitated while
+ * plain decoding kept it as "uh".
+ *
+ * If either is ever re-attempted: do **not** copy upstream's `<|notimestamps|>`
+ * along with the mode tags. Upstream can suppress timestamps because it derives
+ * word timings itself via Viterbi over the space token's cross-attention;
+ * transformers.js instead splits chunked audio *on* timestamp tokens, so
+ * suppressing them accumulates one unsegmented run whose stride-overlap merge
+ * can resolve to nothing — surfacing mid-transcription as "token_ids must be a
+ * non-empty array of integers".
+ *
+ * Nothing is lost either way: every checkpoint emits its fillers unprompted
+ * ("uh" on stock Whisper, `[UH]` on CrisperWhisper), and whatever a model does
+ * swallow is still recovered as a timed `...` placeholder by
+ * `insertDisfluencyPlaceholders`, so it stays cuttable.
+ *
* Local speech models that can run in the transcription worker.
* Shared display fields live on every entry; backend-specific knobs
- * (`dtype` / `keepFillers` vs `repoId`) are gated by `backend`.
+ * (`dtype` / `crisper` vs `repoId`) are gated by `backend`.
*/
export const MODELS: {
base: WhisperModelInfo;
small: WhisperModelInfo;
+ // medium: WhisperModelInfo;
parakeet: ParakeetModelInfo;
+ // crisperSmall: WhisperModelInfo;
+ // crisperTurbo: WhisperModelInfo;
} = {
base: {
backend: "whisper",
@@ -106,8 +191,6 @@ export const MODELS: {
description: "Faster download and transcription. Good for most clips.",
size: "~200 MB",
dtype: WHISPER_DTYPE,
- // Short filler prompt only — long prompts truncate long-form ASR.
- keepFillers: true,
},
small: {
backend: "whisper",
@@ -116,22 +199,77 @@ export const MODELS: {
description: "More accurate on longer or noisier audio. Larger download.",
size: "~600 MB",
dtype: WHISPER_DTYPE,
- keepFillers: true,
},
+ // medium: {
+ // backend: "whisper",
+ // id: "onnx-community/whisper-medium_timestamped",
+ // label: "Whisper Medium",
+ // description:
+ // "Best accuracy on accents, crosstalk and poor recordings. Slow, and a big download.",
+ // // WASM int8 encoder + q4 decoder ~780 MB; WebGPU fp16 encoder ~1.1 GB.
+ // size: "~1.1 GB",
+ // dtype: WHISPER_MEDIUM_DTYPE,
+ // },
parakeet: {
backend: "parakeet",
id: "parakeet-tdt-0.6b-v3",
repoId: "ysdede/parakeet-tdt-0.6b-v3-onnx",
- label: "Parakeet TDT v3",
+ label: "Parakeet v3",
description:
"NVIDIA FastConformer — faster on WebGPU, strong EU-language accuracy. Auto-detects language.",
- // WASM int8 ~670 MB; WebGPU fp16 ~1.2 GB.
- size: "~700 MB",
+ // WASM int8 encoder + fp16 decoder ~690 MB; WebGPU fp16 + fp32 ~1.3 GB.
+ size: "~1.3 GB",
},
+ // crisperSmall: {
+ // backend: "whisper",
+ // // Local folder under public/models — not published yet. Install with
+ // // `python tools/crisperwhisper-onnx/install_local.py`.
+ // id: "crisperwhisper-2.0-small-onnx",
+ // local: true,
+ // label: "CrisperWhisper Small (local)",
+ // description:
+ // "Verbatim: transcribes fillers as [UM] / [UH] instead of dropping them. Self-exported, unpublished. Non-commercial licence.",
+ // // q4 encoder 66 MB + q4 merged decoder 258 MB.
+ // size: "~324 MB",
+ // dtype: CRISPER_SMALL_DTYPE,
+ // crisper: true,
+ // },
+ // crisperTurbo: {
+ // backend: "whisper",
+ // id: "Masterx/CrisperWhisper2.0-turbo-ONNX",
+ // label: "CrisperWhisper Turbo",
+ // description:
+ // "Keeps fillers, on a large-v3 encoder. The largest download. Non-commercial licence.",
+ // // q4 encoder 425 MB + fp16 merged decoder 477 MB.
+ // size: "~900 MB",
+ // dtype: CRISPER_TURBO_DTYPE,
+ // crisper: true,
+ // },
};
+/**
+ * Whether `model` is a CrisperWhisper checkpoint, and so needs the tokenizer
+ * repair in {@link WhisperModelInfo.crisper}.
+ */
+export function isCrisperModel(model: ModelId): boolean {
+ const info = MODELS[model];
+ return info.backend === "whisper" && info.crisper === true;
+}
+
+/** Whether `model` loads from public/models rather than the Hub. */
+export function isLocalModel(model: ModelId): boolean {
+ const info = MODELS[model];
+ return info.backend === "whisper" && info.local === true;
+}
+
export function isWhisperModel(value: unknown): value is WhisperModel {
- return value === "base" || value === "small";
+ return (
+ value === "base" ||
+ value === "small" ||
+ value === "medium" ||
+ value === "crisperSmall" ||
+ value === "crisperTurbo"
+ );
}
export function isParakeetModel(value: unknown): value is ParakeetModel {
diff --git a/package-lock.json b/package-lock.json
index 9c08de2..1dc62f4 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -14,7 +14,7 @@
"@ffmpeg/ffmpeg": "^0.12.15",
"@ffmpeg/util": "^0.12.2",
"@floating-ui/react": "^0.27.20",
- "@huggingface/transformers": "^4.2.0",
+ "@huggingface/transformers": "4.2.0",
"@next/third-parties": "^16.2.12",
"@sentry/electron": "^7.16.0",
"@sentry/react": "^10.69.0",
@@ -45,6 +45,7 @@
"esbuild": "^0.28.1",
"eslint": "^9",
"eslint-config-next": "16.2.12",
+ "patch-package": "^8.0.1",
"tailwindcss": "^4",
"tsx": "^4.22.4",
"typescript": "^5"
@@ -4104,6 +4105,13 @@
"node": ">=10.0.0"
}
},
+ "node_modules/@yarnpkg/lockfile": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz",
+ "integrity": "sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ==",
+ "dev": true,
+ "license": "BSD-2-Clause"
+ },
"node_modules/abbrev": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/abbrev/-/abbrev-4.0.0.tgz",
@@ -6857,6 +6865,16 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/find-yarn-workspace-root": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/find-yarn-workspace-root/-/find-yarn-workspace-root-2.0.0.tgz",
+ "integrity": "sha512-1IMnbjt4KzsQfnhnzNd8wUEgXZ44IzZaZmnLYx7D5FZlaHt2gW20Cri8Q+E/t5tIj4+epTBub+2Zxu/vNILzqQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "micromatch": "^4.0.2"
+ }
+ },
"node_modules/flat-cache": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz",
@@ -7699,6 +7717,22 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/is-docker": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz",
+ "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "is-docker": "cli.js"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/is-document.all": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/is-document.all/-/is-document.all-1.0.0.tgz",
@@ -7982,6 +8016,19 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/is-wsl": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz",
+ "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-docker": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/isarray": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz",
@@ -8119,6 +8166,26 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/json-stable-stringify": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/json-stable-stringify/-/json-stable-stringify-1.3.0.tgz",
+ "integrity": "sha512-qtYiSSFlwot9XHtF9bD9c7rwKjr+RecWT//ZnPvSmEjpV5mmPOCN4j8UjY5hbjNkOwZ/jQv3J6R1/pL7RwgMsg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "call-bound": "^1.0.4",
+ "isarray": "^2.0.5",
+ "jsonify": "^0.0.1",
+ "object-keys": "^1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/json-stable-stringify-without-jsonify": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
@@ -8158,6 +8225,16 @@
"graceful-fs": "^4.1.6"
}
},
+ "node_modules/jsonify": {
+ "version": "0.0.1",
+ "resolved": "https://registry.npmjs.org/jsonify/-/jsonify-0.0.1.tgz",
+ "integrity": "sha512-2/Ki0GcmuqSrgFyelQq9M05y7PS0mEwuIzrf3f1fPqkVDVRvZrPZtVSMHxdgo8Aq0sxAOb/cr2aqqA3LeWHVPg==",
+ "dev": true,
+ "license": "Public Domain",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/jsx-ast-utils": {
"version": "3.3.5",
"resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz",
@@ -8184,6 +8261,16 @@
"json-buffer": "3.0.1"
}
},
+ "node_modules/klaw-sync": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/klaw-sync/-/klaw-sync-6.0.0.tgz",
+ "integrity": "sha512-nIeuVSzdCCs6TDPTqI8w1Yre34sSq7AkZ4B3sfOBbI2CgVSB4Du4aLQijFU2+lhAFCwt9+42Hel6lQNIv6AntQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "graceful-fs": "^4.1.11"
+ }
+ },
"node_modules/language-subtag-registry": {
"version": "0.3.23",
"resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz",
@@ -9230,6 +9317,23 @@
"integrity": "sha512-BOoomdHYmNRL5r4iQ4bMvsl2t0/hzVQ3OM3PHD0gxeXu1PmggqBv3puZicEUVOA3AtHHYmqZtjMj9FOfGrATTw==",
"license": "MIT"
},
+ "node_modules/open": {
+ "version": "7.4.2",
+ "resolved": "https://registry.npmjs.org/open/-/open-7.4.2.tgz",
+ "integrity": "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-docker": "^2.0.0",
+ "is-wsl": "^2.1.1"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/optionator": {
"version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
@@ -9351,6 +9455,52 @@
"node": ">=6"
}
},
+ "node_modules/patch-package": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/patch-package/-/patch-package-8.0.1.tgz",
+ "integrity": "sha512-VsKRIA8f5uqHQ7NGhwIna6Bx6D9s/1iXlA1hthBVBEbkq+t4kXD0HHt+rJhf/Z+Ci0F/HCB2hvn0qLdLG+Qxlw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@yarnpkg/lockfile": "^1.1.0",
+ "chalk": "^4.1.2",
+ "ci-info": "^3.7.0",
+ "cross-spawn": "^7.0.3",
+ "find-yarn-workspace-root": "^2.0.0",
+ "fs-extra": "^10.0.0",
+ "json-stable-stringify": "^1.0.2",
+ "klaw-sync": "^6.0.0",
+ "minimist": "^1.2.6",
+ "open": "^7.4.2",
+ "semver": "^7.5.3",
+ "slash": "^2.0.0",
+ "tmp": "^0.2.4",
+ "yaml": "^2.2.2"
+ },
+ "bin": {
+ "patch-package": "index.js"
+ },
+ "engines": {
+ "node": ">=14",
+ "npm": ">5"
+ }
+ },
+ "node_modules/patch-package/node_modules/ci-info": {
+ "version": "3.9.0",
+ "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz",
+ "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/sibiraj-s"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/path-exists": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
@@ -10388,6 +10538,16 @@
"node": ">=10"
}
},
+ "node_modules/slash": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/slash/-/slash-2.0.0.tgz",
+ "integrity": "sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/source-map": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
@@ -11488,6 +11648,22 @@
"dev": true,
"license": "ISC"
},
+ "node_modules/yaml": {
+ "version": "2.9.0",
+ "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz",
+ "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "yaml": "bin.mjs"
+ },
+ "engines": {
+ "node": ">= 14.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/eemeli"
+ }
+ },
"node_modules/yargs": {
"version": "17.7.2",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
diff --git a/package.json b/package.json
index 63aba16..7d3b3fe 100644
--- a/package.json
+++ b/package.json
@@ -19,7 +19,7 @@
"build": "next build",
"start": "next start",
"lint": "eslint",
- "postinstall": "node scripts/copy-assets.mjs",
+ "postinstall": "patch-package && node scripts/copy-assets.mjs",
"build:electron": "node scripts/build-electron.mjs",
"typecheck:electron": "tsc -p electron/tsconfig.json --noEmit",
"electron:dev": "npm run build:electron && concurrently -k -n next,electron -c cyan,magenta \"next dev\" \"node scripts/electron-dev.mjs\"",
@@ -37,7 +37,7 @@
"@ffmpeg/ffmpeg": "^0.12.15",
"@ffmpeg/util": "^0.12.2",
"@floating-ui/react": "^0.27.20",
- "@huggingface/transformers": "^4.2.0",
+ "@huggingface/transformers": "4.2.0",
"@next/third-parties": "^16.2.12",
"@sentry/electron": "^7.16.0",
"@sentry/react": "^10.69.0",
@@ -68,6 +68,7 @@
"esbuild": "^0.28.1",
"eslint": "^9",
"eslint-config-next": "16.2.12",
+ "patch-package": "^8.0.1",
"tailwindcss": "^4",
"tsx": "^4.22.4",
"typescript": "^5"
diff --git a/patches/@huggingface+transformers+4.2.0.patch b/patches/@huggingface+transformers+4.2.0.patch
new file mode 100644
index 0000000..78b25e9
--- /dev/null
+++ b/patches/@huggingface+transformers+4.2.0.patch
@@ -0,0 +1,296 @@
+diff --git a/node_modules/@huggingface/transformers/dist/transformers.js b/node_modules/@huggingface/transformers/dist/transformers.js
+index 3274d9c..0baa642 100644
+--- a/node_modules/@huggingface/transformers/dist/transformers.js
++++ b/node_modules/@huggingface/transformers/dist/transformers.js
+@@ -16280,11 +16280,12 @@ var WhisperTokenizer = class extends PreTrainedTokenizer {
+ */
+ decodeWithTimestamps(token_ids, decode_args) {
+ const time_precision = decode_args?.time_precision ?? 0.02;
+- const timestamp_begin = this.all_special_ids.at(-1) + 1;
++ const timestamp_begin = this.timestamp_begin;
++ const timestamp_end = timestamp_begin + 1500;
+ let outputs = [[]];
+ for (let token of token_ids) {
+ token = Number(token);
+- if (token >= timestamp_begin) {
++ if (token >= timestamp_begin && token <= timestamp_end) {
+ const timestamp = ((token - timestamp_begin) * time_precision).toFixed(2);
+ outputs.push(`<|${timestamp}|>`);
+ outputs.push([]);
+@@ -16292,7 +16293,7 @@ var WhisperTokenizer = class extends PreTrainedTokenizer {
+ outputs[outputs.length - 1].push(token);
+ }
+ }
+- outputs = outputs.map((s) => typeof s === "string" ? s : super.decode(s, decode_args));
++ outputs = outputs.map((s) => typeof s === "string" ? s : s.length === 0 ? "" : super.decode(s, decode_args));
+ return outputs.join("");
+ }
+ /**
+@@ -23586,6 +23587,7 @@ var WhisperTimeStampLogitsProcessor = class extends LogitsProcessor {
+ this.eos_token_id = Array.isArray(generate_config.eos_token_id) ? generate_config.eos_token_id[0] : generate_config.eos_token_id;
+ this.no_timestamps_token_id = generate_config.no_timestamps_token_id;
+ this.timestamp_begin = this.no_timestamps_token_id + 1;
++ this.timestamp_end = this.timestamp_begin + 1500;
+ this.begin_index = init_tokens.length;
+ if (init_tokens.at(-1) === this.no_timestamps_token_id) {
+ this.begin_index -= 1;
+@@ -23606,17 +23608,17 @@ var WhisperTimeStampLogitsProcessor = class extends LogitsProcessor {
+ );
+ batch_logits_data[this.no_timestamps_token_id] = -Infinity;
+ if (input_ids[i].length === this.begin_index) {
+- batch_logits_data.subarray(0, this.timestamp_begin).fill(-Infinity);
++ batch_logits_data.subarray(0, this.timestamp_begin).fill(-Infinity), batch_logits_data.subarray(this.timestamp_end + 1).fill(-Infinity);
+ continue;
+ }
+ const seq = input_ids[i].slice(this.begin_index);
+- const last_was_timestamp = seq.length >= 1 && seq[seq.length - 1] >= this.timestamp_begin;
+- const penultimate_was_timestamp = seq.length < 2 || seq[seq.length - 2] >= this.timestamp_begin;
++ const last_was_timestamp = seq.length >= 1 && seq[seq.length - 1] >= this.timestamp_begin && seq[seq.length - 1] <= this.timestamp_end;
++ const penultimate_was_timestamp = seq.length < 2 || (seq[seq.length - 2] >= this.timestamp_begin && seq[seq.length - 2] <= this.timestamp_end);
+ if (last_was_timestamp) {
+ if (penultimate_was_timestamp) {
+- batch_logits_data.subarray(this.timestamp_begin).fill(-Infinity);
++ batch_logits_data.subarray(this.timestamp_begin, this.timestamp_end + 1).fill(-Infinity);
+ } else {
+- batch_logits_data.subarray(0, this.eos_token_id).fill(-Infinity);
++ batch_logits_data.subarray(0, this.eos_token_id).fill(-Infinity), batch_logits_data.subarray(this.timestamp_end + 1).fill(-Infinity);
+ }
+ }
+ if (input_ids[i].length === this.begin_index && this.max_initial_timestamp_index !== null) {
+@@ -23625,11 +23627,11 @@ var WhisperTimeStampLogitsProcessor = class extends LogitsProcessor {
+ }
+ const logprobs = log_softmax(batch_logits_data);
+ const timestamp_logprob = Math.log(
+- logprobs.subarray(this.timestamp_begin).map(Math.exp).reduce((a, b) => a + b)
++ logprobs.subarray(this.timestamp_begin, this.timestamp_end + 1).map(Math.exp).reduce((a, b) => a + b)
+ );
+- const max_text_token_logprob = max(logprobs.subarray(0, this.timestamp_begin))[0];
++ const max_text_token_logprob = Math.max(max(logprobs.subarray(0, this.timestamp_begin))[0], logprobs.length > this.timestamp_end + 1 ? max(logprobs.subarray(this.timestamp_end + 1))[0] : -Infinity);
+ if (timestamp_logprob > max_text_token_logprob) {
+- batch_logits_data.subarray(0, this.timestamp_begin).fill(-Infinity);
++ batch_logits_data.subarray(0, this.timestamp_begin).fill(-Infinity), batch_logits_data.subarray(this.timestamp_end + 1).fill(-Infinity);
+ }
+ }
+ return logits;
+diff --git a/node_modules/@huggingface/transformers/dist/transformers.node.cjs b/node_modules/@huggingface/transformers/dist/transformers.node.cjs
+index 6cb09ae..fcf2117 100644
+--- a/node_modules/@huggingface/transformers/dist/transformers.node.cjs
++++ b/node_modules/@huggingface/transformers/dist/transformers.node.cjs
+@@ -16472,11 +16472,12 @@ var WhisperTokenizer = class extends PreTrainedTokenizer {
+ */
+ decodeWithTimestamps(token_ids, decode_args) {
+ const time_precision = decode_args?.time_precision ?? 0.02;
+- const timestamp_begin = this.all_special_ids.at(-1) + 1;
++ const timestamp_begin = this.timestamp_begin;
++ const timestamp_end = timestamp_begin + 1500;
+ let outputs = [[]];
+ for (let token of token_ids) {
+ token = Number(token);
+- if (token >= timestamp_begin) {
++ if (token >= timestamp_begin && token <= timestamp_end) {
+ const timestamp = ((token - timestamp_begin) * time_precision).toFixed(2);
+ outputs.push(`<|${timestamp}|>`);
+ outputs.push([]);
+@@ -16484,7 +16485,7 @@ var WhisperTokenizer = class extends PreTrainedTokenizer {
+ outputs[outputs.length - 1].push(token);
+ }
+ }
+- outputs = outputs.map((s) => typeof s === "string" ? s : super.decode(s, decode_args));
++ outputs = outputs.map((s) => typeof s === "string" ? s : s.length === 0 ? "" : super.decode(s, decode_args));
+ return outputs.join("");
+ }
+ /**
+@@ -23769,6 +23770,7 @@ var WhisperTimeStampLogitsProcessor = class extends LogitsProcessor {
+ this.eos_token_id = Array.isArray(generate_config.eos_token_id) ? generate_config.eos_token_id[0] : generate_config.eos_token_id;
+ this.no_timestamps_token_id = generate_config.no_timestamps_token_id;
+ this.timestamp_begin = this.no_timestamps_token_id + 1;
++ this.timestamp_end = this.timestamp_begin + 1500;
+ this.begin_index = init_tokens.length;
+ if (init_tokens.at(-1) === this.no_timestamps_token_id) {
+ this.begin_index -= 1;
+@@ -23789,17 +23791,17 @@ var WhisperTimeStampLogitsProcessor = class extends LogitsProcessor {
+ );
+ batch_logits_data[this.no_timestamps_token_id] = -Infinity;
+ if (input_ids[i].length === this.begin_index) {
+- batch_logits_data.subarray(0, this.timestamp_begin).fill(-Infinity);
++ batch_logits_data.subarray(0, this.timestamp_begin).fill(-Infinity), batch_logits_data.subarray(this.timestamp_end + 1).fill(-Infinity);
+ continue;
+ }
+ const seq = input_ids[i].slice(this.begin_index);
+- const last_was_timestamp = seq.length >= 1 && seq[seq.length - 1] >= this.timestamp_begin;
+- const penultimate_was_timestamp = seq.length < 2 || seq[seq.length - 2] >= this.timestamp_begin;
++ const last_was_timestamp = seq.length >= 1 && seq[seq.length - 1] >= this.timestamp_begin && seq[seq.length - 1] <= this.timestamp_end;
++ const penultimate_was_timestamp = seq.length < 2 || (seq[seq.length - 2] >= this.timestamp_begin && seq[seq.length - 2] <= this.timestamp_end);
+ if (last_was_timestamp) {
+ if (penultimate_was_timestamp) {
+- batch_logits_data.subarray(this.timestamp_begin).fill(-Infinity);
++ batch_logits_data.subarray(this.timestamp_begin, this.timestamp_end + 1).fill(-Infinity);
+ } else {
+- batch_logits_data.subarray(0, this.eos_token_id).fill(-Infinity);
++ batch_logits_data.subarray(0, this.eos_token_id).fill(-Infinity), batch_logits_data.subarray(this.timestamp_end + 1).fill(-Infinity);
+ }
+ }
+ if (input_ids[i].length === this.begin_index && this.max_initial_timestamp_index !== null) {
+@@ -23808,11 +23810,11 @@ var WhisperTimeStampLogitsProcessor = class extends LogitsProcessor {
+ }
+ const logprobs = log_softmax(batch_logits_data);
+ const timestamp_logprob = Math.log(
+- logprobs.subarray(this.timestamp_begin).map(Math.exp).reduce((a, b) => a + b)
++ logprobs.subarray(this.timestamp_begin, this.timestamp_end + 1).map(Math.exp).reduce((a, b) => a + b)
+ );
+- const max_text_token_logprob = max(logprobs.subarray(0, this.timestamp_begin))[0];
++ const max_text_token_logprob = Math.max(max(logprobs.subarray(0, this.timestamp_begin))[0], logprobs.length > this.timestamp_end + 1 ? max(logprobs.subarray(this.timestamp_end + 1))[0] : -Infinity);
+ if (timestamp_logprob > max_text_token_logprob) {
+- batch_logits_data.subarray(0, this.timestamp_begin).fill(-Infinity);
++ batch_logits_data.subarray(0, this.timestamp_begin).fill(-Infinity), batch_logits_data.subarray(this.timestamp_end + 1).fill(-Infinity);
+ }
+ }
+ return logits;
+diff --git a/node_modules/@huggingface/transformers/dist/transformers.node.mjs b/node_modules/@huggingface/transformers/dist/transformers.node.mjs
+index bacb354..e367085 100644
+--- a/node_modules/@huggingface/transformers/dist/transformers.node.mjs
++++ b/node_modules/@huggingface/transformers/dist/transformers.node.mjs
+@@ -15512,11 +15512,12 @@ var WhisperTokenizer = class extends PreTrainedTokenizer {
+ */
+ decodeWithTimestamps(token_ids, decode_args) {
+ const time_precision = decode_args?.time_precision ?? 0.02;
+- const timestamp_begin = this.all_special_ids.at(-1) + 1;
++ const timestamp_begin = this.timestamp_begin;
++ const timestamp_end = timestamp_begin + 1500;
+ let outputs = [[]];
+ for (let token of token_ids) {
+ token = Number(token);
+- if (token >= timestamp_begin) {
++ if (token >= timestamp_begin && token <= timestamp_end) {
+ const timestamp = ((token - timestamp_begin) * time_precision).toFixed(2);
+ outputs.push(`<|${timestamp}|>`);
+ outputs.push([]);
+@@ -15524,7 +15525,7 @@ var WhisperTokenizer = class extends PreTrainedTokenizer {
+ outputs[outputs.length - 1].push(token);
+ }
+ }
+- outputs = outputs.map((s) => typeof s === "string" ? s : super.decode(s, decode_args));
++ outputs = outputs.map((s) => typeof s === "string" ? s : s.length === 0 ? "" : super.decode(s, decode_args));
+ return outputs.join("");
+ }
+ /**
+@@ -22809,6 +22810,7 @@ var WhisperTimeStampLogitsProcessor = class extends LogitsProcessor {
+ this.eos_token_id = Array.isArray(generate_config.eos_token_id) ? generate_config.eos_token_id[0] : generate_config.eos_token_id;
+ this.no_timestamps_token_id = generate_config.no_timestamps_token_id;
+ this.timestamp_begin = this.no_timestamps_token_id + 1;
++ this.timestamp_end = this.timestamp_begin + 1500;
+ this.begin_index = init_tokens.length;
+ if (init_tokens.at(-1) === this.no_timestamps_token_id) {
+ this.begin_index -= 1;
+@@ -22829,17 +22831,17 @@ var WhisperTimeStampLogitsProcessor = class extends LogitsProcessor {
+ );
+ batch_logits_data[this.no_timestamps_token_id] = -Infinity;
+ if (input_ids[i].length === this.begin_index) {
+- batch_logits_data.subarray(0, this.timestamp_begin).fill(-Infinity);
++ batch_logits_data.subarray(0, this.timestamp_begin).fill(-Infinity), batch_logits_data.subarray(this.timestamp_end + 1).fill(-Infinity);
+ continue;
+ }
+ const seq = input_ids[i].slice(this.begin_index);
+- const last_was_timestamp = seq.length >= 1 && seq[seq.length - 1] >= this.timestamp_begin;
+- const penultimate_was_timestamp = seq.length < 2 || seq[seq.length - 2] >= this.timestamp_begin;
++ const last_was_timestamp = seq.length >= 1 && seq[seq.length - 1] >= this.timestamp_begin && seq[seq.length - 1] <= this.timestamp_end;
++ const penultimate_was_timestamp = seq.length < 2 || (seq[seq.length - 2] >= this.timestamp_begin && seq[seq.length - 2] <= this.timestamp_end);
+ if (last_was_timestamp) {
+ if (penultimate_was_timestamp) {
+- batch_logits_data.subarray(this.timestamp_begin).fill(-Infinity);
++ batch_logits_data.subarray(this.timestamp_begin, this.timestamp_end + 1).fill(-Infinity);
+ } else {
+- batch_logits_data.subarray(0, this.eos_token_id).fill(-Infinity);
++ batch_logits_data.subarray(0, this.eos_token_id).fill(-Infinity), batch_logits_data.subarray(this.timestamp_end + 1).fill(-Infinity);
+ }
+ }
+ if (input_ids[i].length === this.begin_index && this.max_initial_timestamp_index !== null) {
+@@ -22848,11 +22850,11 @@ var WhisperTimeStampLogitsProcessor = class extends LogitsProcessor {
+ }
+ const logprobs = log_softmax(batch_logits_data);
+ const timestamp_logprob = Math.log(
+- logprobs.subarray(this.timestamp_begin).map(Math.exp).reduce((a, b) => a + b)
++ logprobs.subarray(this.timestamp_begin, this.timestamp_end + 1).map(Math.exp).reduce((a, b) => a + b)
+ );
+- const max_text_token_logprob = max(logprobs.subarray(0, this.timestamp_begin))[0];
++ const max_text_token_logprob = Math.max(max(logprobs.subarray(0, this.timestamp_begin))[0], logprobs.length > this.timestamp_end + 1 ? max(logprobs.subarray(this.timestamp_end + 1))[0] : -Infinity);
+ if (timestamp_logprob > max_text_token_logprob) {
+- batch_logits_data.subarray(0, this.timestamp_begin).fill(-Infinity);
++ batch_logits_data.subarray(0, this.timestamp_begin).fill(-Infinity), batch_logits_data.subarray(this.timestamp_end + 1).fill(-Infinity);
+ }
+ }
+ return logits;
+diff --git a/node_modules/@huggingface/transformers/dist/transformers.web.js b/node_modules/@huggingface/transformers/dist/transformers.web.js
+index bb7b327..37a5541 100644
+--- a/node_modules/@huggingface/transformers/dist/transformers.web.js
++++ b/node_modules/@huggingface/transformers/dist/transformers.web.js
+@@ -11617,11 +11617,12 @@ var WhisperTokenizer = class extends PreTrainedTokenizer {
+ */
+ decodeWithTimestamps(token_ids, decode_args) {
+ const time_precision = decode_args?.time_precision ?? 0.02;
+- const timestamp_begin = this.all_special_ids.at(-1) + 1;
++ const timestamp_begin = this.timestamp_begin;
++ const timestamp_end = timestamp_begin + 1500;
+ let outputs = [[]];
+ for (let token of token_ids) {
+ token = Number(token);
+- if (token >= timestamp_begin) {
++ if (token >= timestamp_begin && token <= timestamp_end) {
+ const timestamp = ((token - timestamp_begin) * time_precision).toFixed(2);
+ outputs.push(`<|${timestamp}|>`);
+ outputs.push([]);
+@@ -11629,7 +11630,7 @@ var WhisperTokenizer = class extends PreTrainedTokenizer {
+ outputs[outputs.length - 1].push(token);
+ }
+ }
+- outputs = outputs.map((s) => typeof s === "string" ? s : super.decode(s, decode_args));
++ outputs = outputs.map((s) => typeof s === "string" ? s : s.length === 0 ? "" : super.decode(s, decode_args));
+ return outputs.join("");
+ }
+ /**
+@@ -18923,6 +18924,7 @@ var WhisperTimeStampLogitsProcessor = class extends LogitsProcessor {
+ this.eos_token_id = Array.isArray(generate_config.eos_token_id) ? generate_config.eos_token_id[0] : generate_config.eos_token_id;
+ this.no_timestamps_token_id = generate_config.no_timestamps_token_id;
+ this.timestamp_begin = this.no_timestamps_token_id + 1;
++ this.timestamp_end = this.timestamp_begin + 1500;
+ this.begin_index = init_tokens.length;
+ if (init_tokens.at(-1) === this.no_timestamps_token_id) {
+ this.begin_index -= 1;
+@@ -18943,17 +18945,17 @@ var WhisperTimeStampLogitsProcessor = class extends LogitsProcessor {
+ );
+ batch_logits_data[this.no_timestamps_token_id] = -Infinity;
+ if (input_ids[i].length === this.begin_index) {
+- batch_logits_data.subarray(0, this.timestamp_begin).fill(-Infinity);
++ batch_logits_data.subarray(0, this.timestamp_begin).fill(-Infinity), batch_logits_data.subarray(this.timestamp_end + 1).fill(-Infinity);
+ continue;
+ }
+ const seq = input_ids[i].slice(this.begin_index);
+- const last_was_timestamp = seq.length >= 1 && seq[seq.length - 1] >= this.timestamp_begin;
+- const penultimate_was_timestamp = seq.length < 2 || seq[seq.length - 2] >= this.timestamp_begin;
++ const last_was_timestamp = seq.length >= 1 && seq[seq.length - 1] >= this.timestamp_begin && seq[seq.length - 1] <= this.timestamp_end;
++ const penultimate_was_timestamp = seq.length < 2 || (seq[seq.length - 2] >= this.timestamp_begin && seq[seq.length - 2] <= this.timestamp_end);
+ if (last_was_timestamp) {
+ if (penultimate_was_timestamp) {
+- batch_logits_data.subarray(this.timestamp_begin).fill(-Infinity);
++ batch_logits_data.subarray(this.timestamp_begin, this.timestamp_end + 1).fill(-Infinity);
+ } else {
+- batch_logits_data.subarray(0, this.eos_token_id).fill(-Infinity);
++ batch_logits_data.subarray(0, this.eos_token_id).fill(-Infinity), batch_logits_data.subarray(this.timestamp_end + 1).fill(-Infinity);
+ }
+ }
+ if (input_ids[i].length === this.begin_index && this.max_initial_timestamp_index !== null) {
+@@ -18962,11 +18964,11 @@ var WhisperTimeStampLogitsProcessor = class extends LogitsProcessor {
+ }
+ const logprobs = log_softmax(batch_logits_data);
+ const timestamp_logprob = Math.log(
+- logprobs.subarray(this.timestamp_begin).map(Math.exp).reduce((a, b) => a + b)
++ logprobs.subarray(this.timestamp_begin, this.timestamp_end + 1).map(Math.exp).reduce((a, b) => a + b)
+ );
+- const max_text_token_logprob = max(logprobs.subarray(0, this.timestamp_begin))[0];
++ const max_text_token_logprob = Math.max(max(logprobs.subarray(0, this.timestamp_begin))[0], logprobs.length > this.timestamp_end + 1 ? max(logprobs.subarray(this.timestamp_end + 1))[0] : -Infinity);
+ if (timestamp_logprob > max_text_token_logprob) {
+- batch_logits_data.subarray(0, this.timestamp_begin).fill(-Infinity);
++ batch_logits_data.subarray(0, this.timestamp_begin).fill(-Infinity), batch_logits_data.subarray(this.timestamp_end + 1).fill(-Infinity);
+ }
+ }
+ return logits;
diff --git a/patches/README.md b/patches/README.md
new file mode 100644
index 0000000..743f75f
--- /dev/null
+++ b/patches/README.md
@@ -0,0 +1,70 @@
+# Patches
+
+Applied by `patch-package` from `postinstall`. If a patch fails to apply, install
+fails loudly rather than silently reverting to the buggy behaviour.
+
+**Patched dependencies are pinned to an exact version in `package.json`, not a
+caret range.** A patch filename carries the version it was cut against, and on a
+mismatch `patch-package` only *warns* — so a range would let a minor bump quietly
+drop the fix. That matters more than usual here: the unpatched failure mode below
+is a silently truncated transcript, not an error. When bumping
+`@huggingface/transformers`, re-cut the patch (`npx patch-package
+@huggingface/transformers`) and re-run the CrisperWhisper models against a clip
+containing a filler.
+
+## `@huggingface/transformers` — bound the Whisper timestamp-token range
+
+**Upstream bug.** `WhisperTokenizer` defines the start of the timestamp block as
+`token_to_id("<|notimestamps|>") + 1`, and Whisper has exactly 1500 timestamp
+tokens above it. `_decode_asr` uses both bounds:
+
+```js
+const timestamp_begin = this.timestamp_begin;
+const total_timestamp_tokens = 1500;
+const timestamp_end = timestamp_begin + total_timestamp_tokens;
+```
+
+Two other places test only the lower bound, so **every token above the timestamp
+block is mistaken for a timestamp**:
+
+1. `WhisperTokenizer.decodeWithTimestamps` — additionally re-derives the start as
+ `all_special_ids.at(-1) + 1` rather than using the getter.
+2. `WhisperTimeStampLogitsProcessor._call` — during generation.
+
+For stock Whisper this is harmless: the vocabulary ends at the timestamp block,
+so there is nothing above it to misclassify. It breaks any derivative that
+extends the vocabulary.
+
+**How it broke Rescript.** CrisperWhisper appends 31 tokens past the block —
+`[UM]`, `[UH]`, 13 vocal events (`[laughter]`, `[breath]`, …) and its prompt
+scaffolding. Two distinct failures, both triggered by the model transcribing a
+filler:
+
+- *Decoding.* `decodeWithTimestamps` split on `[UM]`, leaving an empty token
+ bucket that reached `decode([])` — `token_ids must be a non-empty array of
+ integers`, thrown from `combineTokensIntoWords` part-way through a transcript.
+- *Generation.* The logits processor saw `[UH]` as a timestamp and ran
+ `subarray(0, eos_token_id).fill(-Infinity)`, suppressing every text token and
+ leaving only EOS. Transcription stopped at the first hesitation; the rest of
+ the audio came back as `...` VAD placeholders.
+
+The second one is the quieter of the two — no error, just a silently truncated
+transcript. It is why CrisperWhisper appeared to *never* emit fillers through
+transformers.js while emitting them readily in PyTorch: asking for word
+timestamps suppressed the very tokens the model was chosen for.
+
+**The patch.** Adds `timestamp_end = timestamp_begin + 1500` alongside the
+existing lower bound and range-checks against both, matching what `_decode_asr`
+already does. Also:
+
+- makes `decodeWithTimestamps` use the `timestamp_begin` getter instead of
+ re-deriving it from `all_special_ids`;
+- skips empty buckets in its output map (a leading or doubled timestamp produces
+ one even on stock Whisper — a latent crash);
+- when forcing a timestamp, suppresses text tokens *above* the block too, not
+ just below it;
+- compares against the max text-token logprob on both sides of the block.
+
+**Upstreaming.** Worth a PR — the fix is small and the tokenizer already computes
+the correct bound. Until then this patch is required for the CrisperWhisper
+entries in `lib/models.ts` to work at all.
diff --git a/tests/models-test.ts b/tests/models-test.ts
index f582587..302e746 100644
--- a/tests/models-test.ts
+++ b/tests/models-test.ts
@@ -2,13 +2,13 @@
* Model + transcript-source helpers.
*/
import {
- MAX_VERBATIM_PROMPT_LENGTH,
MODEL_ORDER,
MODELS,
+ // isCrisperModel,
+ // isLocalModel,
isModelId,
isParakeetModel,
isWhisperModel,
- whisperFillerPrompt,
} from "../lib/models";
import { isTranscriptSource } from "../lib/source";
@@ -18,6 +18,7 @@ function assert(cond: boolean, msg: string) {
assert(isWhisperModel("base"), "base is Whisper");
assert(isWhisperModel("small"), "small is Whisper");
+// assert(isWhisperModel("medium"), "medium is Whisper");
assert(!isWhisperModel("parakeet"), "parakeet is not Whisper");
assert(isParakeetModel("parakeet"), "parakeet is Parakeet");
assert(isModelId("parakeet"), "parakeet is a model id");
@@ -37,9 +38,18 @@ assert(typeof MODELS.parakeet.label === "string", "parakeet label");
assert(MODELS.base.backend === "whisper", "base backend");
assert(typeof MODELS.base.id === "string", "whisper base id");
assert(typeof MODELS.small.id === "string", "whisper small id");
+// assert(typeof MODELS.medium.id === "string", "whisper medium id");
assert(MODELS.base.dtype.webgpu.encoder_model === "fp32", "whisper dtype");
-assert(MODELS.base.keepFillers === true, "base keeps fillers");
-assert(MODELS.small.keepFillers === true, "small keeps fillers");
+// Medium deviates from WHISPER_DTYPE on purpose: its fp32 encoder export is
+// 1.2 GB. Pin the split so a later dtype tidy-up cannot quietly reinstate it.
+// assert(
+// MODELS.medium.dtype.wasm.encoder_model === "int8",
+// "medium encoder is int8 on wasm"
+// );
+// assert(
+// MODELS.medium.dtype.webgpu.encoder_model === "fp16",
+// "medium encoder is fp16 on webgpu"
+// );
assert(
MODEL_ORDER.includes("parakeet") && MODEL_ORDER.includes("base"),
@@ -51,12 +61,41 @@ for (const id of MODEL_ORDER) {
assert(typeof MODELS[id].size === "string", `${id} has size`);
}
-assert(whisperFillerPrompt("base", "en") === "Um, uh, hmm, er, ah.", "en prompt");
-assert(whisperFillerPrompt("small", "de") === "Äh, ähm, öhm, mhh.", "de prompt");
-assert(whisperFillerPrompt("parakeet", "en") === null, "parakeet has no prompt");
-assert(
- (whisperFillerPrompt("base", "en")?.length ?? 0) <= MAX_VERBATIM_PROMPT_LENGTH,
- "en prompt within length cap"
-);
+// Turbo runs fp16 on the merged decoder, not q4: on q4 it collapsed after the
+// first VAD segment, and bisection cleared the encoder (a q4 encoder under an
+// fp32 decoder reproduces the fp32 transcript exactly). fp16 is also 477 MB
+// against q4's 600 MB. Pinned so a dtype tidy-up cannot fold it back into the
+// Small config.
+// assert(
+// MODELS.crisperTurbo.dtype.wasm.decoder_model_merged === "fp16" &&
+// MODELS.crisperTurbo.dtype.webgpu.decoder_model_merged === "fp16",
+// "crisperTurbo decodes in fp16"
+// );
+// assert(
+// MODELS.crisperSmall.dtype.wasm.decoder_model_merged === "q4",
+// "crisperSmall keeps the verified q4 decoder"
+// );
+
+// The tokenizer fix-up is required by CrisperWhisper's vocabulary layout: the
+// extended tokens that break word collation are emitted whether or not the
+// decoder is primed with anything.
+// assert(isCrisperModel("crisperSmall"), "crisperSmall needs the tokenizer fix-up");
+// assert(isCrisperModel("crisperTurbo"), "crisperTurbo needs the tokenizer fix-up");
+// assert(!isCrisperModel("base"), "base needs no tokenizer fix-up");
+// assert(!isCrisperModel("parakeet"), "parakeet needs no tokenizer fix-up");
+
+// Only the unpublished export is served from public/models; a stray `local`
+// flag on a Hub model would send transformers.js to a path that 404s.
+// assert(isLocalModel("crisperSmall"), "crisperSmall is served locally");
+// assert(!isLocalModel("crisperTurbo"), "crisperTurbo loads from the Hub");
+// assert(!isLocalModel("base"), "base loads from the Hub");
+// assert(
+// !MODELS.crisperSmall.id.includes("/"),
+// "local model id is a public/models folder name, not a Hub repo id"
+// );
+// assert(
+// MODELS.crisperTurbo.id === "Masterx/CrisperWhisper2.0-turbo-ONNX",
+// "crisperTurbo Hub id"
+// );
console.log("models-test: ok");
diff --git a/tests/vad-regression-test.ts b/tests/vad-regression-test.ts
index 37c5fdc..0341ec9 100644
--- a/tests/vad-regression-test.ts
+++ b/tests/vad-regression-test.ts
@@ -14,15 +14,6 @@ import {
speechSegmentsFromFrames,
VAD_SAMPLE_RATE,
} from "../lib/vad";
-import {
- MAX_VERBATIM_PROMPT_LENGTH,
- MODELS,
- whisperFillerPrompt,
-} from "../lib/models";
-import {
- TRANSCRIPT_LANGUAGE_ORDER,
- type TranscriptLanguage,
-} from "../lib/languages";
const wav = path.join(
process.cwd(),
@@ -38,35 +29,22 @@ function ffmpegAvailable(): boolean {
return probe.status === 0;
}
-// Short filler prompts are OK; long ones truncate multi-speaker / long-form ASR.
-for (const id of Object.keys(MODELS) as (keyof typeof MODELS)[]) {
- const info = MODELS[id];
- if (info.backend !== "whisper") continue;
- assert(info.keepFillers === true, `${id} should keep fillers via a short prompt`);
- for (const language of TRANSCRIPT_LANGUAGE_ORDER) {
- const prompt = whisperFillerPrompt(id, language);
- assert(!!prompt, `${id}/${language} must resolve a filler prompt`);
- assert(
- prompt!.length <= MAX_VERBATIM_PROMPT_LENGTH,
- `${id}/${language} filler prompt too long (${prompt!.length} > ${MAX_VERBATIM_PROMPT_LENGTH})`
- );
- assert(
- !/\bI'm\b/i.test(prompt!),
- `${id}/${language} filler prompt must not contain "I'm" (seeds hallucination loops)`
- );
- }
-}
-assert(
- whisperFillerPrompt("parakeet", "en" as TranscriptLanguage) === null,
- "parakeet must not use Whisper filler prompts"
-);
-console.log("whisper filler prompts stay short: ok");
-
const workerSrc = fs.readFileSync(
path.join(process.cwd(), "workers/transcription.worker.ts"),
"utf8"
);
+// No decoder-prefix conditioning. Both mechanisms tried — Whisper's
+// <|startofprev|> filler prompt and CrisperWhisper's [verbatim_N] mode tags —
+// collapse short VAD segments: Small's longest slice decoded to a bare "Um,",
+// Medium truncated to a fragment, Turbo dropped a leading clause. See the note
+// above MODELS in lib/models.ts for the measurements.
+assert(
+ !/decoder_input_ids/.test(workerSrc),
+ "worker must not force decoder_input_ids (prefix conditioning truncates VAD segments)"
+);
+console.log("no decoder-prefix conditioning: ok");
+
// High repetition_penalty truncates this clip mid-utterance even on full audio.
const penaltyMatch = workerSrc.match(/repetition_penalty:\s*([0-9.]+)/);
if (!penaltyMatch) throw new Error("worker must set repetition_penalty");
diff --git a/workers/transcription.worker.ts b/workers/transcription.worker.ts
index 5402d11..6303851 100644
--- a/workers/transcription.worker.ts
+++ b/workers/transcription.worker.ts
@@ -32,7 +32,7 @@ import {
MODELS,
isParakeetModel,
isWhisperModel,
- whisperFillerPrompt,
+ isCrisperModel,
type ModelId,
type WhisperModel,
} from "@/lib/models";
@@ -49,6 +49,14 @@ import {
import { isWebGpuDeviceLostError } from "@/lib/webgpu";
env.allowLocalModels = false;
+/**
+ * Where {@link MODELS} entries flagged `local` are served from — an export that
+ * has not been published to the Hub yet, sitting in public/models//.
+ * Enabled only for the duration of such a load (see `servedLocally`), because
+ * `allowLocalModels` is global: left on, every Hub model would probe this path
+ * and 404 for each of its files before falling back.
+ */
+const LOCAL_MODEL_PATH = "/models/";
const ORT_WASM_PATHS = "/vendor/ort/";
/** Parakeet.js pins onnxruntime-web@1.24.1 — keep its WASM on a separate path. */
const PARAKEET_ORT_WASM_PATHS = "/vendor/ort-parakeet/";
@@ -57,6 +65,25 @@ if (env.backends?.onnx?.wasm) {
env.backends.onnx.wasm.wasmPaths = ORT_WASM_PATHS;
}
+/**
+ * WebKit — Safari everywhere, plus every browser on iOS — kills the tab for
+ * memory far sooner than Chromium ("This webpage was reloaded because it was
+ * using significant memory"), and onnxruntime's WebGPU path is what pushes it
+ * over. That path loads the JSEP build (26 MB of wasm against 13 MB for the
+ * plain threaded one, all compiled up front by JSC) and then uploads every
+ * weight into Metal buffers during session creation, which is precisely where
+ * the reload lands. Staying on WASM costs throughput but is the difference
+ * between finishing a transcript and losing the tab mid-run.
+ *
+ * Sniffed rather than feature-detected on purpose: there is nothing to detect.
+ * WebGPU is present and functional here — it is the memory ceiling around it
+ * that differs, and no API reports that. `vendor` is frozen to Apple's string
+ * across WebKit, which is the exact set of engines affected.
+ */
+if (/apple/i.test(navigator.vendor)) {
+ fallbackDevicePolicy.preferWasm();
+}
+
const DIARIZATION_MODEL = "onnx-community/pyannote-segmentation-3.0";
const VAD_MODEL = "onnx-community/silero-vad";
/** Gaps longer than this split speech into separate Whisper jobs. */
@@ -166,21 +193,35 @@ function parakeetModel(): ModelDefinition {
});
};
const common = {
- preprocessorBackend: "js" as const,
+ // nemo128.onnx is NeMo's own featurisation graph (0.1 MB). The "js"
+ // alternative is a hand-written mel — its own FFT and slaney filterbank —
+ // and any drift from NeMo's exact features degrades every prediction in
+ // a way that reads as "the model is just worse".
+ preprocessorBackend: "onnx" as const,
progress: onProgress,
wasmPaths: PARAKEET_ORT_WASM_PATHS,
};
- // WebGPU cannot run the int8 encoder; fp16 (~1.2 GB) is the practical
- // WebGPU path. WASM int8 (~670 MB) is the compatibility / size fallback.
+ /**
+ * Encoder quantisation is a size decision; decoder quantisation is not.
+ *
+ * In a TDT model the decoder/joint network is what emits tokens, so
+ * quantising it lands directly on word accuracy — and it is tiny next to
+ * the encoder (fp32 72 MB, fp16 36 MB, int8 18 MB, against 1239 MB for
+ * the fp16 encoder). parakeet.js defaults both to int8; taking that
+ * default for the decoder traded measurable accuracy for ~1% of the
+ * download, so it is set explicitly here instead.
+ */
const device = await fallbackDevicePolicy.pickDevice();
if (device === "webgpu") {
try {
+ // fp16 encoder + fp32 decoder ≈ 1.31 GB. WebGPU cannot run the int8
+ // encoder at all, so fp16 is the only practical encoder here.
const model = await fromHub(MODELS.parakeet.id, {
...common,
backend: "webgpu",
encoderQuant: "fp16",
- decoderQuant: "int8",
+ decoderQuant: "fp32",
});
asrDevice = "webgpu";
return model as ParakeetInstance;
@@ -193,11 +234,13 @@ function parakeetModel(): ModelDefinition {
}
}
+ // int8 encoder + fp16 decoder ≈ 690 MB: the compatibility / size fallback,
+ // keeping the decoder off int8 for the reason above.
const model = await fromHub(MODELS.parakeet.id, {
...common,
backend: "wasm",
encoderQuant: "int8",
- decoderQuant: "int8",
+ decoderQuant: "fp16",
});
asrDevice = "wasm";
return model as ParakeetInstance;
@@ -205,6 +248,37 @@ function parakeetModel(): ModelDefinition {
};
}
+/**
+ * Flip `env.allowLocalModels` on for one model's load and back afterwards.
+ *
+ * transformers.js resolves local-vs-Hub from global state, so a model served
+ * from public/models can only be reached by enabling it — but leaving it
+ * enabled makes every Hub model try the local path first and 404 once per
+ * file. Scoping it to the load keeps both paths clean.
+ */
+function servedLocally(definition: ModelDefinition): ModelDefinition {
+ const withLocalPath = async (fn: () => Promise): Promise => {
+ const previousAllow = env.allowLocalModels;
+ const previousPath = env.localModelPath;
+ env.allowLocalModels = true;
+ env.localModelPath = LOCAL_MODEL_PATH;
+ try {
+ return await fn();
+ } finally {
+ env.allowLocalModels = previousAllow;
+ env.localModelPath = previousPath;
+ }
+ };
+
+ return {
+ ...definition,
+ load: (ctx) => withLocalPath(() => definition.load(ctx)),
+ ...(definition.isCached
+ ? { isCached: () => withLocalPath(async () => definition.isCached!()) }
+ : {}),
+ };
+}
+
/**
* ASR registry keyed by each model's `id` from MODELS. Definitions are
* registered up front; loaders only take an id. unloadAll() after a WebGPU
@@ -217,19 +291,17 @@ const models = new ModelManager({
if (info.backend === "parakeet") {
return [info.id, parakeetModel()];
}
- return [
- info.id,
- transformersModel({
- pipeline,
- task: "automatic-speech-recognition",
- modelId: info.id,
- dtype: info.dtype,
- cacheKey: env.cacheKey ?? "transformers-cache",
- onDevice: (device) => {
- asrDevice = device;
- },
- }),
- ];
+ const definition = transformersModel({
+ pipeline,
+ task: "automatic-speech-recognition",
+ modelId: info.id,
+ dtype: info.dtype,
+ cacheKey: env.cacheKey ?? "transformers-cache",
+ onDevice: (device) => {
+ asrDevice = device;
+ },
+ });
+ return [info.id, info.local ? servedLocally(definition) : definition];
})
),
});
@@ -248,8 +320,75 @@ models.subscribe((snap) => {
});
});
+/**
+ * CrisperWhisper's prompt scaffolding: mode tags plus the verbatimize / hotword
+ * / continuation markers. All sit at the very top of the vocabulary, above the
+ * timestamp block.
+ */
+const CRISPER_PROMPT_TOKENS = [
+ ...[1, 2, 3, 4, 5].map((i) => `[verbatim_${i}]`),
+ ...[1, 2, 3, 4, 5].map((i) => `[intended_${i}]`),
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+];
+
+/**
+ * Register CrisperWhisper's prompt scaffolding as special so it can never
+ * surface as literal text in a transcript.
+ *
+ * These are the `[verbatim_N]` / `[intended_N]` mode tags and the
+ * verbatimize / hotword / context markers — decoder-prompt machinery, not
+ * speech. `_decode_asr` skips anything in `all_special_ids`, which is the only
+ * hook for keeping them out of the output.
+ *
+ * This used to carry a second job: raising `all_special_ids.at(-1)` above the
+ * whole vocabulary so `decodeWithTimestamps` would stop mistaking `[UM]` and
+ * `[UH]` for timestamps. That was a workaround for an upstream bug, now fixed
+ * properly in patches/@huggingface+transformers+4.2.0.patch — see
+ * patches/README.md. Only the narrow purpose above remains.
+ *
+ * Idempotent, so re-running it after a WebGPU-to-WASM reload is harmless.
+ */
+function markCrisperPromptTokensSpecial(
+ transcriber: AutomaticSpeechRecognitionPipeline
+): void {
+ try {
+ // Tokenizer internals are untyped in transformers.js.
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const tokenizer = transcriber.tokenizer as any;
+ const ids = new Set(tokenizer.all_special_ids ?? []);
+ const before = ids.size;
+ for (const token of CRISPER_PROMPT_TOKENS) {
+ const encoded = tokenizer.encode(token, { add_special_tokens: false });
+ // Anything that does not map to exactly one id is not the atomic token we
+ // are looking for — skip rather than guess.
+ if (encoded?.length === 1) ids.add(encoded[0]);
+ }
+ if (ids.size === before) return;
+ // Sorted because the workaround depends on `.at(-1)` being the maximum.
+ tokenizer.all_special_ids = [...ids].sort((a, b) => a - b);
+ } catch {
+ console.warn(
+ "Could not mark CrisperWhisper prompt tokens as special; " +
+ "word timestamps may fail on the first filler token."
+ );
+ }
+}
+
async function getAsr(choice: WhisperModel) {
- return models.load(MODELS[choice].id);
+ const transcriber = await models.load(
+ MODELS[choice].id
+ );
+ // Keyed on the checkpoint, not on the prefix: the repair is required by
+ // CrisperWhisper's vocabulary layout, so it applies even without one.
+ if (isCrisperModel(choice)) {
+ markCrisperPromptTokensSpecial(transcriber);
+ }
+ return transcriber;
}
async function getParakeet() {
@@ -272,45 +411,6 @@ async function fallbackAsrToWasm() {
});
}
-/**
- * Build decoder input tokens implementing Whisper's "initial prompt"
- * conditioning: `<|startofprev|> …prompt… <|startoftranscript|> <|lang|>
- * <|transcribe|>`. transformers.js documents `prompt_ids` but does not
- * implement it, so the tokens are constructed manually and passed as
- * `decoder_input_ids` (which `generate` honors for every chunk). Returns
- * null if any required token cannot be resolved.
- */
-function buildPromptedDecoderIds(
- transcriber: AutomaticSpeechRecognitionPipeline,
- prompt: string,
- language: string
-): number[] | null {
- try {
- // Tokenizer/config internals are untyped in transformers.js.
- /* eslint-disable @typescript-eslint/no-explicit-any */
- const tokenizer = transcriber.tokenizer as any;
- const genCfg = (transcriber.model as any).generation_config;
- /* eslint-enable @typescript-eslint/no-explicit-any */
- const startOfPrev = tokenizer.encode("<|startofprev|>", { add_special_tokens: false });
- const promptIds = tokenizer.encode(" " + prompt.trim(), { add_special_tokens: false });
- const startId = genCfg?.decoder_start_token_id;
- const langId = genCfg?.lang_to_id?.[`<|${language}|>`];
- const taskId = genCfg?.task_to_id?.["transcribe"];
- if (
- startOfPrev?.length !== 1 ||
- !promptIds?.length ||
- startId == null ||
- langId == null ||
- taskId == null
- ) {
- return null;
- }
- return [startOfPrev[0], ...promptIds, startId, langId, taskId];
- } catch {
- return null;
- }
-}
-
interface DiarizationSegment {
id: number;
start: number;
@@ -688,16 +788,6 @@ async function runWhisper(
const { segments: speechSegments, frames: speechFrames } =
await detectSpeechSegments(audio, vad);
- // Short filler-rich <|startofprev|> prompt so um/uh survive for "Remove
- // fillers". Long prompts truncate multi-speaker clips — see whisperFillerPrompt.
- const fillerPrompt = whisperFillerPrompt(choice, transcriptLanguage);
- const promptedIds = fillerPrompt
- ? buildPromptedDecoderIds(transcriber, fillerPrompt, transcriptLanguage)
- : null;
- if (fillerPrompt && !promptedIds) {
- console.warn("Could not build filler prompt tokens; using default decoding.");
- }
-
const speechSamples = speechSegments.reduce(
(n, s) => n + (s.endSample - s.startSample),
0
@@ -765,9 +855,11 @@ async function runWhisper(
// mid-utterance (second speaker dropped on continuous speech).
no_repeat_ngram_size: 4,
repetition_penalty: 1.05,
- ...(promptedIds
- ? { decoder_input_ids: promptedIds }
- : { language: transcriptLanguage }),
+ // Plain decoding — no forced decoder prefix. Priming the decoder collapses
+ // short VAD segments on every model here, whether with Whisper's
+ // <|startofprev|> filler prompt or CrisperWhisper's mode tags. See the note
+ // above MODELS in lib/models.ts; vad-regression-test.ts guards it.
+ language: transcriptLanguage,
};
const rawWords: Word[] = [];
@@ -841,7 +933,23 @@ async function runWhisper(
chunks = await runSlice();
}
- rawWords.push(...wordsFromChunks(chunks, offsetS, sliceDuration, duration));
+ const words = wordsFromChunks(chunks, offsetS, sliceDuration, duration);
+ // A segment that decodes to nothing is the signature of a model or prompt
+ // that has collapsed on this slice — the timeline fills with "..." VAD
+ // placeholders and the transcript silently loses a stretch of speech. It is
+ // indistinguishable from genuine silence downstream, so say so here, and
+ // report enough to tell "ASR returned nothing" apart from "words were
+ // produced and then dropped in post-processing".
+ if (chunks.length === 0 || words.length === 0) {
+ console.warn(
+ `[asr] ${choice}: segment ${offsetS.toFixed(2)}s +${sliceDuration.toFixed(2)}s ` +
+ `produced ${chunks.length} chunk(s) → ${words.length} word(s).`,
+ chunks.length > 0
+ ? { text: chunks.map((c) => c.text).join(""), chunks }
+ : "(model returned no chunks)"
+ );
+ }
+ rawWords.push(...words);
speechDone += segmentSamples;
reportProgress(0, 0);
}