120 lines
4.4 KiB
TypeScript
120 lines
4.4 KiB
TypeScript
/**
|
|
* 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}.`;
|
|
}
|