diff --git a/src/App.tsx b/src/App.tsx index 0c22644..4985267 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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(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 && ( + + )} + {applyUpdate && (
A new version of Whisper Web is available. diff --git a/src/components/StorageManager.tsx b/src/components/StorageManager.tsx index b5159da..152f767 100644 --- a/src/components/StorageManager.tsx +++ b/src/components/StorageManager.tsx @@ -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
  • - {getModel(entry.modelId)?.displayName ?? entry.modelId} + {displayName(entry.modelId)} {entry.modelId}
    diff --git a/src/components/TranscriptView.tsx b/src/components/TranscriptView.tsx index 188b832..d593f37 100644 --- a/src/components/TranscriptView.tsx +++ b/src/components/TranscriptView.tsx @@ -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(null); const prevResultRef = useRef(null); @@ -102,7 +112,7 @@ export function TranscriptView({

    - {result ? "Transcript" : activeLabel} + {result ? title : activeLabel} {percent !== null && ( {percent}% )} @@ -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", ) } diff --git a/src/components/TranslatePanel.tsx b/src/components/TranslatePanel.tsx new file mode 100644 index 0000000..be60664 --- /dev/null +++ b/src/components/TranslatePanel.tsx @@ -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; + /** 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(null); + const runRef = useRef(0); + const [modelId, setModelId] = useState(() => defaultModel(result, sourceLanguageHint).id); + const [phase, setPhase] = useState({ kind: "idle" }); + const [error, setError] = useState(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) => { + 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 ( + <> +
    + Translate transcript… +
    + + {running ? ( + + ) : ( + + )} +
    +

    + {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. +

    +

    + Model: {model.id} (Opus-MT) +

    + {status && ( +
    + {status} + {progressValue && ( + + )} +
    + )} + {error && ( +
    +

    {error.message}

    + {error.suggestion &&

    {error.suggestion}

    } + {error.retryable && ( + + )} +
    + )} +
    + {translation && ( + + )} + + ); +} + +function defaultModel(result: TranscriptResult, hint?: string): TranslationModel { + const source = guessSourceLanguage(result.text, hint); + return TRANSLATION_MODELS.find((m) => m.source === source) ?? TRANSLATION_MODELS[0]!; +} diff --git a/src/index.css b/src/index.css index 999d03c..9f444d1 100644 --- a/src/index.css +++ b/src/index.css @@ -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; +} diff --git a/tests/translatePanel.test.tsx b/tests/translatePanel.test.tsx new file mode 100644 index 0000000..bd5186f --- /dev/null +++ b/tests/translatePanel.test.tsx @@ -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( + {}} + exportName="talk.m4a" + disabled={false} + />, + ); +} + +describe("TranslatePanel", () => { + test("is a secondary disclosure named in plain words", () => { + const html = render(); + expect(html).toContain(" { + 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(/