diff --git a/README.md b/README.md
index 4deb023..22ec90e 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/index.html b/index.html
index 78a1023..1c6193e 100644
--- a/index.html
+++ b/index.html
@@ -77,6 +77,10 @@
Translation options
How many already-translated lines to show the model for continuity (tone, names, gender). 0 disables context.
+ Parallel requests
+
+ 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.
+
Tone / formality
Register to aim for. Formal/Informal also nudge the polite vs familiar "you" (vous/Sie vs tu/du) where the language has one.
diff --git a/package.json b/package.json
index 116ff1d..46e97a9 100644
--- a/package.json
+++ b/package.json
@@ -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": {
diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock
index 0818067..fb25b04 100644
--- a/src-tauri/Cargo.lock
+++ b/src-tauri/Cargo.lock
@@ -77,7 +77,7 @@ checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "app"
-version = "0.2.0"
+version = "0.3.0"
dependencies = [
"chardetng",
"encoding_rs",
diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml
index 94e6572..3709e8b 100644
--- a/src-tauri/Cargo.toml
+++ b/src-tauri/Cargo.toml
@@ -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"
diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json
index e132e96..873d8c0 100644
--- a/src-tauri/tauri.conf.json
+++ b/src-tauri/tauri.conf.json
@@ -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",
diff --git a/src/lib/llm/client.ts b/src/lib/llm/client.ts
index 3c0a6f5..6f0ba65 100644
--- a/src/lib/llm/client.ts
+++ b/src/lib/llm/client.ts
@@ -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;
}
diff --git a/src/lib/settings.ts b/src/lib/settings.ts
index 41d135a..90a0488 100644
--- a/src/lib/settings.ts
+++ b/src/lib/settings.ts
@@ -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;
@@ -34,6 +36,7 @@ export const DEFAULT_SETTINGS: AppSettings = {
targetLang: "ar",
batchSize: 10,
contextLines: 6,
+ parallelRequests: 1,
temperature: 0.2,
contextNote: "",
glossary: "",
diff --git a/src/lib/subtitle/ass.ts b/src/lib/subtitle/ass.ts
index 4c23399..f2aec6b 100644
--- a/src/lib/subtitle/ass.ts
+++ b/src/lib/subtitle/ass.ts
@@ -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) => {
diff --git a/src/lib/subtitle/srt.ts b/src/lib/subtitle/srt.ts
index 6592ec0..4cae181 100644
--- a/src/lib/subtitle/srt.ts
+++ b/src/lib/subtitle/srt.ts
@@ -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;
diff --git a/src/lib/subtitle/types.ts b/src/lib/subtitle/types.ts
index ebf1865..663df28 100644
--- a/src/lib/subtitle/types.ts
+++ b/src/lib/subtitle/types.ts
@@ -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;
diff --git a/src/lib/translate/engine.ts b/src/lib/translate/engine.ts
index c59b900..734c56e 100644
--- a/src/lib/translate/engine.ts
+++ b/src/lib/translate/engine.ts
@@ -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,
@@ -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. */
@@ -72,10 +85,14 @@ export async function translateCues(params: TranslateParams): Promise {
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) };
});
@@ -92,6 +109,22 @@ export async function translateCues(params: TranslateParams): Promise {
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 => {
+ 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.
@@ -113,7 +146,7 @@ export async function translateCues(params: TranslateParams): Promise {
const runGroup = async (items: Prepared[], strict: boolean): Promise> => {
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();
for (const p of items) {
@@ -136,14 +169,13 @@ export async function translateCues(params: TranslateParams): Promise {
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 => {
ensureNotAborted();
- const slice = translatables.slice(i, i + batchSize);
let failed = await runGroup(slice, false);
for (let attempt = 0; attempt < maxRetries && failed.size > 0; attempt++) {
@@ -156,47 +188,88 @@ export async function translateCues(params: TranslateParams): Promise {
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 {
- 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 | null = null;
+ async function refineBatch(slice: Prepared[]): Promise {
+ 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 | 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,
+): Promise {
+ 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`. */
diff --git a/src/main.ts b/src/main.ts
index 8722644..0bf627b 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -3,7 +3,7 @@ import { fetch as tauriFetch } from "@tauri-apps/plugin-http";
import { parseSubtitle, type ParsedSubtitle } from "./lib/subtitle";
import type { Cue } from "./lib/subtitle/types";
-import { LlmClient, reasoningEffortFor, type FetchLike, type LlmConfig } from "./lib/llm/client";
+import { LlmClient, LlmError, reasoningEffortFor, type FetchLike, type LlmConfig } from "./lib/llm/client";
import { ENDPOINT_PRESETS, groupedPresets } from "./lib/llm/presets";
import { translateCues } from "./lib/translate/engine";
import { parseGlossary } from "./lib/translate/glossary";
@@ -71,7 +71,11 @@ function msg(err: unknown): string {
}
function isAbort(err: unknown): boolean {
- return err instanceof DOMException && err.name === "AbortError";
+ // Cancelling mid-request surfaces as LlmError("aborted"), not a DOMException.
+ return (
+ (err instanceof DOMException && err.name === "AbortError") ||
+ (err instanceof LlmError && err.kind === "aborted")
+ );
}
let toastTimer: number | undefined;
@@ -295,6 +299,7 @@ function applySettings(s: AppSettings): void {
el("targetLang").value = s.targetLang;
el("batchSize").value = String(s.batchSize);
el("contextLines").value = String(s.contextLines);
+ el("parallelRequests").value = String(s.parallelRequests);
el("temperature").value = String(s.temperature);
el("contextNote").value = s.contextNote;
el("glossary").value = s.glossary;
@@ -320,6 +325,7 @@ function gatherSettings(): Omit {
targetLang: el("targetLang").value,
batchSize: int("batchSize", 10),
contextLines: int("contextLines", 6),
+ parallelRequests: Math.max(1, int("parallelRequests", 1)),
temperature: parseFloat(el("temperature").value) || 0,
contextNote: el("contextNote").value,
glossary: el("glossary").value,
@@ -418,7 +424,7 @@ function renderPreview(p: ParsedSubtitle): void {
const orig = document.createElement("div");
orig.className = "cell orig";
- orig.textContent = cue.text; // source, captured before translation mutates it
+ orig.textContent = cue.source ?? cue.text;
const trans = document.createElement("div");
trans.className = "cell trans";
@@ -504,6 +510,18 @@ function refreshTranslationCells(): void {
}
}
+// Progress ticks fire once per translated line; refreshing the whole table on
+// each would be quadratic on large files. Coalesce to one refresh per frame.
+let refreshQueued = false;
+function queueRefreshTranslationCells(): void {
+ if (refreshQueued) return;
+ refreshQueued = true;
+ requestAnimationFrame(() => {
+ refreshQueued = false;
+ refreshTranslationCells();
+ });
+}
+
// ---- actions ----------------------------------------------------------------
function setBusy(busy: boolean): void {
@@ -594,6 +612,7 @@ async function translate(): Promise {
targetName: promptName(settings.targetLang),
batchSize: settings.batchSize,
contextLines: settings.contextLines,
+ concurrency: settings.parallelRequests,
contextNote: settings.contextNote,
glossary: parseGlossary(settings.glossary),
tone: settings.tone,
@@ -659,7 +678,7 @@ function updateProgress(done: number, total: number): void {
if (rate > 0) extra = ` · ${rate.toFixed(1)} lines/s · ~${fmtDuration((total - done) / rate)} left`;
}
el("progressText").textContent = `${done} / ${total} (${pct}%)${extra}`;
- refreshTranslationCells();
+ queueRefreshTranslationCells();
}
function replaceAll(): void {
@@ -779,7 +798,7 @@ function wireEvents(): void {
// A key just appeared/changed -> probe the key-gated endpoint now.
if (canDiscoverModels()) void testConnection();
});
- for (const id of ["sourceLang", "model", "batchSize", "contextLines", "temperature", "contextNote", "glossary", "tone", "refine", "reasoning"]) {
+ for (const id of ["sourceLang", "model", "batchSize", "contextLines", "parallelRequests", "temperature", "contextNote", "glossary", "tone", "refine", "reasoning"]) {
el(id).addEventListener("change", persist);
}
setupModelDropdown();
diff --git a/tests/engine.test.ts b/tests/engine.test.ts
index 203f4a6..de174d3 100644
--- a/tests/engine.test.ts
+++ b/tests/engine.test.ts
@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest";
-import type { ChatBackend, ChatMessage } from "../src/lib/llm/client";
+import { LlmError, type ChatBackend, type ChatMessage } from "../src/lib/llm/client";
import { parseTranslations } from "../src/lib/translate/prompt";
import { translateCues, reconcileTokens } from "../src/lib/translate/engine";
import { RLE, LRE, PDF } from "../src/lib/translate/bidi";
@@ -272,6 +272,84 @@ describe("translateCues", () => {
expect(seen.at(-1)).toEqual([5, 5]);
});
+ it("translates from cue.source, not the current (already translated) text", async () => {
+ const cues = [{ id: "0", text: "HOLA — a previous translation", source: "hello" }];
+ await translateCues({
+ cues,
+ backend: new MockBackend(),
+ sourceName: "English",
+ targetName: "Spanish",
+ });
+ expect(cues[0].text).toBe("HELLO");
+ });
+
+ it("retries transient errors instead of failing the run", async () => {
+ const inner = new MockBackend();
+ let failures = 2;
+ const backend: ChatBackend = {
+ async chat(messages: ChatMessage[]) {
+ if (failures-- > 0) throw new LlmError("connection reset", "network");
+ return inner.chat(messages);
+ },
+ };
+ const cues = [{ id: "0", text: "hello" }];
+ await translateCues({
+ cues,
+ backend,
+ sourceName: "English",
+ targetName: "Spanish",
+ retryDelayMs: 0,
+ });
+ expect(cues[0].text).toBe("HELLO");
+ });
+
+ it("does not retry non-transient errors (e.g. 401)", async () => {
+ let calls = 0;
+ const backend: ChatBackend = {
+ async chat() {
+ calls++;
+ throw new LlmError("Server returned 401 Unauthorized", "http", 401);
+ },
+ };
+ await expect(
+ translateCues({
+ cues: [{ id: "0", text: "hello" }],
+ backend,
+ sourceName: "English",
+ targetName: "Spanish",
+ retryDelayMs: 0,
+ }),
+ ).rejects.toThrow("401");
+ expect(calls).toBe(1);
+ });
+
+ it("runs batches in parallel when concurrency > 1 and translates all lines", async () => {
+ let inFlight = 0;
+ let maxInFlight = 0;
+ const inner = new MockBackend();
+ const backend: ChatBackend = {
+ async chat(messages: ChatMessage[]) {
+ inFlight++;
+ maxInFlight = Math.max(maxInFlight, inFlight);
+ await new Promise((r) => setTimeout(r, 5));
+ const reply = await inner.chat(messages);
+ inFlight--;
+ return reply;
+ },
+ };
+ const cues = Array.from({ length: 6 }, (_, i) => ({ id: String(i), text: `line ${i}` }));
+ await translateCues({
+ cues,
+ backend,
+ sourceName: "English",
+ targetName: "Spanish",
+ batchSize: 2,
+ concurrency: 3,
+ });
+ expect(maxInFlight).toBeGreaterThan(1);
+ expect(cues.map((c) => c.text)).toEqual(cues.map((_, i) => `LINE ${i}`));
+ });
+
it("rejects when the signal is already aborted", async () => {
const ac = new AbortController();
ac.abort();