Plan transcript translation units and sentence pieces for Opus-MT

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas WintherandClaude Opus 5.5 committed 2026-10-29 21:39:37 +01:00
1 parent 02a5eab119
commit 82ed34c7f9
4 files changed
+486 -3

No files matched your search

+12 -3
View File
@@ -16,7 +16,12 @@
* inspect across Web Audio / Transformers.js / onnxruntime-web / fetch.
*/
export type ErrorStage = "decode" | "load" | "transcribe" | "worker-crash";
/**
* `"translate"` covers text translation of a finished transcript (D-WS4b):
* both its one-time model download and the translation pass, which run in
* their own worker — only the suggestions differ from the Whisper stages.
*/
export type ErrorStage = "decode" | "load" | "transcribe" | "worker-crash" | "translate";
export type ErrorKind = "unsupported-file" | "out-of-memory" | "device-lost" | "network" | "generic";
@@ -95,7 +100,9 @@ export function classifyError(raw: unknown, context: ClassifyContext = {}): Clas
kind: "out-of-memory",
message: trimmed.length > 0 ? message : "The browser ran out of memory and had to stop.",
suggestion:
"Try a smaller model (Tiny, Base, or Small) — it needs far less memory to load and run.",
context.stage === "translate"
? "Close other tabs to free memory, then try translating again."
: "Try a smaller model (Tiny, Base, or Small) — it needs far less memory to load and run.",
retryable: false,
};
}
@@ -117,7 +124,9 @@ export function classifyError(raw: unknown, context: ClassifyContext = {}): Clas
suggestion:
context.stage === "load"
? "Check your connection, then retry — the model download will restart."
: "Check your connection and try again.",
: context.stage === "translate"
? "Check your connection, then retry — the translation model download will restart."
: "Check your connection and try again.",
retryable: true,
};
}
+273
View File
@@ -0,0 +1,273 @@
/**
* Text translation of a finished transcript (D-WS4b) — the pure half.
*
* Opus-MT (MarianMT) runs in `src/worker/translate.worker.ts`; everything
* here is planning that needs no model: which checkpoint serves a language
* pair, how a transcript becomes translation units that keep their
* timestamps, and how a unit is cut into sentence pieces under the model's
* input budget. No browser globals, so `bun test` covers it.
*
* Why per-sentence pieces: Opus-MT is trained on sentence pairs and, given
* several sentences at once, routinely drops the later ones (measured
* 2026-10-29: "…I live in Copenhagen. How are you?" came back as one
* sentence). Its encoder also tops out at 512 positions.
*/
import type { TranscriptChunk } from "../worker/protocol";
export type TranslationLanguage = "da" | "en";
export interface TranslationModel {
/** Hugging Face Hub id of the Transformers.js ONNX export. */
id: string;
source: TranslationLanguage;
target: TranslationLanguage;
/** Direction as shown in the picker, e.g. "Danish → English". */
label: string;
/** Plain name of the target language, e.g. "English". */
targetLabel: string;
/**
* One-time download at the q8 dtype the worker loads: encoder + merged
* decoder + tokenizer/configs, summed from the Hub file sizes.
*/
approxBytes: number;
}
/**
* Xenova's ONNX exports of Helsinki-NLP's Opus-MT checkpoints (Apache-2.0
* upstream). Verified to load and translate under Transformers.js 4.2.0.
*/
export const TRANSLATION_MODELS: readonly TranslationModel[] = [
{
id: "Xenova/opus-mt-da-en",
source: "da",
target: "en",
label: "Danish → English",
targetLabel: "English",
approxBytes: 113_000_000,
},
{
id: "Xenova/opus-mt-en-da",
source: "en",
target: "da",
label: "English → Danish",
targetLabel: "Danish",
approxBytes: 113_000_000,
},
];
export const TRANSLATION_MODEL_IDS: readonly string[] = TRANSLATION_MODELS.map((m) => m.id);
export function getTranslationModel(
source: TranslationLanguage,
target: TranslationLanguage,
): TranslationModel | undefined {
return TRANSLATION_MODELS.find((m) => m.source === source && m.target === target);
}
/** Lookup by Hub id (storage panel names, worker request validation). */
export function getTranslationModelById(id: string): TranslationModel | undefined {
return TRANSLATION_MODELS.find((m) => m.id === id);
}
// ---------------------------------------------------------------------------
// Units: transcript → sentence-ending groups of chunks with their time span
// ---------------------------------------------------------------------------
/** A stretch of transcript translated as one cue. */
export interface TranslationUnit {
text: string;
/** Merged [start, end] of the source chunks; null for untimed text. */
timestamp: [number, number | null] | null;
}
const SENTENCE_END = /[.!?…]["'”’)\]]*$/;
/** Default cap on chunks merged into one unit, so an unpunctuated transcript
* still yields subtitle-sized cues rather than one giant one. */
export const MAX_CHUNKS_PER_UNIT = 8;
/**
* Group a transcript into translation units. Whisper's segments often end
* mid-sentence; translating fragments hurts quality, so consecutive chunks
* are merged until one ends a sentence (or `maxChunksPerUnit` is reached)
* and the unit takes the merged time span — `.srt` keeps working, with
* slightly longer cues. Without chunks (nothing timestamped), each non-empty
* line is an untimed unit.
*/
export function buildTranslationUnits(
result: { text: string; chunks: TranscriptChunk[] },
maxChunksPerUnit: number = MAX_CHUNKS_PER_UNIT,
): TranslationUnit[] {
const chunks = result.chunks.filter((chunk) => chunk.text.trim() !== "");
if (chunks.length === 0) {
return result.text
.split(/\n+/)
.map((line) => line.trim())
.filter((line) => line !== "")
.map((text) => ({ text, timestamp: null }));
}
const units: TranslationUnit[] = [];
let group: TranscriptChunk[] = [];
const flush = () => {
const first = group[0];
const last = group[group.length - 1];
if (!first || !last) return;
units.push({
text: group.map((chunk) => chunk.text.trim()).join(" "),
timestamp: [first.timestamp[0], last.timestamp[1]],
});
group = [];
};
for (const chunk of chunks) {
group.push(chunk);
if (SENTENCE_END.test(chunk.text.trim()) || group.length >= maxChunksPerUnit) flush();
}
flush();
return units;
}
// ---------------------------------------------------------------------------
// Pieces: unit text → sentences under the model's token budget
// ---------------------------------------------------------------------------
/**
* Cheap upper-bound token estimate for Opus-MT's SentencePiece vocab, used
* when the real tokenizer isn't at hand (tests, planning). Measured ~0.34
* tokens/char on en_alien and ~0.26 on da_festuge with the real tokenizers;
* 0.5/char plus the end-of-sentence token stays above both.
*/
export function estimateTokens(text: string): number {
return text.length === 0 ? 0 : Math.ceil(text.length / 2) + 1;
}
/**
* Default per-piece budget, well inside Opus-MT's 512 positions: quality
* falls off on very long inputs long before the hard limit, and normal
* sentences are 10–60 tokens, so this only bites on unpunctuated run-ons.
*/
export const DEFAULT_MAX_PIECE_TOKENS = 200;
export interface SplitOptions {
maxTokens?: number;
/** Token counter — the worker passes the real tokenizer. */
countTokens?: (text: string) => number;
}
/** "Mr.", "Dr.", "St." — sentence-segmenter false breaks. */
const ABBREVIATION = /^\p{Lu}\p{Ll}{0,3}\.$/u;
function sentences(text: string): string[] {
const out: string[] = [];
let carry = "";
for (const { segment } of new Intl.Segmenter("en", { granularity: "sentence" }).segment(text)) {
const trimmed = segment.trim();
if (trimmed === "") continue;
if (ABBREVIATION.test(trimmed)) {
carry = carry === "" ? trimmed : `${carry} ${trimmed}`;
continue;
}
out.push(carry === "" ? trimmed : `${carry} ${trimmed}`);
carry = "";
}
if (carry !== "") out.push(carry);
return out;
}
/** Greedily pack `parts` (in order) into space-joined pieces under budget. */
function pack(parts: string[], maxTokens: number, count: (text: string) => number): string[] {
const pieces: string[] = [];
let current = "";
for (const part of parts) {
const candidate = current === "" ? part : `${current} ${part}`;
if (current !== "" && count(candidate) > maxTokens) {
pieces.push(current);
current = part;
} else {
current = candidate;
}
}
if (current !== "") pieces.push(current);
return pieces;
}
/** Split one over-budget sentence: commas first, then words. A single word
* longer than the budget is kept whole (nothing is ever dropped). */
function wrap(sentence: string, maxTokens: number, count: (text: string) => number): string[] {
const clauses = sentence.split(/(?<=,)\s+/);
const parts = clauses.flatMap((clause) =>
count(clause) <= maxTokens ? [clause] : pack(clause.split(/\s+/), maxTokens, count),
);
return pack(parts, maxTokens, count);
}
/**
* Cut a unit's text into the pieces actually fed to Opus-MT: one per
* sentence, an over-budget sentence wrapped on commas then words. Order is
* preserved and every word survives — joining the pieces with spaces gives
* the input back up to whitespace.
*/
export function splitForTranslation(text: string, options: SplitOptions = {}): string[] {
const maxTokens = options.maxTokens ?? DEFAULT_MAX_PIECE_TOKENS;
const count = options.countTokens ?? estimateTokens;
return sentences(text).flatMap((sentence) =>
count(sentence) <= maxTokens ? [sentence] : wrap(sentence, maxTokens, count),
);
}
// ---------------------------------------------------------------------------
// Assembly
// ---------------------------------------------------------------------------
/**
* Pair translated unit texts back with their units' time spans, in the
* `{ text, chunks }` shape the transcript view and exports already take.
*/
export function assembleTranslation(
units: readonly TranslationUnit[],
translated: readonly string[],
): { text: string; chunks: TranscriptChunk[] } {
if (units.length !== translated.length) {
throw new Error(
`Translation returned ${translated.length} parts for ${units.length} — refusing to misalign the transcript.`,
);
}
const texts = translated.map((text) => text.trim());
const chunks: TranscriptChunk[] = [];
units.forEach((unit, index) => {
if (unit.timestamp) chunks.push({ text: texts[index] ?? "", timestamp: unit.timestamp });
});
const untimed = units.every((unit) => unit.timestamp === null);
return { text: texts.join(untimed ? "\n" : " "), chunks };
}
// ---------------------------------------------------------------------------
// Source language guess (default for the direction picker)
// ---------------------------------------------------------------------------
const DANISH_WORDS = new Set([
"og", "ikke", "det", "jeg", "vi", "på", "til", "med", "som", "har", "af", "der",
"kan", "skal", "vil", "også", "men", "hvad", "hvordan", "være", "noget",
]);
const ENGLISH_WORDS = new Set([
"the", "and", "is", "to", "of", "that", "you", "it", "with", "have", "this", "not",
"what", "how", "be", "was", "are",
]);
/**
* Best guess at which language a transcript is in, for the direction
* picker's default (the user can always change it). An explicit Whisper
* language wins; otherwise Danish letters and function words vs English
* ones decide, defaulting to English.
*/
export function guessSourceLanguage(text: string, whisperLanguage?: string): TranslationLanguage {
if (whisperLanguage === "da" || whisperLanguage === "en") return whisperLanguage;
const lower = text.toLowerCase();
let danish = (lower.match(/[æøå]/g)?.length ?? 0) * 2;
let english = 0;
for (const word of lower.match(/\p{L}+/gu) ?? []) {
if (DANISH_WORDS.has(word)) danish += 1;
if (ENGLISH_WORDS.has(word)) english += 1;
}
return danish > english ? "da" : "en";
}