Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,8 +90,9 @@ need FUSE 2 once: `sudo apt install libfuse2t64` (older distros: `libfuse2`).
4. Open **Model & connection**, choose your endpoint preset, click **Test connection**
(this also lists the available models), pick a model.
5. Optionally adjust **Translation options** — *Lines per batch* (how many lines go to
the model at once) and *Context lines* (how many already-translated lines to show it
for continuity).
the model at once), *Context lines* (how many already-translated lines to show it
for continuity), and *Parallel requests* (batches translated at once — a big speedup
on cloud endpoints; keep it at 1 for local servers and best context continuity).
6. Optionally fill in **Context / notes for the model** — a short description of the
movie/show so the model uses the right terminology. e.g. for a chess film: *"keep
chess terms accurate (grandmaster, gambit) and leave move notation like Nf3, O-O
Expand Down
4 changes: 4 additions & 0 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,10 @@ <h2>Translation options</h2>
<input id="contextLines" type="number" min="0" max="40" />
<small class="muted">How many already-translated lines to show the model for continuity (tone, names, gender). 0 disables context.</small>
</label>
<label>Parallel requests
<input id="parallelRequests" type="number" min="1" max="8" />
<small class="muted">Batches translated at once. 1 keeps full context continuity; higher is much faster on cloud endpoints. Most local servers handle one request at a time anyway.</small>
</label>
<label>Tone / formality
<select id="tone"></select>
<small class="muted">Register to aim for. Formal/Informal also nudge the polite vs familiar "you" (vous/Sie vs tu/du) where the language has one.</small>
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "subllminal",
"private": true,
"version": "0.2.0",
"version": "0.3.0",
"type": "module",
"description": "Desktop app that translates .srt/.ass subtitles via a local-first (OpenAI-compatible) LLM, preserving timing, styling, and RTL languages.",
"scripts": {
Expand Down
2 changes: 1 addition & 1 deletion src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "app"
version = "0.2.0"
version = "0.3.0"
description = "Desktop app that translates .srt/.ass subtitles via a local-first (OpenAI-compatible) LLM, preserving timing, styling, and RTL languages."
authors = ["LockhartKZ"]
license = "MIT"
Expand Down
2 changes: 1 addition & 1 deletion src-tauri/tauri.conf.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"productName": "SubLLMinal",
"version": "0.2.0",
"version": "0.3.0",
"identifier": "com.omer.subllminal",
"build": {
"frontendDist": "../dist",
Expand Down
14 changes: 14 additions & 0 deletions src/lib/llm/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,20 @@ export class LlmError extends Error {
}
}

/**
* Errors worth retrying automatically: network blips, timeouts, rate limits
* (429) and server errors (5xx). Auth/4xx errors and cancellations are not —
* retrying those just repeats the same failure.
*/
export function isTransientLlmError(err: unknown): boolean {
return (
err instanceof LlmError &&
(err.kind === "network" ||
err.kind === "timeout" ||
(err.kind === "http" && (err.status === 429 || (err.status ?? 0) >= 500)))
);
}

function joinUrl(baseUrl: string, path: string): string {
return baseUrl.replace(/\/+$/, "") + path;
}
Expand Down
3 changes: 3 additions & 0 deletions src/lib/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ export interface AppSettings {
targetLang: string;
batchSize: number;
contextLines: number;
/** Batches sent in parallel (1 = sequential; >1 mainly speeds up cloud endpoints). */
parallelRequests: number;
temperature: number;
/** Free-text notes about the material (genre, terminology, names) for the model. */
contextNote: string;
Expand All @@ -34,6 +36,7 @@ export const DEFAULT_SETTINGS: AppSettings = {
targetLang: "ar",
batchSize: 10,
contextLines: 6,
parallelRequests: 1,
temperature: 0.2,
contextNote: "",
glossary: "",
Expand Down
2 changes: 1 addition & 1 deletion src/lib/subtitle/ass.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ export function parseAss(input: string): ParsedSubtitle {
// Only Dialogue lines are translated; Comment lines are left untouched.
if (section === "[events]" && /^dialogue\s*:/i.test(trimmed)) {
const { prefix, text } = splitAtTextField(raw, textIndex);
const cue: Cue = { id: String(ordinal++), text };
const cue: Cue = { id: String(ordinal++), text, source: text };
// Fields before Text never contain commas, so a naive split is safe here.
const cols = raw.slice(raw.indexOf(":") + 1).split(",");
const at = (name: string) => {
Expand Down
2 changes: 1 addition & 1 deletion src/lib/subtitle/srt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ export function parseSrt(input: string): ParsedSubtitle {
const timing = lines[1] ?? "";
const text = lines.slice(2).join("\n");
const [rawStart, rawEnd] = timing.split("-->");
const cue: Cue = { id: String(i), text };
const cue: Cue = { id: String(i), text, source: text };
const start = rawStart ? parseSrtTime(rawStart) : undefined;
const end = rawEnd ? parseSrtTime(rawEnd) : undefined;
if (start !== undefined) cue.start = start;
Expand Down
7 changes: 7 additions & 0 deletions src/lib/subtitle/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,13 @@ export interface Cue {
/** Stable id, unique within the file (a stringified ordinal). */
id: string;
text: string;
/**
* The original source-language text, set at parse time and never mutated.
* The engine translates from this (falling back to `text` when absent), so
* retranslating always starts from the source — even after `text` holds a
* previous translation or a manual edit.
*/
source?: string;
/** Read-only timing metadata in milliseconds, when the format provides it. */
start?: number;
end?: number;
Expand Down
149 changes: 111 additions & 38 deletions src/lib/translate/engine.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { ChatBackend } from "../llm/client";
import { isTransientLlmError, type ChatBackend, type ChatMessage } from "../llm/client";
import type { Cue } from "../subtitle/types";
import {
maskTags,
Expand Down Expand Up @@ -29,6 +29,19 @@ export interface TranslateParams {
batchSize?: number; // default 10
contextLines?: number; // default 6
maxRetries?: number; // default 2
/**
* Batches translated in parallel (default 1 = fully sequential). Values >1
* speed up cloud endpoints considerably; context continuity becomes
* best-effort (a batch sees whatever earlier lines have finished). Local
* single-slot servers (LM Studio, llama.cpp default) serialize requests
* anyway, so >1 buys little there.
*/
concurrency?: number;
/**
* Base delay between transient-error retries (network/timeout/429/5xx),
* default 1000ms; grows linearly per attempt. Tests pass 0.
*/
retryDelayMs?: number;
/** Free-text background about the material, injected into the system prompt. */
contextNote?: string;
/** Term mappings the model must honour; injected into the system prompt. */
Expand Down Expand Up @@ -72,10 +85,14 @@ export async function translateCues(params: TranslateParams): Promise<void> {
const batchSize = Math.max(1, params.batchSize ?? 10);
const contextLines = Math.max(0, params.contextLines ?? 6);
const maxRetries = Math.max(0, params.maxRetries ?? 2);
const concurrency = Math.max(1, params.concurrency ?? 1);
const retryDelayMs = params.retryDelayMs ?? 1000;
const tone = toneInstruction(params.tone ?? "");

const prepared: Prepared[] = cues.map((cue) => {
const { masked, map } = maskTags(cue.text);
// Translate from the immutable source (parsers always set it); `cue.text`
// may already hold a previous translation or a manual edit.
const { masked, map } = maskTags(cue.source ?? cue.text);
return { cue, masked, map, translatable: hasTranslatableText(masked) };
});

Expand All @@ -92,6 +109,22 @@ export async function translateCues(params: TranslateParams): Promise<void> {
if (params.signal?.aborted) throw new DOMException("Translation cancelled", "AbortError");
};

// One network blip or 429 must not kill a long run: retry transient errors
// a couple of times with a short growing delay, then give up for real.
const chat = async (messages: ChatMessage[]): Promise<string> => {
for (let attempt = 0; ; attempt++) {
try {
return await backend.chat(messages, params.signal);
} catch (err) {
if (attempt >= TRANSIENT_RETRIES || !isTransientLlmError(err) || params.signal?.aborted) {
throw err;
}
await new Promise((r) => setTimeout(r, retryDelayMs * (attempt + 1)));
ensureNotAborted();
}
}
};

// Render a masked translation into the cue: fix RTL bidi on the MASKED text
// (markup is still `⟦n⟧` tokens, so Latin inside restored tags is never touched,
// and break tokens let us force base direction per visual line), then restore.
Expand All @@ -113,7 +146,7 @@ export async function translateCues(params: TranslateParams): Promise<void> {
const runGroup = async (items: Prepared[], strict: boolean): Promise<Set<string>> => {
const batch: BatchLine[] = items.map((p) => ({ id: p.cue.id, masked: p.masked }));
const messages = buildMessages(sourceName, targetName, batch, context.slice(-contextLines), strict, params.contextNote, params.glossary, tone);
const reply = await backend.chat(messages, params.signal);
const reply = await chat(messages);
const map = parseTranslations(reply);
const failed = new Set<string>();
for (const p of items) {
Expand All @@ -136,14 +169,13 @@ export async function translateCues(params: TranslateParams): Promise<void> {
params.glossary,
tone,
);
const reply = await backend.chat(messages, params.signal);
const reply = await chat(messages);
const parsed = parseTranslations(reply).get(p.cue.id) ?? lenientSingle(reply);
commit(p, sameTokens(p.masked, parsed) ? parsed : reconcileTokens(p.masked, parsed));
};

for (let i = 0; i < translatables.length; i += batchSize) {
const processBatch = async (slice: Prepared[]): Promise<void> => {
ensureNotAborted();
const slice = translatables.slice(i, i + batchSize);
let failed = await runGroup(slice, false);

for (let attempt = 0; attempt < maxRetries && failed.size > 0; attempt++) {
Expand All @@ -156,47 +188,88 @@ export async function translateCues(params: TranslateParams): Promise<void> {
ensureNotAborted();
await translateSingle(p);
}
};

const batches: Prepared[][] = [];
for (let i = 0; i < translatables.length; i += batchSize) {
batches.push(translatables.slice(i, i + batchSize));
}

if (params.refine) await refinePass();
await runPool(concurrency, batches.length, (i) => processBatch(batches[i]));

// Second pass: ask the model to improve each batch's draft. Best-effort —
// one call per batch, no retry/fallback; a refinement that fails id/token
// one call per batch, no fallback; a refinement that fails id/token
// validation is dropped so the (already valid) first-pass draft is kept.
async function refinePass(): Promise<void> {
for (let i = 0; i < translatables.length; i += batchSize) {
ensureNotAborted();
const slice = translatables.slice(i, i + batchSize);
const batch: RefineLine[] = slice.map((p) => ({
id: p.cue.id,
source: p.masked,
draft: p.draftMasked ?? p.masked,
}));
const messages = buildRefineMessages(
sourceName,
targetName,
batch,
params.contextNote,
params.glossary,
tone,
);

let map: Map<string, string> | null = null;
async function refineBatch(slice: Prepared[]): Promise<void> {
ensureNotAborted();
const batch: RefineLine[] = slice.map((p) => ({
id: p.cue.id,
source: p.masked,
draft: p.draftMasked ?? p.masked,
}));
const messages = buildRefineMessages(
sourceName,
targetName,
batch,
params.contextNote,
params.glossary,
tone,
);

let map: Map<string, string> | null = null;
try {
map = parseTranslations(await chat(messages));
} catch (err) {
if (params.signal?.aborted) throw err;
// A flaky refine call must not discard good drafts: keep them, move on.
}

for (const p of slice) {
const cand = map?.get(p.cue.id);
if (cand !== undefined && sameTokens(p.masked, cand)) finalize(p, cand);
done++;
params.onProgress?.(done, total);
}
}

if (params.refine) {
await runPool(concurrency, batches.length, (i) => refineBatch(batches[i]));
}
}

/** Attempts beyond the first for a transient (network/timeout/429/5xx) error. */
const TRANSIENT_RETRIES = 2;

/**
* Run `jobs` async tasks with at most `limit` in flight. The first failure
* stops workers from picking up new jobs (batches already in flight finish
* and their results are kept) and is rethrown once all workers settle.
*/
async function runPool(
limit: number,
jobs: number,
run: (i: number) => Promise<void>,
): Promise<void> {
let next = 0;
let failed = false;
let firstError: unknown;
const worker = async () => {
while (!failed && next < jobs) {
const i = next++;
try {
map = parseTranslations(await backend.chat(messages, params.signal));
await run(i);
} catch (err) {
if (params.signal?.aborted) throw err;
// A flaky refine call must not discard good drafts: keep them, move on.
}

for (const p of slice) {
const cand = map?.get(p.cue.id);
if (cand !== undefined && sameTokens(p.masked, cand)) finalize(p, cand);
done++;
params.onProgress?.(done, total);
if (!failed) {
failed = true;
firstError = err;
}
return;
}
}
}
};
const workers = Math.max(1, Math.min(limit, jobs));
await Promise.all(Array.from({ length: workers }, worker));
if (failed) throw firstError;
}

/** Force `candidate` to carry exactly the formatting tokens of `sourceMasked`. */
Expand Down
Loading
Loading