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:
1 parent
02a5eab119
commit
82ed34c7f9
4 files changed
+486
-3
No files matched your search
+12
-3
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
Reference in new issue
Block a user