diff --git a/.gitignore b/.gitignore index 10f0dc8..7f0fda5 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,11 @@ /electron-dist/ /dist/ +# Locally-served speech models (tools/crisperwhisper-onnx/install_local.py). +# Hundreds of MB of ONNX weights, reproducible from the export tooling, and +# non-commercially licensed — they must not be committed. +/public/models/ + # misc .DS_Store *.pem diff --git a/components/ExportDialog.tsx b/components/ExportDialog.tsx index c2c59a0..74c5c4d 100644 --- a/components/ExportDialog.tsx +++ b/components/ExportDialog.tsx @@ -407,7 +407,7 @@ export default function ExportDialog() { )} {error && ( -

+

{error}

)} diff --git a/components/ModelSelector.tsx b/components/ModelSelector.tsx index 11c6409..6b56c91 100644 --- a/components/ModelSelector.tsx +++ b/components/ModelSelector.tsx @@ -8,6 +8,7 @@ import { useId, useMemo, useState, + type ComponentType, type ReactNode, } from "react"; import { @@ -18,8 +19,12 @@ import { FileText, Languages, Loader2, - type LucideIcon, } from "lucide-react"; +import { + SignalBarsHigh, + SignalBarsLow, + SignalBarsMedium, +} from "./SignalBars"; import { TRANSCRIPT_LANGUAGE_ORDER, TRANSCRIPT_LANGUAGES, @@ -50,9 +55,15 @@ export type ModelOptionContextValue = { keepMenuOpen: () => void; }; +/** Any `size` + `className` icon: lucide's, or a local one like {@link SignalBarsLow}. */ +export type IconComponent = ComponentType<{ + size?: number | string; + className?: string; +}>; + export type OptionTrigger = { label: ReactNode; - icon?: LucideIcon; + icon?: IconComponent; iconClassName?: string; /** Show a spinner instead of the icon in the closed trigger. */ busy?: boolean; @@ -301,12 +312,26 @@ export default function ModelSelector({ ); } +/** + * Signal bars stand in for relative model strength on the default rows. + * + * A plain lookup rather than a `iconForSource(id)` helper: the row renders this + * value as JSX, and react-hooks/static-components reads any call result used as + * a component type as a component created during render. + */ +const SOURCE_ICONS: Record = { + base: SignalBarsLow, + small: SignalBarsMedium, + parakeet: SignalBarsHigh, + import: FileText, +}; + /** Default option row: icon + label + optional meta. ASR ids fill in from MODELS. */ export function ModelOption({ id, label, meta, - icon: Icon = AudioLines, + icon, children, onSelect, /** When false, a child owns the closed trigger via `useOptionTrigger`. */ @@ -315,7 +340,7 @@ export function ModelOption({ id: TranscriptSource; label?: string; meta?: string; - icon?: LucideIcon; + icon?: IconComponent; children?: ReactNode; onSelect?: (ctx: ModelOptionContextValue) => void; autoTrigger?: boolean; @@ -323,6 +348,7 @@ export function ModelOption({ const selector = useSelectorCtx(); const selected = selector.value === id; + const Icon = icon ?? SOURCE_ICONS[id]; const resolvedLabel = label ?? (isModelId(id) ? MODELS[id].label : id); const resolvedMeta = meta ?? (isModelId(id) ? MODELS[id].size : undefined); @@ -355,11 +381,10 @@ export function ModelOption({ role="option" aria-selected={selected} onClick={handleClick} - className={`flex w-full flex-col gap-0.5 rounded-lg px-2.5 py-2 text-left transition cursor-pointer ${ - selected + className={`flex w-full flex-col gap-0.5 rounded-lg px-2.5 py-2 text-left transition cursor-pointer ${selected ? "bg-zinc-100 text-zinc-900 dark:bg-zinc-800 dark:text-zinc-50" : "text-zinc-700 hover:bg-zinc-50 dark:text-zinc-300 dark:hover:bg-zinc-800/60" - }`} + }`} > !v); selector.keepMenuOpen(); }} - className={`flex w-full cursor-pointer items-center gap-2.5 rounded-lg px-2.5 py-2 text-left transition ${ - submenuOpen + className={`flex w-full cursor-pointer items-center gap-2.5 rounded-lg px-2.5 py-2 text-left transition ${submenuOpen ? "bg-zinc-100 text-zinc-900 dark:bg-zinc-800 dark:text-zinc-50" : "text-zinc-700 hover:bg-zinc-50 dark:text-zinc-300 dark:hover:bg-zinc-800/60" - }`} + }`} > {active.flag} @@ -439,9 +463,8 @@ export function LanguageSection() { @@ -462,11 +485,10 @@ export function LanguageSection() { role="menuitemradio" aria-checked={selected} onClick={() => select(id)} - className={`flex w-full cursor-pointer items-center gap-2.5 rounded-lg px-2.5 py-2 text-left transition ${ - selected + className={`flex w-full cursor-pointer items-center gap-2.5 rounded-lg px-2.5 py-2 text-left transition ${selected ? "bg-zinc-100 text-zinc-900 dark:bg-zinc-800 dark:text-zinc-50" : "text-zinc-700 hover:bg-zinc-50 dark:text-zinc-300 dark:hover:bg-zinc-800/60" - }`} + }`} > {option.flag} diff --git a/components/SignalBars.tsx b/components/SignalBars.tsx new file mode 100644 index 0000000..5c24dba --- /dev/null +++ b/components/SignalBars.tsx @@ -0,0 +1,67 @@ +/** + * Signal-strength bars for the model rows, drawn here rather than taken from + * lucide: `SignalLow` and friends omit the bars above the level, so the three + * model rows would each get a differently-shaped, differently-sized glyph. + * These always draw all three bars and fade the inactive ones, so the rows share + * one silhouette and read as "level N of 3". + */ + +export type SignalBarsProps = { + size?: number | string; + className?: string; +}; + +/** x position and top y of each bar, shortest first. Bars sit on y = 19. */ +const BARS: ReadonlyArray<{ x: number; top: number }> = [ + { x: 5.75, top: 14.5 }, + { x: 12, top: 10.25 }, + { x: 18.25, top: 6 }, +]; + +const BASELINE = 19; +/** Matches the 2px stroke of the lucide icons beside it, plus round caps. */ +const STROKE = 2.5; +const INACTIVE_OPACITY = 0.25; + +function SignalBars({ + level, + size = 24, + className, +}: SignalBarsProps & { level: 1 | 2 | 3 }) { + return ( + + {BARS.map((bar, i) => ( + + ))} + + ); +} + +export function SignalBarsLow(props: SignalBarsProps) { + return ; +} + +export function SignalBarsMedium(props: SignalBarsProps) { + return ; +} + +export function SignalBarsHigh(props: SignalBarsProps) { + return ; +} diff --git a/components/SpeakerLabel.tsx b/components/SpeakerLabel.tsx index 1b0d52e..08a5697 100644 --- a/components/SpeakerLabel.tsx +++ b/components/SpeakerLabel.tsx @@ -275,7 +275,7 @@ export default function SpeakerLabel({ ? "Find a speaker…" : "Search or create…" } - className="w-full rounded-lg border border-zinc-200 bg-zinc-50 px-2 py-1.5 text-[13px] text-zinc-800 outline-none focus:border-zinc-400 focus:bg-white dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100 dark:focus:border-zinc-500" + className="w-full rounded-lg border border-zinc-200 bg-zinc-50 px-2 py-1.5 text-[13px] text-zinc-800 outline-none focus:border-zinc-400 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100 dark:focus:border-zinc-500" /> @@ -481,7 +481,7 @@ export function SelectionSpeakerPopover({ else if (canCreate) apply("new", trimmedQuery); }} placeholder="Search or create…" - className="w-full rounded-lg border border-zinc-200 bg-zinc-50 px-2 py-1.5 text-[13px] text-zinc-800 outline-none focus:border-zinc-400 focus:bg-white dark:border-zinc-600 dark:bg-zinc-900 dark:text-zinc-100 dark:focus:border-zinc-400" + className="w-full rounded-lg border border-zinc-200 bg-zinc-50 px-2 py-1.5 text-[13px] text-zinc-800 outline-none focus:border-zinc-400 dark:border-zinc-600 dark:bg-zinc-900 dark:text-zinc-100 dark:focus:bg-zinc-900 dark:focus:border-zinc-400" />
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); }