Add the Translate transcript panel with its own exports

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Lucas Winther
2026-10-29 22:10:51 +01:00
co-authored by Claude Opus 5.5
parent a0748b5fa6
commit c110c9203f
6 changed files with 501 additions and 7 deletions
+24 -1
View File
@@ -10,6 +10,7 @@ import { ModelPicker } from "./components/ModelPicker";
import { StorageManager } from "./components/StorageManager";
import { TranscriptView } from "./components/TranscriptView";
import { TranscriptionOptions } from "./components/TranscriptionOptions";
import { TranslatePanel } from "./components/TranslatePanel";
import { decodeAudioFile } from "./lib/audio";
import { classifyError, type ClassifiedError } from "./lib/errors";
import {
@@ -26,6 +27,7 @@ import { useAuth } from "./lib/useAuth";
import { LIVE_TUNING, LiveTranscriber, type LiveTranscribeResult } from "./lib/live";
import { startMicCapture, type MicCapture } from "./lib/mic";
import { getDownloadedModels } from "./lib/modelCache";
import { TRANSLATION_MODEL_IDS } from "./lib/translation";
import { registerServiceWorker } from "./pwa/register";
import {
AUTO_LANGUAGE,
@@ -68,6 +70,8 @@ interface AppErrorState {
}
const MODEL_IDS = MODELS.map((model) => model.id);
// Downloaded badges cover the Opus-MT models too (transcript translation, D-WS4b).
const CACHED_MODEL_IDS = [...MODEL_IDS, ...TRANSLATION_MODEL_IDS];
export function App() {
const workerRef = useRef<Worker | null>(null);
@@ -183,7 +187,7 @@ export function App() {
function refreshDownloaded() {
// Downloaded badges (D-005): query Cache Storage for cached weights.
getDownloadedModels(MODEL_IDS).then(setDownloaded, () => {});
getDownloadedModels(CACHED_MODEL_IDS).then(setDownloaded, () => {});
}
// Terminate the worker when the app unmounts (also keeps HMR tidy).
@@ -1165,6 +1169,25 @@ export function App() {
}
/>
{/* Text translation of the finished transcript (D-WS4b) — any engine,
file or live; secondary to the transcript itself. */}
{result && !liveActive && (
<TranslatePanel
result={result}
sourceLanguageHint={
!model?.multilingual
? "en"
: taskForModel(modelId, task) === "translate"
? "en"
: language
}
downloaded={downloaded}
onModelCached={refreshDownloaded}
exportName={fileName}
disabled={busy || liveActive}
/>
)}
{applyUpdate && (
<div className="update-toast" role="status">
<span>A new version of Whisper Web is available.</span>
+10 -2
View File
@@ -5,6 +5,7 @@ import {
type ModelStorageInfo,
} from "../lib/modelCache";
import { MODELS, getModel } from "../lib/models";
import { TRANSLATION_MODEL_IDS, getTranslationModelById } from "../lib/translation";
interface StorageManagerProps {
/** Downloaded-badge set from App — changes re-measure an open panel. */
@@ -15,7 +16,14 @@ interface StorageManagerProps {
onChanged: () => void;
}
const MODEL_IDS = MODELS.map((model) => model.id);
// Whisper weights plus the Opus-MT translation models (D-WS4b).
const MODEL_IDS = [...MODELS.map((model) => model.id), ...TRANSLATION_MODEL_IDS];
function displayName(modelId: string): string {
const translation = getTranslationModelById(modelId);
if (translation) return `Translation, ${translation.label}`;
return getModel(modelId)?.displayName ?? modelId;
}
function formatBytes(bytes: number): string {
if (bytes >= 1e9) return `${(bytes / 1e9).toFixed(2)} GB`;
@@ -82,7 +90,7 @@ export function StorageManager({ downloaded, disabled, onChanged }: StorageManag
<li key={entry.modelId} className="storage-row">
<div className="storage-model">
<span className="storage-name">
{getModel(entry.modelId)?.displayName ?? entry.modelId}
{displayName(entry.modelId)}
</span>
<code className="storage-id">{entry.modelId}</code>
</div>
+14 -4
View File
@@ -34,6 +34,13 @@ interface TranscriptViewProps {
* "talk.txt"/"talk.srt"); null falls back to "transcript.*".
*/
exportName?: string | null;
/** Heading once the result is final; defaults to "Transcript". */
title?: string;
/**
* Inserted before each export extension ("en" → "talk.en.txt",
* "talk.en.srt") so a translation's exports sit beside the original's.
*/
exportSuffix?: string;
}
/** Trigger a browser download of `contents` as `filename`. */
@@ -62,7 +69,10 @@ export function TranscriptView({
progress = null,
placeholder = "Listening for the first words…",
exportName = null,
title = "Transcript",
exportSuffix,
}: TranscriptViewProps) {
const ext = (extension: string) => (exportSuffix ? `${exportSuffix}.${extension}` : extension);
const [copied, setCopied] = useState(false);
const headingRef = useRef<HTMLHeadingElement>(null);
const prevResultRef = useRef<typeof result>(null);
@@ -102,7 +112,7 @@ export function TranscriptView({
<section className="transcript">
<div className="transcript-header">
<h2 ref={headingRef} tabIndex={-1}>
{result ? "Transcript" : activeLabel}
{result ? title : activeLabel}
{percent !== null && (
<span className="transcript-percent"> {percent}%</span>
)}
@@ -115,7 +125,7 @@ export function TranscriptView({
onClick={() =>
download(
transcriptToTxt(result),
exportFilename(exportName, "txt"),
exportFilename(exportName, ext("txt")),
"text/plain",
)
}
@@ -130,7 +140,7 @@ export function TranscriptView({
onClick={() =>
download(
transcriptToSrt(result.chunks),
exportFilename(exportName, "srt"),
exportFilename(exportName, ext("srt")),
"application/x-subrip",
)
}
@@ -146,7 +156,7 @@ export function TranscriptView({
onClick={() =>
download(
transcriptToTimestampedTxt(result.chunks),
exportFilename(exportName, "timestamps.txt"),
exportFilename(exportName, ext("timestamps.txt")),
"text/plain",
)
}
+269
View File
@@ -0,0 +1,269 @@
import { useEffect, useRef, useState } from "react";
import { classifyError, type ClassifiedError } from "../lib/errors";
import { formatModelSize } from "../lib/models";
import {
TRANSLATION_MODELS,
assembleTranslation,
buildTranslationUnits,
guessSourceLanguage,
type TranslationModel,
type TranslationUnit,
} from "../lib/translation";
import type { TranscriptChunk } from "../worker/protocol";
import { isTranslateWorkerMessage, type TranslateRequest } from "../worker/translateProtocol";
import { TranscriptView } from "./TranscriptView";
interface TranscriptResult {
text: string;
chunks: TranscriptChunk[];
}
/** Where a translation run is; `idle` covers "never started" and "finished". */
export type TranslatePhase =
| { kind: "idle" }
| { kind: "starting" }
| { kind: "downloading"; loaded: number; total: number }
| { kind: "translating"; done: number; total: number };
/**
* Plain-language status for a running translation (PRODUCT.md: say what is
* happening, in everyday words). Null when nothing is running.
*/
export function translateStatusText(phase: TranslatePhase, cached: boolean): string | null {
switch (phase.kind) {
case "idle":
return null;
case "starting":
return cached ? "Loading the translation model…" : "Starting the download…";
case "downloading": {
const percent = phase.total > 0 ? Math.min(100, Math.round((phase.loaded / phase.total) * 100)) : 0;
return `Downloading the translation model… ${percent}%`;
}
case "translating":
return phase.total === 0
? "Translating…"
: `Translating… ${phase.done} of ${phase.total} sentence${phase.total === 1 ? "" : "s"}`;
}
}
interface TranslatePanelProps {
/** The finished transcript to translate (any engine, file or live). */
result: TranscriptResult;
/**
* Language Whisper was told the audio is in ("da", "en"), "en" when
* Whisper itself translated, or undefined/"auto" — seeds the direction.
*/
sourceLanguageHint?: string;
/** Hub ids with cached weights (D-005) — decides the download wording. */
downloaded: ReadonlySet<string>;
/** Called once a model finished loading, to refresh downloaded badges. */
onModelCached: () => void;
/** Source file name for export names ("talk.m4a" → "talk.en.srt"). */
exportName: string | null;
/** True while a transcription or live session is running. */
disabled: boolean;
}
/**
* Text translation of a finished transcript with Opus-MT (D-WS4b): a
* secondary disclosure under the transcript, separate from Whisper's own
* "Translate to English" task. Runs on-device in its own worker; the model
* is downloaded only when the user clicks Translate (D-020).
*/
export function TranslatePanel({
result,
sourceLanguageHint,
downloaded,
onModelCached,
exportName,
disabled,
}: TranslatePanelProps) {
const workerRef = useRef<Worker | null>(null);
const runRef = useRef(0);
const [modelId, setModelId] = useState(() => defaultModel(result, sourceLanguageHint).id);
const [phase, setPhase] = useState<TranslatePhase>({ kind: "idle" });
const [error, setError] = useState<ClassifiedError | null>(null);
const [translation, setTranslation] = useState<{
model: TranslationModel;
result: TranscriptResult;
} | null>(null);
const model = TRANSLATION_MODELS.find((m) => m.id === modelId) ?? TRANSLATION_MODELS[0]!;
const cached = downloaded.has(model.id);
const running = phase.kind !== "idle";
const empty = result.text.trim() === "" && result.chunks.every((c) => c.text.trim() === "");
function stopWorker() {
runRef.current++;
workerRef.current?.terminate();
workerRef.current = null;
}
// A new transcript (or model switch upstream) invalidates everything here.
useEffect(() => {
stopWorker();
setPhase({ kind: "idle" });
setError(null);
setTranslation(null);
setModelId(defaultModel(result, sourceLanguageHint).id);
// Keyed on the transcript only: the language hint seeds the default for
// a new transcript but must not wipe a translation when it changes.
}, [result]);
useEffect(() => stopWorker, []);
function translate() {
if (running || empty) return;
const run = ++runRef.current;
const units: TranslationUnit[] = buildTranslationUnits(result);
const target = model;
setError(null);
setTranslation(null);
setPhase({ kind: "starting" });
// Separate worker, bundled and served at a stable URL like the Whisper
// worker (src/index.ts, scripts/build.ts). Reused across runs.
const worker = (workerRef.current ??= new Worker("translate.worker.js", { type: "module" }));
const requestId = `translate-${run}`;
const fail = (raw: unknown, stage: "translate" | "worker-crash") => {
if (runRef.current !== run) return;
setPhase({ kind: "idle" });
const offline = typeof navigator !== "undefined" && !navigator.onLine;
// A crash (e.g. OOM tab-kill) leaves nothing to reuse.
if (stage === "worker-crash") stopWorker();
setError(classifyError(raw, { stage: "translate", offline }));
};
worker.onmessage = (event: MessageEvent<unknown>) => {
const message = event.data;
if (runRef.current !== run || !isTranslateWorkerMessage(message)) return;
if (message.requestId !== requestId) return;
switch (message.type) {
case "translate-download":
setPhase({ kind: "downloading", loaded: message.loaded, total: message.total });
break;
case "translate-progress":
if (message.done === 0) onModelCached();
setPhase({ kind: "translating", done: message.done, total: message.total });
break;
case "translate-result":
try {
setTranslation({ model: target, result: assembleTranslation(units, message.texts) });
setPhase({ kind: "idle" });
} catch (assembleError) {
fail(assembleError, "translate");
}
break;
case "translate-error":
fail(message.message, "translate");
break;
}
};
worker.onerror = (event) => fail(event.message ?? "", "worker-crash");
const request: TranslateRequest = {
type: "translate",
requestId,
modelId: target.id,
units: units.map((unit) => unit.text),
};
worker.postMessage(request);
}
function cancel() {
stopWorker();
setPhase({ kind: "idle" });
}
const status = translateStatusText(phase, cached);
const progressValue =
phase.kind === "downloading"
? { value: phase.loaded, max: Math.max(phase.total, 1) }
: phase.kind === "translating"
? { value: phase.done, max: Math.max(phase.total, 1) }
: null;
return (
<>
<details className="translate-panel">
<summary>Translate transcript…</summary>
<div className="translate-controls">
<label className="translate-direction">
Direction
<select
value={model.id}
disabled={running || disabled}
onChange={(event) => setModelId(event.target.value)}
>
{TRANSLATION_MODELS.map((m) => (
<option key={m.id} value={m.id}>
{m.label}
</option>
))}
</select>
</label>
{running ? (
<button type="button" className="translate-cancel" onClick={cancel}>
Cancel
</button>
) : (
<button
type="button"
className="translate-button"
onClick={translate}
disabled={disabled || empty}
>
{translation?.model.id === model.id ? "Translate again" : "Translate"}
</button>
)}
</div>
<p className="translate-hint">
{cached
? "Translation model downloaded — works offline. "
: `Downloads a translation model once (~${formatModelSize(model.approxBytes)}), then works offline. `}
Runs on this device; the text never leaves it. Machine translation — check
names and important details.
</p>
<p className="translate-hint translate-model-id">
Model: <code>{model.id}</code> (Opus-MT)
</p>
{status && (
<div className="translate-progress" role="status">
<span className="translate-status">{status}</span>
{progressValue && (
<progress
value={progressValue.value}
max={progressValue.max}
aria-label="Translation progress"
/>
)}
</div>
)}
{error && (
<div className="error translate-error" role="alert">
<p className="error-message">{error.message}</p>
{error.suggestion && <p className="error-suggestion">{error.suggestion}</p>}
{error.retryable && (
<button type="button" className="error-retry-button" onClick={translate}>
Retry
</button>
)}
</div>
)}
</details>
{translation && (
<TranscriptView
partialText=""
result={translation.result}
transcribing={false}
title={`Translation (${translation.model.targetLabel})`}
exportName={exportName}
exportSuffix={translation.model.target}
/>
)}
</>
);
}
function defaultModel(result: TranscriptResult, hint?: string): TranslationModel {
const source = guessSourceLanguage(result.text, hint);
return TRANSLATION_MODELS.find((m) => m.source === source) ?? TRANSLATION_MODELS[0]!;
}
+119
View File
@@ -1006,3 +1006,122 @@ code {
align-items: center;
gap: 0.5rem;
}
/* --------------------------------------------------------------------------
Transcript translation (D-WS4b): a secondary disclosure under the
transcript, same panel language as .storage-manager.
-------------------------------------------------------------------------- */
.translate-panel {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 12px;
padding: 0.75rem 1rem;
}
.translate-panel summary {
cursor: pointer;
font-weight: 600;
}
.translate-controls {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.5rem 0.75rem;
margin-top: 0.75rem;
}
.translate-direction {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.85rem;
color: var(--muted);
min-width: 0;
}
.translate-direction select {
min-width: 0;
max-width: 100%;
background: var(--surface-raised);
color: var(--fg);
border: 1px solid var(--border);
border-radius: 8px;
padding: 0.3rem 0.5rem;
font-size: 0.85rem;
}
.translate-button,
.translate-cancel {
border: 1px solid var(--border);
background: transparent;
color: inherit;
border-radius: 8px;
padding: 0.3rem 0.9rem;
font-size: 0.85rem;
cursor: pointer;
white-space: nowrap;
transition: border-color 0.15s, color 0.15s;
}
/* The panel's one action reads as the action: accent outline, like a
focused copy button, without competing with the transcript above. */
.translate-button {
border-color: var(--accent);
color: var(--accent);
}
.translate-button:hover:enabled {
background: var(--accent);
color: var(--on-accent);
}
.translate-button:disabled {
opacity: 0.5;
cursor: default;
}
.translate-cancel:hover {
border-color: var(--danger-border);
color: var(--danger);
}
.translate-hint {
margin: 0.6rem 0 0;
max-width: 65ch;
color: var(--muted);
font-size: 0.85rem;
}
.translate-model-id {
margin-top: 0.25rem;
font-size: 0.75rem;
}
.translate-model-id code {
overflow-wrap: anywhere;
}
.translate-progress {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.4rem 0.75rem;
margin-top: 0.75rem;
font-size: 0.85rem;
}
.translate-status {
font-variant-numeric: tabular-nums;
}
.translate-progress progress {
flex: 1 1 10rem;
height: 6px;
accent-color: var(--accent);
}
.translate-error {
margin-top: 0.75rem;
}
+65
View File
@@ -0,0 +1,65 @@
import { describe, expect, test } from "bun:test";
import { renderToStaticMarkup } from "react-dom/server";
import { TranslatePanel, translateStatusText } from "../src/components/TranslatePanel";
// D-WS4b: the panel's copy, before any worker exists (static render).
const danish = { text: "Det er ikke noget med at vi skal på ferie.", chunks: [] };
function render(downloaded: string[] = [], hint?: string) {
return renderToStaticMarkup(
<TranslatePanel
result={danish}
sourceLanguageHint={hint}
downloaded={new Set(downloaded)}
onModelCached={() => {}}
exportName="talk.m4a"
disabled={false}
/>,
);
}
describe("TranslatePanel", () => {
test("is a secondary disclosure named in plain words", () => {
const html = render();
expect(html).toContain("<details");
expect(html).toContain("Translate transcript…");
});
test("states the one-time download size before anything downloads (D-020)", () => {
const html = render();
expect(html).toContain("Downloads a translation model once (~113 MB)");
expect(html).toContain("never leaves");
});
test("says it works offline once the model is cached", () => {
expect(render(["Xenova/opus-mt-da-en"])).toContain("Translation model downloaded — works offline");
});
test("defaults the direction from the transcript's language", () => {
expect(render()).toMatch(/<option value="Xenova\/opus-mt-da-en" selected="">/);
expect(render([], "en")).toMatch(/<option value="Xenova\/opus-mt-en-da" selected="">/);
});
});
describe("translateStatusText", () => {
test("idle says nothing", () => {
expect(translateStatusText({ kind: "idle" }, false)).toBeNull();
});
test("never claims a download for a cached model (D-033 spirit)", () => {
expect(translateStatusText({ kind: "starting" }, true)).toBe("Loading the translation model…");
expect(translateStatusText({ kind: "starting" }, false)).toBe("Starting the download…");
});
test("download percent and sentence counts", () => {
expect(translateStatusText({ kind: "downloading", loaded: 40, total: 100 }, false)).toBe(
"Downloading the translation model… 40%",
);
expect(translateStatusText({ kind: "translating", done: 12, total: 80 }, true)).toBe(
"Translating… 12 of 80 sentences",
);
expect(translateStatusText({ kind: "translating", done: 0, total: 1 }, true)).toBe(
"Translating… 0 of 1 sentence",
);
});
});