/** * The optional "Names and terms" vocabulary hint (D-048): free text the user * types once, remembered in this browser, and fed to Whisper as a decoder * prompt (`<|startofprev|> hint …`) so names and jargon come out spelled the * way the user spells them. * * Pure and dependency-free so the UI, the worker (src/worker/prompt.ts) and * the server (src/server/transcribe.ts) share one normalization rule, and the * truncation rule below has a Python twin in server-py/sidecar.py * (`fit_prompt_ids`, parity-tested in tests/hint-parity.test.ts). */ import type { StorageLike } from "./engine"; export const HINT_STORAGE_KEY = "whisper-vocabulary-hint"; /** * Longest raw hint accepted (textarea `maxLength`; the server answers 400 * above it). The hint travels as a URL query parameter on the server engine, * so it must stay well below URL limits even fully percent-encoded. */ export const HINT_MAX_CHARS = 1000; /** * Prompt budget in tokens (without `<|startofprev|>`). Whisper itself allows * 223 (half its 448 decoder positions, minus one — the openai-whisper and * faster-whisper cap), but measured on en_alien a prompt that long garbles * small models: base.en WER 0.339 → 0.476 with a realistic 218-token list and * → 1.05 with a synthetic one, while ≤ 120 tokens stays at or below the * no-hint WER (D-048). */ export const PROMPT_TOKEN_BUDGET = 120; /** C0 controls + DEL, except \n and \r (line separators, handled below). Tab stays whitespace. */ const CONTROL_CHARS = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g; /** * Canonical prompt text: one term per line or comma-separated, both become * "a, b, c"; control characters dropped; whitespace collapsed. "" = no hint. */ export function normalizeHint(raw: string): string { return raw .replace(CONTROL_CHARS, "") .split(/\r?\n|\r/) .map((line) => line.replace(/\s+/g, " ").trim()) .filter((line) => line.length > 0) .join(", "); } /** Saved hint (raw, as typed); "" when nothing is stored or storage is unavailable. */ export function loadHint(storage: StorageLike | null = defaultStorage()): string { try { return storage?.getItem(HINT_STORAGE_KEY) ?? ""; } catch { return ""; } } export function saveHint(hint: string, storage: StorageLike | null = defaultStorage()): void { try { storage?.setItem(HINT_STORAGE_KEY, hint); } catch { // Not persistable (private mode / quota) — the hint just won't stick. } } function defaultStorage(): StorageLike | null { try { return (globalThis as { localStorage?: StorageLike }).localStorage ?? null; } catch { return null; } } /** * Token ids for the hint as a Whisper prompt (without `<|startofprev|>`), * truncated predictably to `budget` tokens (D-048): * * 1. Prompt text = `" " + normalizeHint(hint)` (Whisper prompts start with a * space), ending in a full stop unless it already ends in . ? or ! — * measured: an unterminated list makes Whisper drop punctuation and, on * smaller models, fall into repetition loops (D-048). * 2. Over budget → keep the longest prefix of whole space-separated words that * fits (full stop included), a dangling "," / ";" removed: the start of the * list is kept, the end dropped, never mid-word. * 3. Not even the first word fits → its first `budget` tokens. * * `encode` must add no special tokens. Mirrored by `fit_prompt_ids` in * server-py/sidecar.py — change both together. */ export function fitHintToBudget( hint: string, encode: (text: string) => number[], budget: number = PROMPT_TOKEN_BUDGET, ): number[] { const text = normalizeHint(hint); if (text.length === 0) return []; const full = encode(promptSentence(text)); if (full.length <= budget) return full; const words = text.split(" "); const encodePrefix = (count: number) => encode(promptSentence(words.slice(0, count).join(" ").replace(/[,;]+$/, ""))); // Largest word count whose encoding fits; token count grows with the // prefix, so binary search needs O(log n) encodes even for huge pastes. let low = 0; let high = words.length - 1; // the full text is already known not to fit while (low < high) { const mid = Math.ceil((low + high) / 2); if (encodePrefix(mid).length <= budget) low = mid; else high = mid - 1; } if (low === 0) return encode(` ${words[0]}`).slice(0, budget); return encodePrefix(low); } function promptSentence(text: string): string { return /[.?!]$/.test(text) ? ` ${text}` : ` ${text}.`; }