Adds opt-in word-timed ASS karaoke captions while preserving the existing line-caption default.\n\nCo-authored-by: Matt Van Horn <mvanhorn@users.noreply.github.com>
Add OrcaRouter to the Settings → LLM Providers registry (OpenAI-compatible
gateway, base_url https://api.orcarouter.ai/v1, default openai/gpt-5.5).
Env surface follows the existing provider pattern: ORCAROUTER_API_KEY /
ORCAROUTER_BASE_URL / ORCAROUTER_MODEL.
- registry: Provider entry after OpenRouter
- llm_backend: include OrcaRouter in the not-configured hint
- settings search: 'orcarouter' keyword on the LLM Providers category
- docs: list OrcaRouter in the supported-provider docs (docs-sync)
- test: registry test covers the new id
Co-Authored-By: Claude <noreply@anthropic.com>
Renames what users see. The app, the installers, the window title, the
docs and all 21 locales now say VoiceStudio, with "(previously
OmniVoice-Studio)" noted near the title of each doc surface so people
recognise it.
Deliberately NOT renamed, because renaming any of them silently breaks
an existing install — there is no legacy-path fallback anywhere in this
codebase:
- bundle identifier com.debpalash.omnivoice-studio (MSI UpgradeCode,
macOS TCC grants, managed venv, WebView localStorage, the
single-instance lock)
- data directories OmniVoice / .omnivoice and omnivoice.db
- the ~150 OMNIVOICE_* environment variables
- the X-OmniVoice-* HTTP headers (a wire protocol)
- the published Docker image paths
- the OmniVoice ENGINE, which is a model name and not this product
tests/test_identity_paths_survive_the_rename.py pins every one of those
so a future well-meaning sweep cannot orphan a user's library.
Linux .deb users install a new package name and should apt remove
omnivoice-studio; that note is in the changelog.
After transcription the user can paste a translation produced elsewhere
(ChatGPT, DeepL, a human translator) and have it map onto the segments
that already exist — no re-transcription, no timing loss.
Three input shapes are auto-detected: a timestamped .srt/.vtt (cues matched
to segments by time overlap, greedy one-to-one so one long cue can't be
copied onto several rows), numbered lines (`1.` / `2)` / `[3]`, mapped by
number and falling back to order when a model renumbers mid-answer), and
plain lines (positional, blank lines treated as separators rather than
empty translations). Nothing is applied until the preview dialog has shown
every row as before→after with unmatched rows flagged.
Applying goes through `pasteTranslations` in useSegmentEditing, which
mirrors `segmentEditField`'s duties across rows in ONE undo step: write
`text` and `translations[dubLangCode]` in lock-step and clear the stale
machine-translation badges. It never writes `text_original` (the translate
source `handleTranslateAll` reads — overwriting it would poison every later
re-translate) and never touches a language other than the active one.
Changing `text` alone marks those rows stale via the existing per-language
fingerprints, so no new flag is needed.
The new `POST /dub/parse-subtitle-text` is a stateless wrapper over the
existing `services.srt_parser.parse_srt`, so the lenient cue parsing stays
single-sourced instead of being reimplemented in JavaScript.
Also fixes a ReDoS in that parser, reachable today via /dub/import-srt:
`_TIMING_RE` used `^\s*` under re.MULTILINE, so at every line start the
engine consumed all remaining blank lines before failing on the first
digit — quadratic. 20k blank lines already took 1.7s and a 2 MB blank-line
file never returned, pinning the request thread. Horizontal-whitespace-only
classes make the scan linear.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
New pure planning layer (services/duration_planner.py) runs after translation,
before TTS: estimates each translated line's natural speech duration (self-
calibrating from the job's already-synthesized segments, static per-language
rates as cold-start fallback) and classifies it fits/tight/impossible against
slot + capped gap borrow, with thresholds derived from fit_planner's own caps
so "impossible" means "would be trimmed". Verdicts ride the /dub/translate
response and badge the segment table; an opt-in (default OFF) LLM pass attaches
one-click shorter-rewrite suggestions for impossible lines. Never blocks
generation — informs before GPU time is burned.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
One up-front LLM pass over the full transcript extracts a theme summary +
terminology map, merges it under the user's manual glossary (user entries
always win), caches it on the dub job per target language (job_data blob, no
schema change), and injects the brief into every per-segment prompt. A new
reflect pass then critiques each segment's direct translation for wordiness /
stiff register and rewrites it as natural spoken dialogue — any failure or
divergence silently keeps the direct translation. Both stages have Dub-tab
toggles (default ON for the LLM engine, persisted; MT engines unaffected),
with i18n strings across all 21 locales and docs updated.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
New Settings → System → LLM Skills area: every LLM-powered capability
(Cinematic & Autofit translation, speech-rate slot fitting, glossary
auto-extract, direction parsing, dictation cleanup) becomes a "skill" the
user can toggle or route to a specific provider (local Ollama/LM Studio vs
a remote key) instead of everything riding the one global active provider.
Backend:
- services/llm_skills.py — skill registry + settings_store persistence
(llm_skill.<id>.enabled / .provider), resolution precedence
override > active > none, resolve_skill_client() (OpenAI-compat client
bound to the effective provider; None when disabled/unconfigured) and
skill_backend() (OffBackend when disabled — the exact no-LLM object every
caller already degrades on).
- All five consumption points wired through the registry; a disabled skill
degrades exactly like "no LLM configured" today (Fast translation
fallback, refinement pass-through, heuristic direction parse, no-llm slot
fit, 503 on glossary auto-extract). No new degradation modes; defaults
(enabled + no override) keep existing setups byte-identical.
- OpenAICompatBackend gains an optional bound provider (None = active, the
historical behavior).
- GET /api/settings/llm-skills + PUT /api/settings/llm-skills/{skill_id}
(404 unknown skill/provider); route snapshot updated.
Frontend:
- LLMSkillsPanel (Sparkles, next to LLM Providers): one row per skill —
i18n name/description, enable toggle, provider Select ("Use active
provider" + configured providers, local ones tagged), ready /
needs-setup badge linking to LLM Providers. All strings via t()
(settings.llmskills_*).
Tests: 30 backend (precedence, per-consumption-point disabled semantics,
endpoint round-trips, validation) + 4 panel render/PUT tests. Docs:
translation-engines.md gains an LLM Skills section.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
P0 — Cinematic/Autofit silently no-op'd on argos/nllb/openai. Those three
branches returned BEFORE _maybe_cinematic, so only the deep_translator
fall-through reached the refine/fit pass. A user on the DEFAULT Argos engine
who picked Cinematic/Autofit got plain Fast output with a success toast and
no quality_used/cinematic_skipped/rate_ratio. All three now route through
_maybe_cinematic. provider=openai is already an LLM translation, so it skips
the reflect/adapt re-refine (new already_llm flag) but still stamps
rate-ratio badges and runs the Autofit fit pass; the dialect it baked into
its translate prompt is now reported applied.
P1 — the Autofit fit pass ran one blocking adjust_for_slot per segment in the
merge loop, OUTSIDE any budget (a 50-seg dub vs a slow provider spun
~50×timeout unbounded). New speech_rate.adjust_for_slot_many fans it out
concurrently under a wall-clock deadline SHARED with the cinematic refine;
segments still running at the deadline degrade to their literal with
rate_error='fit-budget'. Also set max_retries=0 on the OpenAI clients used
for translate/refine/fit so a 429 + Retry-After can't sleep through the budget.
P2 — glossary auto-extract's no-LLM message now points at Settings → LLM
Providers (was the stale TRANSLATE_BASE_URL/TRANSLATE_API_KEY). Provider error
bodies on the glossary auto-extract, the OpenAI translate-segment path, and the
DeepL/Microsoft translate-segment path are now scrubbed
(core.scrub.scrub_provider_error) — they could echo the API key / a user_id.
DubTab re-polls LLM availability on window focus / visibility so configuring a
provider in Settings lifts the Cinematic gate without a remount. Documented
LLM_DEFAULT_PROVIDER in docs/dubbing/translation-engines.md.
Tests: fail-before/pass-after for argos+cinematic (refine runs), argos+cinematic
no-LLM (cinematic_skipped), argos Fast (rate_ratio stamped), openai+autofit
budget bound, and provider-error scrubbing on the translate + glossary paths.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* feat(llm): multi-provider LLM registry + encrypted key storage + settings API (v0.3.8, phase 1)
Foundation for the LLM Providers settings page and timing-aware (Autofit)
translation. Every provider in the shipped .env is OpenAI-compatible, so one
client drives all of them via a registry instead of a class-per-provider.
- llm_providers.py: registry of 16 providers (OpenAI, OpenRouter, Groq,
Cerebras, Google AI, Mistral, Cohere, NVIDIA, GitHub Models, Cloudflare,
HuggingFace, SambaNova, SiliconFlow, + local Ollama/LM Studio + Custom).
Field resolution precedence env → encrypted store → default; active-provider
selection (LLM_DEFAULT_PROVIDER → stored → first keyed remote; local requires
explicit pick so we never assume a local server is up). Legacy TRANSLATE_*
maps to the Custom provider (keyless-with-base_url preserved).
- settings_store.py: generic ENCRYPTED secrets (get/set/clear_secret,
list_secret_names) reusing the HF-token Fernet path; get_text/set_text now
refuse the secret namespace (no ciphertext leak).
- llm_backend.py: OpenAICompatBackend resolves the active provider's
base_url/key/model from the registry. Backward-compatible.
- settings API: GET /llm-providers, PUT /llm-providers/{id} (encrypted key +
overrides), POST /llm-providers/active, POST /llm-providers/{id}/test.
Loopback-gated; never returns key material.
- 11 registry tests; existing llm-endpoint/openai-available tests still green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(settings): LLM Providers page — configure any provider's key/URL/model + Test + set active (v0.3.8, phase 2)
New Settings → System → LLM Providers pane (Brain icon, searchable). Lists all
16 registry providers; pick one to configure its encrypted API key, base URL,
model (and Cloudflare account id), Test the connection with one round-trip, and
'Save & use for translation' to make it the active provider for Cinematic/
Autofit. Keys are write-only from the UI (masked placeholder, never echoed);
env-set keys show as read-only. Local providers (Ollama/LM Studio) need no key.
- LLMProvidersPanel.jsx: provider selector + per-provider config + Test/activate,
following the LLMEndpointPanel pattern (apiJson/apiFetch/apiPost, SettingsSection
primitives).
- settingsCategories.jsx: new 'llm-providers' category under System + Brain icon.
- Settings.jsx: route the category to the panel.
- en.json: settings.llm_providers label.
- Frontend build passes.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(translate): Autofit quality style + one-click LLM setup from the dub menu (v0.3.8, phases 3-4)
Autofit = Cinematic + a strict 'never exceed the segment time' fit. The LLM
rewrites each translated line so its target-language reading time fits within
the slot, preserving the video timing without harsh audio time-stretch.
Backend:
- speech_rate.adjust_for_slot(strict=): strict caps the accepted upper ratio at
1.0 (fit within slot) vs Cinematic's 1.08; best-effort, degrades gracefully
with no LLM.
- dub_translate: quality='autofit' takes the LLM refine path and runs the fit
pass with strict=True; reports quality_used accurately.
- TranslateRequest.quality doc note.
Frontend:
- 'autofit' added to the quality control (Settings Translation + dub menu) and
the TranslateQuality type.
- Dub menu: picking Cinematic/Autofit with no LLM no longer dead-ends on a toast
— it offers a one-click 'Set up' that routes to Settings → LLM Providers,
with copy about fitting translations to segment time (#838).
- 4 strict-fit tests; frontend build green; i18n keys added.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(translate): document Autofit quality + the LLM Providers page (v0.3.8, phase 5)
- CHANGELOG [0.3.8] Added: Autofit style + LLM Providers page.
- docs/dubbing/translation-engines.md: Fast/Autofit/Cinematic quality section
and an LLM Providers setup section (16 providers, encrypted keys, offline
Ollama/LM Studio, env overrides).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(api): add /api/settings/llm-providers routes to the route-inventory snapshot
Regenerated tests/fixtures/api_routes.txt for the 4 new LLM-providers endpoints
so test_route_inventory_matches_snapshot passes (keep-main-green).
* style(frontend): oxfmt the LLM Providers panel + dub quality control (format:check green)
---------
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two related Dub-tab translation-flow fixes, one PR.
TASK 1 — proactive, highlighted Install affordance in the translate engine
selector (replaces "find out only via a translate-time 400"):
- FROM-SOURCE lane (activeEngineUnavailable && !enginesSandboxed): the muted
install chip is promoted to a HIGHLIGHTED brand-accent Install button, still
wired to handleInstallEngine(translateProvider) with the installing/disabled
state. Selecting any uninstalled engine surfaces it immediately.
- FROZEN lane (enginesSandboxed): pip install is impossible in the read-only,
signed packaged env, so the disabled "needs dev install" span becomes an
equally highlighted button opening a popover with (1) the exact install
command + copy-to-clipboard, (2) one-click "Switch to Argos (bundled,
offline)" — the guaranteed importable escape hatch, and (3) a Docs link via
the existing Tauri shell.open path. Gated on the existing `sandboxed` flag,
not platform.
- Single-source install command: new translation_engines.install_command()
is the one source of truth; list_engines() stamps `install_command` per
engine and BOTH the argos + deep_translator translate-time 400 messages build
their command from it, so the proactive button and the 400 can't drift.
engines.ts gains `install_command: string | null`.
TASK 2 — the translation error banner now dismisses and clears (class fix):
- Root cause: handleTranslateAll never cleared dubError, so a stale 400
survived even a successful retry. It now clears at the start of every
attempt.
- Corrective-action clears (whole class): changing the engine and installing
the package both clear dubError (wrapped setTranslateProvider +
handleInstallEngine in DubTab).
- DubFooter's banner gains a × dismiss and a guarded auto-timeout (skipped
while generating so live per-segment errors persist).
i18n: 8 new dub.* keys translated across all 21 locales. Docs: new
docs/dubbing/translation-engines.md (from-source vs packaged build) linked from
the popover Docs button + a troubleshooting cross-reference. Tests: FE
regression for both lanes + never-installs-when-sandboxed + banner
dismiss/auto-clear; BE regression that list_engines() install_command is
embedded verbatim in the dub_translate 400s.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>