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:
co-authored by
Claude Opus 5.5
parent
8236ea3504
commit
d6c3d1ff70
@@ -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
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user