diff --git a/DESIGN.md b/DESIGN.md index e6dd7c9..ad62958 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -28,6 +28,6 @@ exists, not an aspiration. Update in the same commit as visual changes. ## Components One BEM-ish class block per React component in `src/components/` (`.model-picker`, `.dropzone`, -`.live-controls`, `.engine-toggle`, `.auth-panel`, `.update-toast`, …), with modifier classes like +`.live-controls`, `.engine-toggle`, `.auth-panel`, `.update-toast`, `.vocabulary-hint`, …), with modifier classes like `-selected`, `-active`, `-disabled`. Interactive states use `:hover:enabled` and `:focus-visible`. `.visually-hidden` exists for screen-reader-only text. diff --git a/src/App.tsx b/src/App.tsx index e282021..2962df0 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -10,6 +10,8 @@ import { ModelPicker } from "./components/ModelPicker"; import { StorageManager } from "./components/StorageManager"; import { TranscriptView } from "./components/TranscriptView"; import { TranscriptionOptions } from "./components/TranscriptionOptions"; +import { VocabularyHint } from "./components/VocabularyHint"; +import { loadHint, saveHint } from "./lib/hint"; import { decodeAudioFile } from "./lib/audio"; import { classifyError, type ClassifiedError } from "./lib/errors"; import { @@ -164,6 +166,9 @@ export function App() { const [activeThreads, setActiveThreads] = useState(null); const [language, setLanguage] = useState(AUTO_LANGUAGE); const [task, setTask] = useState(DEFAULT_TASK); + // "Names and terms" vocabulary hint (D-WS2a): raw text, remembered in this + // browser, sent with every pass for every model (`.en` included). + const [hint, setHint] = useState(() => loadHint()); const [downloaded, setDownloaded] = useState>(new Set()); // PWA state (M3, D-007): is a service worker controlling this page (app @@ -676,6 +681,7 @@ export function App() { // take neither. The browser engine ignores modelId (worker holds it). modelId, ...(multilingual && { language, task }), + prompt: hint, onPartial: (fragment) => { if (fileRun.current === run) setPartialText((text) => text + fragment); }, @@ -713,7 +719,7 @@ export function App() { /** One inference pass over a live segment, correlated via requestId. */ async function transcribeLiveSegment( audio: Float32Array, - options: { language?: string; task?: WhisperTask }, + options: { language?: string; task?: WhisperTask; prompt?: string }, ): Promise { // Wait for the pipeline before posting: a load-restart (D-025) may swap // the worker while the model loads, and a pass queued in the old worker @@ -752,12 +758,17 @@ export function App() { setLivePending(0); setLiveStatus("starting"); - const options = (getModel(modelId)?.multilingual ?? false) ? { language, task } : {}; + const options = { + ...((getModel(modelId)?.multilingual ?? false) && { language, task }), + // Same hint for every live pass of the session (D-WS2a). + ...(hint && { prompt: hint }), + }; if (useServer) { const wsUrl = new URL(`/api/transcribe/live?model=${modelId}`, window.location.href); wsUrl.protocol = wsUrl.protocol === "https:" ? "wss:" : "ws:"; if (options.language) wsUrl.searchParams.set("language", options.language); + if (options.prompt) wsUrl.searchParams.set("prompt", options.prompt); if (options.task) wsUrl.searchParams.set("task", options.task); const ws = new WebSocket(wsUrl); @@ -1042,6 +1053,15 @@ export function App() { /> )} + { + setHint(value); + saveHint(value); + }} + disabled={busy || liveActive} + /> + {/* Engine toggle (M5, D-010): signed-in users choose where uploaded files are transcribed. Live mode always stays on-device (D-016). */} {auth.user && ( diff --git a/src/components/VocabularyHint.tsx b/src/components/VocabularyHint.tsx new file mode 100644 index 0000000..03e616a --- /dev/null +++ b/src/components/VocabularyHint.tsx @@ -0,0 +1,49 @@ +import { useId } from "react"; +import { HINT_MAX_CHARS, normalizeHint } from "../lib/hint"; + +interface VocabularyHintProps { + value: string; + onChange: (value: string) => void; + disabled?: boolean; +} + +/** + * Past this many characters the hint likely exceeds Whisper's prompt budget + * (223 tokens ≈ 150 English words) and gets cut at the end (D-WS2a) — say so + * rather than silently ignoring the tail. + */ +const LONG_HINT_CHARS = 600; + +/** + * Optional "Names and terms" vocabulary hint (D-WS2a). Shown for every model; + * the parent persists the value (src/lib/hint.ts) and sends it with each + * transcription as the decoder prompt. + */ +export function VocabularyHint({ value, onChange, disabled = false }: VocabularyHintProps) { + const id = useId(); + const long = normalizeHint(value).length > LONG_HINT_CHARS; + return ( +
+ +