Add the optional "Names and terms" field and send it with every file and live pass

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Lucas Winther
2026-10-29 21:57:30 +01:00
co-authored by Claude Opus 5.5
parent 8236ea3504
commit d6c3d1ff70
4 changed files with 126 additions and 3 deletions
+1 -1
View File
@@ -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.
+22 -2
View File
@@ -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<number | null>(null);
const [language, setLanguage] = useState(AUTO_LANGUAGE);
const [task, setTask] = useState<WhisperTask>(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<ReadonlySet<string>>(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<LiveTranscribeResult> {
// 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() {
/>
)}
<VocabularyHint
value={hint}
onChange={(value) => {
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 && (
+49
View File
@@ -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 (
<div className="vocabulary-hint">
<label htmlFor={`${id}-input`} className="vocabulary-hint-label">
Names and terms <span className="vocabulary-hint-optional">(optional)</span>
</label>
<textarea
id={`${id}-input`}
className="vocabulary-hint-input"
rows={2}
maxLength={HINT_MAX_CHARS}
value={value}
disabled={disabled}
spellCheck={false}
placeholder="e.g. Maxwell, Voices of the Void, Aarhus"
aria-describedby={`${id}-help`}
onChange={(event) => onChange(event.target.value)}
/>
<p id={`${id}-help`} className="vocabulary-hint-help">
{long
? "That's a long list — only the first part (about 150 words) will be used."
: "Helps spell names, places and special words. Separate them with commas or new lines. Saved in this browser."}
</p>
</div>
);
}
+54
View File
@@ -519,6 +519,60 @@ body {
flex-wrap: wrap;
}
/* --- VocabularyHint (D-WS2a) --- */
.vocabulary-hint {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.vocabulary-hint-label {
font-size: 0.85rem;
color: var(--muted);
}
.vocabulary-hint-optional {
font-weight: 400;
opacity: 0.85;
}
.vocabulary-hint-input {
width: 100%;
box-sizing: border-box;
min-height: 2.9rem;
resize: vertical;
background: var(--surface-raised);
color: var(--fg);
border: 1px solid var(--border);
border-radius: 8px;
padding: 0.45rem 0.6rem;
font: inherit;
font-size: 0.9rem;
line-height: 1.4;
transition: border-color 0.15s;
}
.vocabulary-hint-input::placeholder {
color: var(--muted);
}
.vocabulary-hint-input:focus-visible {
border-color: var(--accent);
outline: none;
}
.vocabulary-hint-input:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.vocabulary-hint-help {
margin: 0;
font-size: 0.8rem;
color: var(--muted);
}
/* --- EngineToggle (M5, D-010) --- */
.engine-toggle {