diff --git a/docs/plans/001-4-translate.md b/docs/plans/001-4-translate.md new file mode 100644 index 0000000..b0a57e7 --- /dev/null +++ b/docs/plans/001-4-translate.md @@ -0,0 +1,137 @@ +--- +status: in-progress +--- + +# 001-4 — Translate: implementation plan + +> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:executing-plans (inline, no subagents — +> coordination plan). Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** (4a) a per-model `canTranslate` flag that hides Whisper's "Translate to English" where it is +unsupported and resets a stale `task: "translate"`; (4b) on-device Opus-MT text translation of any +finished transcript (da→en, en→da), with timestamps kept for `.srt` / timestamped `.txt` exports. + +**Architecture:** `canTranslate` lives in the catalog (`src/lib/models.ts`) with a pure +`taskForModel()` guard used by App on model switch and on every run. Text MT runs in a **dedicated +worker** (`src/worker/translate.worker.ts`) — its own onnxruntime instance with full WASM threads, +independent of the Whisper worker's lifetime (cancel/D-018 terminate, D-025 restart) and of its +accelerator (an ort runtime first initialised for WebGPU is pinned single-threaded, D-023). Pure +planning code (`src/lib/translation.ts`) groups transcript chunks into sentence-ending units, splits +units into sentence pieces under a token budget, and reassembles translated units with the merged +timestamps. UI: a secondary `
` disclosure "Translate transcript…" under the transcript. + +**Tech Stack:** Bun, React 19, Transformers.js 4.2.0 (`translation` pipeline, MarianMT), onnxruntime-web +(WASM) in the browser, onnxruntime-node for measurement. + +**Spec:** `docs/specs/001-transcription-quality-and-speed.md` (Goal 4, call "Text translation (4b) +scope"); coordination: `docs/plans/001-0-coordination.md`. + +## Global Constraints + +- AGENTS.md rules: Bun only; no cross-origin subresources (models come from huggingface.co via + Transformers.js like Whisper; ort from `/ort/`); colours as tokens; errors via `classifyError`. +- D-020 / anti-goal: the MT model is downloaded only when the user clicks Translate; the panel states + the one-time size first. Cached via Transformers.js Cache API (D-005); offline works once cached. +- Nothing leaves the device: translation runs in the browser for every engine (server results too). +- Copy: Whisper's option stays **"Translate to English"**; the new entry is **"Translate transcript…"**. +- `canTranslate`: false for `onnx-community/whisper-large-v3-turbo` and every `.en` model; true for + multilingual tiny/base/small/medium. +- Decision ids: `D-WS4a` (canTranslate), `D-WS4b` (text MT). Ports 3140–3149. Heavy jobs under + `flock /tmp/whisper-heavy.lock`. + +## Review Focus + +1. **Transcript > 512 tokens** (Opus-MT input limit): split on sentence boundaries, order preserved, + nothing dropped → Task 3 test `splitForTranslation keeps every word of en_alien in order with every + piece under budget` + real-model test (Task 4) on all of `en_alien.txt`. +2. **Multi-sentence input to Opus-MT drops later sentences** (observed in the probe: "…Copenhagen. + How are you?" → only the first sentence) → each sentence is its own piece (Task 3 test). +3. **Unpunctuated text** (live output, or a 2-minute run-on): hard-wrap on commas, then words, so no + piece exceeds the budget (Task 3 test). +4. **Stale `task: "translate"` after switching to turbo / a `.en` model / the server auto-switch to + turbo** → reset to transcribe (Task 1 test on `taskForModel`, App wired at every switch + run). +5. **Offline / model not cached** → the Translate button still offers the download; a failed download + surfaces a classified network error with Retry (Task 5, errors test). + +--- + +### Task 1: `canTranslate` + stale-task guard (4a) + +**Files:** Modify `src/lib/models.ts`, `src/components/TranscriptionOptions.tsx`, `src/App.tsx`; +Test `tests/models.test.ts`, new `tests/transcriptionOptions.test.tsx`. + +**Interfaces — Produces:** `WhisperModel.canTranslate: boolean`; +`taskForModel(modelId: string, task: WhisperTask): WhisperTask`; +`TranscriptionOptions` prop `canTranslate: boolean`. + +- [ ] Failing tests: `canTranslate` false for turbo and every `.en`, true for the other multilingual + ids; `taskForModel("onnx-community/whisper-large-v3-turbo", "translate") === "transcribe"`, + unchanged for `whisper-small`; unknown id → `"transcribe"`. `renderToStaticMarkup` of + `TranscriptionOptions` with `canTranslate={false}` contains no "Translate to English" and no Task + select; with `true` contains it. +- [ ] Implement; App: `setTask(t => taskForModel(next, t))` in `handleModelSelect` and the server + auto-switch effect; runs pass `task: taskForModel(modelId, task)`. +- [ ] Typecheck, fast suite; D-WS4a entry; commit. + +### Task 2: error stage for translation + +**Files:** `src/lib/errors.ts`, `tests/errors.test.ts`. +- [ ] Failing test: `classifyError("Out of memory", { stage: "translate" })` → suggestion mentions a + shorter transcript / closing tabs, not "smaller model"; network on `stage: "translate"` retryable. +- [ ] Add `"translate"` to `ErrorStage`; commit with Task 3. + +### Task 3: pure translation planning (`src/lib/translation.ts`) + +**Interfaces — Produces:** +- `TRANSLATION_MODELS: readonly TranslationModel[]` — `{ id, source: "da"|"en", target, label, + approxBytes }` for `Xenova/opus-mt-da-en`, `Xenova/opus-mt-en-da` (q8, ~113 MB each). +- `getTranslationModel(source, target)`, `guessSourceLanguage(text, hint?): "da" | "en"`. +- `buildTranslationUnits(result: { text; chunks }): TranslationUnit[]` — + `{ text: string; timestamp: [number, number | null] | null }`; consecutive chunks merged until the + text ends a sentence (cap 8 chunks); no chunks → one unit per non-empty line. +- `splitForTranslation(text, { maxTokens = 200, countTokens = estimateTokens }): string[]` — + Intl.Segmenter sentences (abbreviation-only fragments merge forward), then comma, then word wrap. +- `assembleTranslation(units, translated: string[]): { text; chunks }`. +- Tests (`tests/translation.test.ts`): unit merging & timestamps, null end, no-chunk fallback, sentence + splitting, Review Focus 1–3, assemble length mismatch throws, `guessSourceLanguage`. +- [ ] Red → green → commit. + +### Task 4: translate worker + protocol + measurement + +**Files:** Create `src/worker/translate.worker.ts`, `src/worker/translateProtocol.ts`, +`tests/translateProtocol.test.ts`, `tests/translate-real-model.test.ts` (skips when the Hub is +unreachable and the model is not cached; run under the lock), `scripts/bench-translate.ts`. +- Protocol: UI→worker `{ type: "translate"; requestId; modelId; units: string[] }`; + worker→UI `translate-load-progress` (file/loaded/total/status), `translate-progress + { done; total }` (pieces), `translate-result { texts: string[] }`, `translate-error + { stage: "load" | "translate"; message }`; guards `isTranslateRequest` / `isTranslateWorkerMessage`. +- Worker: `pipeline("translation", id, { dtype: "q8", device: "wasm", + session_options: { graphOptimizationLevel: "basic" } })` (D-014 — Marian also ties embeddings), + WASM threads as D-023, `wasmPaths "./ort/"`; one cached pipeline per model id; pieces via + `splitForTranslation` with the real tokenizer count; batches of `BATCH_SIZE` (measured). +- Bundle + serve: `src/index.ts` dev route `/translate.worker.js`, `scripts/build.ts` emits and + precaches it. +- [ ] Measure en_alien.txt → da and da_festuge → en on onnxruntime-node (batch 1 vs 4 vs 8); record. +- [ ] Commit. + +### Task 5: UI — `TranslatePanel` + +**Files:** Create `src/components/TranslatePanel.tsx`; modify `src/App.tsx`, +`src/components/TranscriptView.tsx` (`title` prop + `exportSuffix`), `src/components/StorageManager.tsx` +(list MT models), `src/index.css`. +- `
Translate transcript…` with direction + select ("Danish → English", "English → Danish", default from `guessSourceLanguage`), size line when + not cached ("Downloads a translation model (~113 MB, one time)"), Translate / Cancel buttons, + progress ("Downloading translation model… 40%", "Translating… 12 of 80 sentences"), error box via + `classifyError(…, { stage: "translate" | "load" })` with Retry when retryable. +- Result rendered by `TranscriptView` with title "Translation (English)" and exports named + `.en.txt` / `.en.srt`. +- State resets whenever `result` changes; Cancel terminates the translate worker. +- [ ] impeccable polish pass; screenshots light/dark × desktop/mobile; real headless-Chrome run of a + translation (WASM) if feasible. +- [ ] Commit. + +### Task 6: docs + wrap-up +- [ ] D-WS4b entry (models, licences, numbers); Architecture.md (translate worker), AGENTS.md rule + wording (two workers), WorkBreakdown, README if needed, `tests/offline.md` step. +- [ ] typecheck, fast suite, build; `git rm` this plan; commit.