* fix(audio): remove hidden reverb from the mastering pre-stage — reverb is preset-declared only (#TBD)
Field report (Discord): baked-in echo/reverb on some voices. apply_mastering()
hardcoded a Reverb that ran on every non-raw synthesis before the user's
preset chain — broadcast shipped reverb it never declared, podcast broke its
"no reverb" promise, cinematic/warm got doubled reverb.
The mastering pre-stage is now data-driven (MASTERING_CHAIN: highpass +
compressor, same params as before) and reverb-free; cinematic/warm keep their
user-chosen reverb. Regression tests pin the contract, incl. a burst-then-
silence echo-tail check and pedalboard-missing passthrough.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs(changelog): hidden mastering reverb entry (#986)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* fix(setup): first-run network check is mirror-aware and never hard-blocks
Field report (Discord, China): the Launchpad preflight probed hardcoded
huggingface.co:443 and any failure disabled Continue outright — users behind
the GFW were stuck on the very first screen, before Settings (and its
HF mirror quick-pick) was even reachable.
- The probe now targets the HF endpoint actually in effect (HF_ENDPOINT /
hf_endpoint pref via configured_hf_mirror), with the real port.
- An unreachable endpoint is a WARNING, not a blocker: local-first — cached
models work offline, and downloads surface their own actionable errors.
- When huggingface.co is blocked but hf-mirror.com answers, the fix text says
exactly that, and the wizard shows an inline mirror quick-pick (presets +
custom URL) that applies via PUT /hf-mirror — effective immediately for
downloads — then re-checks.
- Docs updated (downloading-models, install troubleshooting); regression
tests cover warn-not-fail, mirror-host probing, and the mirror suggestion.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs(changelog): open [Unreleased] with the preflight mirror fix (#984)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
package.json + three mirrors -> 0.3.11 in lockstep; Cargo.lock/uv.lock/
bun.lock regenerated; CHANGELOG [Unreleased] -> [0.3.11] — 2026-07-05
with the multi-language-release headline; nine entries since v0.3.10.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* feat(diagnostics): backend crashes become self-documenting — exit code + stderr tail surfaced and attached to bug reports
When the backend PROCESS died (native CUDA abort, OOM kill, DLL crash) the
user saw only "Can't reach the local OmniVoice backend" and the evidence
died with the process — every #941-class report needed a logs-please
round-trip nobody answers. The v0.3.9 guard fixed HANGS; this fixes the
class of invisible DEATHS:
- Rust (crash.rs): every unexpected child exit — detected by the startup
health poll and the post-Ready supervisor — writes a rotating (last 3)
JSON crash marker next to the backend logs: ts, exit code/signal,
backend version, uptime, ~40-line stderr tail. Intentional shutdowns
never forensicate: app-quit raises the quitting flag first (now also on
macOS Cmd+Q via ExitRequested), and retry/clean-retry kills set a
BACKEND_KILL_INTENDED flag cleared when the fresh child is tracked.
- Tauri commands get_last_backend_crash / acknowledge_backend_crash;
ack is a persisted watermark, never a delete — bug reports still get
the evidence after the user viewed it.
- Crash-loop escalation: the supervisor budget goes 5-in-60s → 3-in-10min
so slow crash loops stop respawning and land on the Failed screen with
the last exit code + stderr tail.
- Frontend: apiFetch's transport-failure path swaps the vague message for
"the backend crashed (exit code X) N s ago…" when an unacknowledged
marker exists, and BackendCrashNotice (banner + details dialog,
i18n'd, ack-on-view) surfaces it even with no request in flight.
- Bug-report prefill gains a "Last backend crash" section (exit code +
home-path-scrubbed stderr tail via the existing scrubText), so the next
report arrives WITH the evidence.
Tests: cargo --lib 57 pass (marker rotation write-4-keep-3, ack
semantics, store IO, ExitStatus decomposition, 3-in-10min policy);
vitest 909 pass incl. crash-notice branch, client crash-message branch,
bug-report enrichment; legacy node:test 41 pass.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs(changelog): add backend crash forensics under [Unreleased] (#969)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* fix(ui): timeline box colors pre-blended in JS — visible on any WebView2, color-mix dependency removed (#963)
#951 moved the segment-box palette to `color-mix(in srgb, tint 45%,
var(--chrome-bg))` strings applied as inline styles. WebView2/Chromium
< 111 has no color-mix, so the CSSOM rejects the whole `background`
assignment — and since .seg-track__box declares no background of its
own, the boxes rendered fully transparent on pinned/enterprise WebView2
runtimes (the Windows installer never enforces a minimum runtime).
Fix the class, not the instance: no engine-dependent CSS may reach this
lane's inline styles. The 0.45·tint + 0.55·bg blend now happens in JS —
timeline.js keeps the tints as numeric [r,g,b], reads --chrome-bg off
the document root (fallback #0f1011), and emits literal `rgb(r, g, b)`
strings every engine parses. Pixel-identical to what color-mix painted.
Theme-awareness is preserved by re-blending when [data-theme] changes
on <html> (the seam App.jsx switches themes through), observed via
MutationObserver; SegmentTrack subscribes with useSyncExternalStore so
mounted boxes recolor live.
Guards updated: palette entries must match plain opaque rgb() (no
color-mix/var()/alpha), the default-theme blend is asserted against
independently computed literals, theme-change re-blend and rgb()/
garbage --chrome-bg parsing are covered, and SegmentTrack's rendered
inline background is asserted to be a literal rgb() — fails on any
reintroduction of engine-dependent CSS in this lane.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs(changelog): add WebView2 box-color fix under [Unreleased] (#968)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* fix(dub): dialogue starts stop snapping to footsteps — sustained-energy onsets, bounded snap distance (#963)
Field report #963 (point 3): dubbed speakers start seconds early or late.
The reporter's own theory was right on the money — 'when a noise is heard
(a sigh or footsteps), it's interpreted as the start of the conversation.'
The #280 onset snapper took the FIRST 20 ms frame above an adaptive RMS
threshold as the speech onset, so any transient qualified; it also had no
snap-distance bound (a wrong onset could move a start by the whole segment
minus 0.3 s) and ran even when Demucs had failed and the 'vocals' track was
really the raw mix, where every ambient sound is a candidate.
Three layered guards, all pure NumPy (no new deps):
- Sustained energy: an onset only counts when >=160 ms of the following
300 ms stays above the threshold. Footsteps/door thuds light up one or
two frames and die; syllables keep the energy up.
- Bounded snap distance: shifts beyond 1.5 s are only trusted when the
skipped span is (near-)silent — that is exactly the genuine #280
whisper start-stretch on the vocals track (Demucs removed the music,
leaving real silence), so long trims over silence still work in full.
Long jumps over audible content (e.g. quiet speech under the relative
threshold) are refused instead of playing the dub seconds late; an
isolated transient in the span (<10% audible frames) doesn't block it.
- Source-aware: snapping now runs only on the separated vocals track.
dub_core detects the Demucs fallback (vocals_path == audio_path, see
dub_pipeline) at both call sites and passes separated_vocals=False on
mixed audio, disabling snapping — whisper's own timestamps beat a
confidently wrong snap when music/ambience is sustained energy too.
Tests (tests/test_onset_align.py, fail-before/pass-after): transient burst
rejected at detect- and snap-level, transient-only window yields no onset,
long jump over audible content refused, bounded shift over audible lead
still allowed, >1.5 s trim over true silence still snaps (#280 regression
guard), mixed-audio mode is a no-op. 28 pass in the file; full dub-adjacent
suites green.
Credit: theory and repro description by the #963 reporter.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs(changelog): add onset-snap robustness under [Unreleased] (#967)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* fix(net): SOCKS-proxy users can synthesize again — ship socksio, cache-first model resolution, degrade LLM clients (#959)
Under ALL_PROXY/HTTPS_PROXY=socks5:// without socksio installed, httpx
raises ImportError AT CLIENT CONSTRUCTION ("Using SOCKS proxy, but the
'socksio' package is not installed"). huggingface_hub's get_session()
builds exactly that client inside snapshot_download, so POST /generate
500'd with the bare message even for a fully installed model, and
preload_model's model_info probe hit the same error and silently
skipped warm-up. Latent since v0.3.5 — #947's fresh-process engine
spawning unmasked it in v0.3.10 by handing the user's proxy env
directly to a clean backend process.
Three layers, so the class (any session-construction failure) is dead,
not just the reported instance:
* Ship SOCKS support: socksio>=1.0 in [project] dependencies (pure
Python, MIT, zero transitive deps) AND in backend.spec hiddenimports
— httpx imports it lazily in try/except, so PyInstaller's tracer
misses it and the frozen installers would stay broken without the
explicit entry. uv.lock regenerated; `uv lock --check` and
`uv sync --frozen` (the Docker/release bootstrap semantics) verified.
* Cache-first model resolution: from_pretrained's snapshot resolution
extracted into _resolve_snapshot_dir() — local dir, else
snapshot_download(local_files_only=True) (a complete cache resolves
with NO HTTP session constructed), else the original network path.
preload_model's failed network probe now falls back to a cache-only
check and warms up anyway instead of silently skipping (honest log
either way).
* Class guards: resolve_skill_client wraps OpenAI() construction —
env-shaped construction failures degrade to the existing "LLM
unavailable" contract instead of 500ing the calling feature; and
core.failure learns SOCKS_PROXY_SUPPORT_MISSING with an actionable
hint, appended on the raw-string surfaces (global 500 handler,
model-install SSE) via the new append_hint().
Fail-before/pass-after verified by reverting the fix: 11 of the 12 new
tests fail pre-fix (the remaining one is the unchanged network-fallback
contract). 165 tests green across the touched suites.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs(changelog): add SOCKS-proxy resilience under [Unreleased] (#966)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* fix(settings): a saved LLM provider survives restart — explicit save activates, stale TRANSLATE_* prefs stop hijacking (#963)
"Ollama works until I restart OmniVoice" had three stacked causes:
1. Only "Save & use for translation" persisted the selection. Plain
"Save" and "Test" sent make_active:false, and on restart
active_provider_id() deliberately excludes local providers
(Ollama/LM Studio) from auto-select — so a saved-and-tested Ollama
was never resolved active again. The PUT handler now also claims the
active slot on an explicit save when the user has never chosen a
provider (new llm_providers.stored_active_provider_id(): the stored
row only — no env pin, no legacy fallback, no auto-detect). An
explicit prior choice is never stolen; an unconfigured provider
can't claim the slot; make_active:true still flips.
2. Users of the retired (≤v0.3.7) Translation-LLM panel had
env.TRANSLATE_* rows in prefs.json, re-imported into os.environ
every launch — and a live TRANSLATE_BASE_URL resolves the active
provider to "custom" ahead of auto-select on every restart. New
startup migration (llm_providers.migrate_legacy_translate_prefs,
run in main.py BEFORE the prefs→env import) moves those values into
the custom provider's own settings-store rows (only where the store
has no value yet) and deletes the prefs rows. Real process env vars
are never touched; a failed store write keeps the prefs row and
retries next boot. The legacy endpoint keeps working — via the
store, without hijacking the active slot.
3. The panel read as "done" after a green Test even when another
provider stayed active. It now shows a notice after save/Test when
the edited provider is not the effective active one (suppressed
while LLM_DEFAULT_PROVIDER pins the choice — the env banner already
covers that).
Tests (fail-before): 7 new backend tests fail on the old code
(save-activates, never-steals, migration semantics, env untouched,
end-to-end ollama-beats-legacy-env) and the new panel test fails
without the notice; all pass after. Full LLM/settings suites, frontend
vitest (890), typecheck:ci, oxlint, oxfmt and vite build are green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs(changelog): add LLM-provider persistence fix under [Unreleased] (#965)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* feat(dub): per-language translations + per-track caches — switching languages stops destroying work (P1)
Multi-language dubbing translated per language (#957) but stored everything
in single-slot state, so tracks silently destroyed each other's work:
P1.2 — per-language translation storage (additive):
- Frontend keeps every translation in s.translations[langCode] alongside the
legacy s.text slot (still = the shown language). Translate All writes both;
the new store action switchDubLangCode swaps text through the map on a
user-driven language switch (non-destructive; restore paths keep the plain
setter); manual edits / restore-original update the current language's
entry; merge joins per-language texts, split drops them. Rides project
save/load inside dubSegments — legacy projects behave exactly as before.
- Backend mirrors it as job["segments_i18n"] = {lang: {segKey: text}}
(segKey = stable id, index for id-less legacy rows), written by
_sync_job_segments; job["segments"] stays byte-identical for every existing
consumer. /dub/srt|vtt?lang= and subtitle burn-in now emit THAT language's
text when present — ExportModal's "all dubs" batch stops producing N
identical files. Legacy jobs without the field fall back to today's output.
P1.3 — per-track WAV cache + fingerprints:
- Per-segment WAVs are language-keyed (seg_{lang}_{id}.wav). The partial-regen
read path falls back to legacy seg_{id}.wav ONLY while the job has no
other-language track — single-language jobs keep their whole on-disk cache;
multi-track jobs stop splicing the last-generated language into the current
track. Read-only endpoints (segment preview, clips zip) gained ?lang= with
the permissive legacy fallback they always had.
- Fingerprints include the track language (segment_fingerprint(track_lang=…),
/tools/incremental lang=…) and live in job["seg_hashes_by_lang"]; the flat
job["seg_hashes"] stays as the current track's mirror so the done event,
history restore and older frontends read it unchanged. A legacy flat map is
attributed to the job's last-generated language (dropped when unknown) and
reads stale once — the safe direction. seg_wav_kind is per-track too.
- The frontend stores fingerprints per language and judges "Regen N changed"
against the ACTIVE track; project save/load and dub-history restore carry
all tracks' hashes (segHashesByLang / seg_hashes_by_lang, additive).
Tests: fail-before regression coverage — two-track regen never splices the
other language's audio (sample-level assert on the mixed track), legacy
single-track cache reuse + multi-track gate, per-lang seg_hashes with flat
mirror + migration semantics, /dub/srt|vtt?lang= emitting different text per
track with legacy fallbacks, per-lang burn-in, /tools/incremental lang
scoping, and 14 frontend tests for translations round-trips, per-track
fingerprints and legacy-project behaviour. Full backend + frontend suites,
typecheck, lint and format:check green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs(changelog): add per-language storage + per-track caches under [Unreleased] (#958)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* feat(dub): multi-language generate translates each language first + picks persist with the project
P1.1 — the "Generate N dubs" loop never translated: the backend synthesizes
segment text verbatim, so every multi-language track rendered the same words
and at most one was actually in its language. The loop now runs
translate → generate per pick:
- handleTranslateAll(langOverride?) accepts an optional ISO-code override
(no-arg Translate All behavior unchanged; a click-event first arg is
guarded). It resolves true only when a translation actually landed, and
both it and handleDubGenerate snapshot segments from the store at call
time — the click-time closures went stale the moment the previous pick's
translate pass rewrote the segments.
- A pick whose translate fails (request error or all-segments-errored) is
SKIPPED — never a wrong-language track — the batch continues, and the
skipped languages are reported in a final toast.
- The redundant first translate is skipped only when pick 1 targets the
language the editor text is already translated into; every later pick
always translates.
- Honest progress: the pill shows "Translating → {lang} (i/N)…" before each
generate, and the header CTA is inert while translating so a re-click
can't start a second batch (belt: a ref guard in the loop).
P1.4 — multiLangMode/multiLangs move from DubTab component state into the
dub store slice and ride the project save/load payload (exportTracks too).
Additive and back-compat: legacy payloads default to off/empty and leave the
in-session exportTracks untouched (utils/projectState.js).
Tests (fail-before verified: 9 failures on the pre-fix code):
- handleTranslateAll override targets + return semantics + call-time
segment snapshot (dubTranslateAllOverride.test.jsx)
- per-language translate-before-generate call order, skip-on-failure with
continuation + skip-report toast, first-pick skip heuristic, unchanged
single-language path (dubMultiLangGenerate.test.jsx)
- slice defaults/setters/reset, payload round-trip, legacy-payload defaults,
App.jsx wiring guards (dubMultiLangPersist.test.js)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs(changelog): add multi-lang auto-translate under [Unreleased] (#957)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* fix(dub): completed tracks always show their tabs + history keeps its language (P0)
Root cause chain: the track switcher's visibility expression required
dubLangCode !== 'und' and ended in a tautology (dubTracks?.length > 0 ||
!!dubTracks), so it was effectively keyed to the language dropdown, not
the persisted tracks. History restore always handed the frontend 'und'
because the dub_history language/language_code COLUMNS froze at the
ingest-time "" — the save_job UPSERT never updated them after generation
set them on the job dict (only the job_data JSON carried the real value).
Net effect: a restored project with finished tracks showed no track tabs
until the user re-picked a language.
- DubTab: hasDubbedTrack = done && dubTracks.length > 0 (tracks only;
also stops the tautology from showing a trackless switcher).
- DubTab auto-jump: membership-guarded — the preview only jumps to a
language that has a track, else tracks[0]. Kills the preview-404 class
(restores falling back to 'en' with tracks ['bn'] pointed the player
at /dub/preview-video?lang=en).
- dub_pipeline.save_job UPSERT: language/language_code now update when
non-empty (same CASE guard as content_hash), so new saves heal the
frozen columns and empty re-saves can't clobber them back.
- App.restoreDubHistory: falls back to job_data's language/language_code
so EXISTING rows in users' DBs restore correctly with no migration.
- P0.2 polish: track pills get duration + timing-strategy tooltips,
hydrated lazily and failure-silently from the existing
GET /dub/tracks/{job_id} via new api/dub.dubListTracks; all new
strings through i18n (en.json).
Tests (fail-before/pass-after): DubTab-level visibility + auto-jump
membership-guard tests (3 of 4 fail pre-fix), pill-tooltip hydration
tests, and save_job language heal/no-clobber tests (heal fails pre-fix).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs(changelog): open [Unreleased] with the dub track-tabs fix (#956)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
package.json (source of truth) + the three mirrors -> 0.3.10, in lockstep;
Cargo.lock/uv.lock/bun.lock regenerated (one line each; bun --frozen-lockfile
verified). CHANGELOG [Unreleased] -> [0.3.10] — 2026-07-05 with the release
headline; nine fixes since v0.3.9, mostly same-day field-report turnarounds.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* fix(audiobook): chapter render no longer crashes on mixed 1-D/2-D audio chunks (#897)
Root cause: synthesize_chapter (backend/services/audiobook.py) built
inter-span pause silence as bare 1-D torch.zeros(n) while every real
engine's synth returns (1, samples) per the TTSBackend.generate contract
(OmniVoice's model.generate(...)[0] included) — so the chapter's final
hard concat in chunked_tts.concatenate_audio_chunks hit
torch.cat with mixed ranks and died with
'RuntimeError: Tensors must have same number of dimensions: got 1 and 2'.
Any chapter containing a [pause] span (Stories/audiobook longform)
crashed; existing tests missed it because their stub synth returned 1-D.
The crossfade branch had the same latent bug for mixed-rank chunks.
Fix, both layers:
- concatenate_audio_chunks now normalizes chunk shapes before any cat
(_normalize_chunk_shapes): lower-rank chunks gain leading singleton
dims to the highest rank present, then singleton channel dims
broadcast to the widest channel count (mono follows stereo). Covers
both the hard-cut and crossfade branches; homogeneous input passes
through untouched, so all-1-D / all-2-D callers keep their exact
output shapes. No future backend's output rank can re-break the join.
- synthesize_chapter materializes silence AFTER the loop, matching the
rendered audio's channel dims / dtype / device — the same pattern
generation.py's _render_with_pauses already uses for the single-shot
path — so the data is rank-consistent at the source too. A
silence-only chapter stays 1-D float32 as before.
Regression tests: mixed-rank hard-cut (both orders), mixed-rank
crossfade, mono->stereo broadcast, all-1-D/all-2-D shape stability, a
2-D-engine + [pause] chapter through synthesize_chapter (the exact #897
scenario), and a spy asserting the parts reaching the concat are
rank-homogeneous. All fail before the fix with the reported error.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs(changelog): add the audiobook pause-span concat fix under [Unreleased] (#953)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* fix(dub): the speaker-count hint is honored on every diarization path + clone-purity guard
The dub "Speakers" count reached _diarize() and then died on 3 of its 4
branches, so setting it changed nothing, speakers blended, and auto-clones
were cut from mixed-speaker audio ("made up" voices):
- FunASR inline-turns shortcut returned before the hint was ever consulted
→ now an explicit num_speakers routes the job through pyannote (the one
engine that honors an exact count); turns stay the fast path only when no
hint is set, and remain the fallback (with an honest "hint ignored"
warning) when pyannote can't load or crashes mid-run.
- pyannote-unavailable fallback used a hardcoded 2-speaker silence-gap
heuristic → assign_speakers_heuristic now takes num_speakers and cycles N
labels on gap boundaries (1 → single speaker; None → legacy alternation),
and the existing diarization warning says the hint is only approximately
honored.
- pyannote-crash fallback dropped the hint the same way → same treatment.
No branch drops the hint silently anymore: every degraded path extends the
existing `warning` SSE payload (detail + a machine-readable speaker_hint
field) that the frontend already renders.
Parity + purity:
- POST /dub/transcribe/{job_id} (the CLI's endpoint) gains the same clamped
num_speakers query param, forwarded to pyannote and the heuristic; the
omnivoice-dub CLI gains --speakers N.
- Clone-purity guard: _pick_reference_slices rejects sub-1.5s slices, prefers
slices not temporally adjacent (<0.3s) to another speaker's turn (scoring
preference, not a hard filter), and extract_speaker_clones skips extraction
entirely when labels came from the heuristic (labels_source kwarg threaded
from _diarize; missing kwarg keeps the old behavior) — with a user-facing
warning pointing at Settings → Models → pyannote.
Tests: fail-before/pass-after coverage in tests/test_speaker_hint.py (all
four _diarize branches driven through the real SSE stream), clone-purity
guards in tests/test_speaker_clone_purity.py, heuristic hint semantics in
tests/test_segmentation.py.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs(changelog): add the speaker-hint + clone-purity fix under [Unreleased] (#952)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* fix(ui): dub timeline boxes can no longer flash invisible during playback (#373 class, completes #381)
Root cause: the segment lane animated an inline
`transform: translateX(-scrollLeft)` on every playback tick, so Chromium
promoted it to a compositor layer — and on some Windows GPU/WebView2
driver combos, composited semi-transparent paints (the 0.45-alpha box
fills) flash invisible/visible while the layer moves, settling only when
paused. PR #381 removed `will-change` and raised the alpha, which only
dampened the symptom; the animated transform kept the lane composited.
Fix the class — no composited translucent paints on the lane, ever:
- Position boxes in pure layout: the lane transform is gone; each box's
`left` is start·pxPerSec − scrollLeft (viewport coordinates). The
virtualization window already derives from the same scrollLeft, so
windowing stays consistent by construction. The selfScroll WebKit
fallback keeps lane coordinates (its viewport is a real scroll
container), unchanged.
- Belt-and-braces: REGION_COLORS are now fully opaque — each entry
pre-blends the old 45% tint against the surface behind the lane
(`--chrome-bg`, the .studio-panel background) via color-mix, which is
pixel-identical to the previous alpha compositing (0.45·tint + 0.55·bg)
in every theme, with zero alpha.
Regression tests (fail on pre-fix code): lane carries no transform at
rest and after a scroll update, box lefts are viewport-relative for a
scrolled view, no double-shift in the selfScroll fallback, and every
REGION_COLORS entry is alpha-free with the 45% ratio preserved.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs(changelog): add the timeline-box compositor fix under [Unreleased] (#951)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Root cause (v0.3.9 field report): the refine paths had no-op output guards.
Cinematic's ADAPT step only checked _looks_like_target_script, which returns
True unconditionally for every Latin-script target (no _SCRIPT_RANGES entry)
— so any non-empty LLM reply (hallucinated dialogue, refusals, commentary,
or the REFLECT critique itself) shipped as the dub line. Autofit's
adjust_for_slot accepted ANY non-empty reply, and its best-candidate tracker
(closest rate_ratio to 1.0) actively selected the most-padded output, while
_EXPAND_PROMPT invited invention with no ceiling. Both call paths also ran
at the provider-default temperature 1.0, unlike the working Fast path which
pins 0.2.
The fix, class-level:
- Shared divergence guard translator.refine_output_ok (length window
0.4–2.5x, env-tunable via OMNIVOICE_REFINE_RATIO_MIN/MAX, with an
absolute cap for short references; target-script check; critique-echo
detection). Rejected ADAPT output degrades to the literal with
error="adapt-diverged" (wrong-script keeps its adapt-wrong-script:<lang>
marker), riding the existing degradation machinery unchanged.
- Autofit validates every reply against the ORIGINAL input text (divergence
compounds across attempts otherwise); rejected candidates are discarded
(attempt burned, graceful degradation to the input preserved) with
error="fit-diverged"; lines under 15% of their slot skip LLM expansion
entirely (fit-skip-short) — they could only "fill" the slot with
fabricated dialogue.
- temperature=0.2 pinned on the cinematic (_chat) and fit (llm.chat) calls;
chat/chat_messages gained an optional temperature param that is only sent
when set, so refinement/director/glossary callers keep provider defaults.
- Prompts hardened: ADAPT forbids introducing facts/names/dialogue not in
the source line; EXPAND forbids inventing information and more than
doubling the line.
- speech_rate strict-mode docstring made honest: strict changes only the
upper tolerance bound; expansion still runs (now guard-bounded).
Fail-before/pass-after regression tests for the reported bugs (10x runaway
ADAPT on an es target, critique echo, hallucinated slot-fill expansion,
refusal replies, tiny-line expansion skip, pinned temperature) plus the
previously-untested wrong-script fallback and legit-output acceptance.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* fix(launcher): replace a stale old-version backend instead of attaching to it
After an update, an orphaned backend from the PREVIOUS version can survive
holding the port. It still answers /system/info, so both attach paths
(lib.rs launch bootstrap + bootstrap.rs retry) treated it as "already
serving OmniVoice — attaching" and the new UI silently ran OLD backend
code: every fix in the update appeared to change nothing. Reported on
Discord as "a bound port which blocked the newer versions"; the app
already knew how to kill_orphan_on_port on both Unix (lsof) and Windows
(netstat) — it just never applied it to a healthy-but-stale backend.
The attach decision now compares versions: running_backend_version()
reads app_version from /system/info (string-sniff, no new deps), and
same_app_version() compares BASE versions (pre-release -N suffix
stripped, so a preview build 0.3.10-4 still attaches to its 0.3.10
backend). Same version → attach exactly as before. Different or missing
version → the orphan is killed and the bundled backend spawns. Foreign
processes keep the existing port_in_use take-ownership path; the
post-spawn health polls are untouched (we spawned that backend
ourselves).
Rust unit tests cover the /system/info parse shape and the
match/preview/stale/unversioned decisions; 51 pass.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs(changelog): add the stale-backend port-reclaim fix under [Unreleased] (#947)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Backend rows store timestamps as Unix SECONDS (time.time() REAL columns:
generation_history, dub_history, exports, longform jobs, projects), while
frontend-local records carry milliseconds (Date.now() story projects) or ISO
strings (transcriptions). Projects/OmniDrive fed the seconds straight into a
millisecond-based diff (fmtTime), so every generation-history card rendered
as ~1970 ("20617d ago") — and, because the same raw value drove the recency
sort, history items also sank to the bottom of the drive.
Fix the class, not the label: a single shared, unit-tolerant normalizer
(frontend/src/utils/relativeTime.js) now backs every relative-time call site.
- toMillis(ts): numbers < 1e12 are seconds (×1000), >= 1e12 already ms; ISO
and numeric strings parse; Date instances pass through; null/0/undefined/
garbage -> null. Backend storage format is untouched (backward compat).
- timeAgo(ts): "—" for missing stamps (never an epoch age), "just now" for
future stamps within 1 min of clock skew, s/m/h/d buckets, short absolute
date beyond 7 days.
- absoluteTime(ts): unit-safe tooltip text, '' when missing (no more
"Jan 1 1970" titles on null rows).
Converted call sites: pages/Projects.jsx (drop local fmtTime + per-source
*1000 juggling; normalize ts once so sort and label agree), components/
Sidebar.jsx + components/WorkspaceProjects.jsx (drop duplicated local
timeAgo copies and caller-side *1000), pages/BatchQueue.jsx (drop local
formatAge; missing created_at used to render an epoch date), pages/
Transcriptions.jsx + components/TranscriptionPicker.jsx (parse via
toMillis, keep their i18n labels; unparseable stamps no longer render
"Invalid Date").
Tests (fail-before/pass-after): utils/relativeTime.test.js covers seconds/
ms/ISO/numeric-string/Date inputs, null/0 -> "—", clock-skew "just now",
and the 1970 regression (a seconds stamp from today must not render as
thousands of days ago); test/ProjectsRelativeTime.test.jsx guards the
OmniDrive wiring end-to-end (seconds created_at renders "2h ago", null
renders "—", mixed-unit sort orders by real recency). Full frontend suite:
106 files / 843 tests green; oxlint, oxfmt, typecheck:ci, node:test green.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* fix(translate): the Dub LLM engine now runs on the configured LLM provider
Picking "LLM (OpenAI-compatible)" in the Dub tab read only the raw
TRANSLATE_* env vars — completely bypassing Settings → LLM Providers, so a
provider the user had configured AND tested in-app silently didn't power
the engine (empty key → raw 401 per segment). The Cinematic refiner was
already rewired through LLM Skills (#910/#912); this closes the gap for
direct LLM translation:
* new "dub_translation" LLM skill (Settings → LLM Skills) — per-skill
provider override → global active provider, same resolution as every
other skill; disabled == unconfigured, no new degradation modes
* the provider=openai branch resolves through resolve_skill_client();
TRANSLATE_BASE_URL/TRANSLATE_API_KEY/TRANSLATE_MODEL stay working as
the power-user override (env-only setups see zero behavior change,
except the stale gpt-3.5-turbo default is now gpt-4o-mini, matching
the cinematic path)
* per-segment calls are now bounded by the LLM timeout (45s default via
OMNIVOICE_LLM_TIMEOUT) instead of the SDK's 600s default
* fully unconfigured → an up-front actionable 400 naming Settings → LLM
Providers / LLM Skills instead of a per-segment 401
* provider-store keys are resolved into the error scrubber so a provider
echoing the key can't leak it (parity with the env-key scrub)
* translation_engines registry: honest notes + a configured/configured_via
stamp on LLM entries so the Engine dropdown can show ready-vs-needs-setup
before the user clicks Translate
Tests: 4 new (skills-resolved client wins with its model+timeout; 400s
name the right settings page for no_provider vs disabled; env fallback
keeps working incl. TRANSLATE_MODEL); skills registry coverage updated;
existing openai-branch tests routed deterministically through the env
branch via the shared fake helper.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs(changelog): add the dub-translation provider wiring under [Unreleased] (#944)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* fix(engines): snapshot lazy registry keys so /engines can't 500 under concurrency
`list_backends()` runs in a FastAPI threadpool and iterates the lazy TTS/ASR
registries via `items()` → `__iter__`, which held a *live* `dict.__iter__(self)`
open across each engine's slow `is_available()` probe. Meanwhile the lazy
`__getitem__` resolves a deferred entry by mutating the dict (`self[key] = cls`).
A second concurrent `/engines` request (or any ASR op) materializing the lazy
`faster-whisper-isolated` entry therefore changed the dict size mid-iteration:
RuntimeError: dictionary changed size during iteration
asr_backend.py:1729 list_backends → _REGISTRY.items()
asr_backend.py:1665 __iter__ → for k in dict.__iter__(self)
Both `_LazyRegistry` (TTS) and `_LazyASRRegistry` (ASR) now snapshot their live
keys up front with `list(dict.__iter__(self))` — consumed atomically under the
GIL — so a concurrent lazy insert can no longer trip the iteration. The slow
per-engine probes then run over the snapshot, not the live iterator.
Deterministic fail-before/pass-after regression for both registries:
tests/backend/services/test_lazy_registry_concurrency.py.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* docs(changelog): add the /engines concurrency fix under [Unreleased] (#940)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
A per-chunk temp-WAV write that fails with OSError EINVAL ("[Errno 22]
Invalid argument") — a missing/read-only/full temp dir, a removed drive,
or antivirus — collapsed into "Transcription produced no segments.
[Errno 22] Invalid argument" with no next step. classify() now names the
class (OS_INVALID_ARGUMENT) so build_failure attaches an actionable
temp-dir/disk/AV hint at the exact surface the streaming dub path already
feeds it (dub_core.py:672) — same treatment the ffmpeg and compute-type
classes get. Fail-before/pass-after regression added; the errno-22 token
keeps it from colliding with the errno-2 transformers-import class.
Also stamps the [0.3.9] CHANGELOG section with today's release date
(2026-07-04) ahead of tagging.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Bullets for #922 (release titles), #923+#924 (sponsors), #925 (contact),
#927 (models), #928 (openapi), #930 (engines) — the agents kept off
CHANGELOG.md during the merge chain. Plus a portable how-we-set-up-
sponsorship playbook for reuse on other projects.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Nine PRs (#904-912) shipped without their changelog bullets (agents were
kept off CHANGELOG.md to avoid merge conflicts across the wave); this
backfills them per the changelog hard rule.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* fix(bootstrap): port cuDNN 8 (NVIDIA CUDA GPU) + VC++ redist install into ensure_venv_ready()
* fix(bootstrap): address #869 review — drop dead VC++ half, cache negative CUDA probe, gate on ROCm, sync docs
Per maintainer review on #869:
1. Drop the VC++ Redistributable half: LoadLibraryA("vcruntime140.dll")
from the running Tauri exe is a tautology (the exe itself links the
MSVC CRT, so the process wouldn't be running without it), and torch's
real failure mode is msvcp140.dll inside the venv python process.
Dead code removed; a comment records why for future readers.
2. Stop taxing every non-CUDA launch: a negative torch probe (CPU /
Intel / AMD — most installs) is now cached in a
.venv/.cudnn8_probe_negative marker, so the synchronous `import
torch` runs at most once per venv lifetime. Invalidated on every
path that can change the torch build (drift sync #307, repair sync,
first-run sync, ROCm reinstall) and implicitly by a venv rebuild.
A probe that fails to run cleanly is skipped WITHOUT caching so a
transient error can't wedge a real CUDA machine.
3. Rewrite docs/install/troubleshooting.md §10 to the actual root
cause: packaged installs never had the cudnn8_compat libs (so
reinstalling never restored them); the bootstrap now installs them
automatically on CUDA machines, with the manual uv pip command as
the offline fallback and PyTorch Whisper as the sidestep.
4. Gate the ~700 MB nvidia-cudnn-cu12 download on the venv torch being
a real CUDA build: the probe now reports 'hip' before checking
cuda.is_available() (which HIP spoofs), so opt-in ROCm installs
(#124) never fetch the CUDA wheel.
Also reflow the CHANGELOG entry to house style (bold one-line lead,
1-3 lines of why, (#827, #869) refs) and extend the bootstrap unit
tests: classify_cuda_probe verdict mapping and the marker
write/invalidate round-trip (6 cuDNN tests total, 43 lib tests green).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* fix(diagnostics): harden the bug-report scrubber against 5 audited leak/correctness gaps
Audit of the (already-on-main) diagnostics/bug-report feature found the opt-in/
no-telemetry contract clean but 5 real gaps in the redaction + URL assembly.
Fixed in both scrub twins (backend/core/scrub.py + frontend utils/bugReport.js):
- Windows home paths with lowercase 'users' now redact (case-insensitive) — a
spec-level PII leak: c:\users\john\… kept the username verbatim.
- Broadened credential shapes (JWT/Bearer, Google AIza, Slack xox, AWS AKIA) +
a URL query-secret pass (?token=/?api_key=… → value redacted, name kept) so a
secret propagated from a backend error into error.message/.stack can't reach a
public issue. The webview has no env backstop, so these shapes are its only
defense.
- Boundary-safe $HOME replace: a home of /Users/john no longer rewrites
/Users/johnny to '~ny' (fragment leak + path mangling).
- Bug-report URL now bounds the URL-ENCODED body length (~7k), not the raw
length — a dense 6k markdown body encoded to ~9k and blew past GitHub's ceiling
(silent truncation / failed open). Message body is capped too.
- 9 new scrub regressions (backend) + 9 (frontend); all green. No API/behavior
change beyond stricter redaction.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(changelog): note the bug-report scrubber hardening in [0.3.8] (#856)
---------
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <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>
* fix(settings): contain + tighten the whole Settings surface (measure cap, container-query stacking, wrap the shared rows)
Two systemic issues drove 'too spread out' + 'elements go out of view' across
many Settings pages:
1. Spread — .settings-content capped at 1280px, so on wide windows every
label-left/control-right row left a huge void. Introduce a --settings-measure
token (720px, macOS-like) + --settings-rail, and cap the content to it,
left-aligned under the nav. One token now controls the reading width.
2. Overflow + bad responsiveness — the row stack break was a *viewport* media
query (560px), but the 168px nav rail means a 760px-viewport window only has
~530px of content, so rows went side-by-side in a cramped box. Make
.settings-content a container (container-type: inline-size) and stack on the
CONTENT width via @container, keeping the viewport @media as a fallback for
the .st-row instances used outside Settings (Splash/FirstRun/Dub/SetupWizard).
3. The shared .perfpanel__row (button/badge row reused by 6+ panels:
RemoteBackend, HFMirror, LLMEndpoint, Pronunciation, MCPBindings, …) was an
inline-flex with no wrap and no max-width, so it ran off the right edge —
add flex-wrap + max-width:100% + min-width:0. Plus two rigid-width fixes that
escaped the row cap: ApiKeys input min-width:220→0, Appearance scale floor.
Frontend builds clean; tokens, @container query, and the wrap all verified in the
emitted CSS bundle.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(settings): center the settings block + tighten measure (kill the lopsided right void)
The capped content was left-aligned, so on a wide window everything jammed to the
left with a dead empty third on the right (screenshot). Center the whole settings
block (nav rail + content) as a unit via max-width + margin-inline:auto, and drop
the measure 720→660 so label→control rows read denser. The cap is computed from
the tokens (rail + gap + measure + page padding) so the content track lands
exactly at --settings-measure.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(device): fall back to CPU when the GPU arch is unsupported, instead of 500-ing every generate (#756)
get_best_device() called check_device_compatibility() and, on an unsupported
compute capability, only LOGGED a warning then still returned 'cuda' — so the
model loaded on a GPU whose kernels can't launch and every generate 500'd with
'CUDA error: no kernel image is available for execution'. Both a too-old card
(Pascal sm_61, GTX 10-series) and a too-new one (Blackwell sm_120 on pre-cu128
wheels) hit this.
Now an unsupported arch falls back to CPU (works, just slower) with a clear
warning; OMNIVOICE_FORCE_CUDA=1 overrides. Belt-and-suspenders: _oom_friendly_reraise
classifies a raw 'no kernel image is available' as an unsupported-GPU error
(switch to CPU / install matching torch) rather than the OOM/Flush message.
Tests: get_best_device → cpu on incompatible, stays cuda on compatible, honors
the force override; reraise gives the actionable GPU message, not OOM.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(device): patch detect_host_caps via string path so the #756 fallback test is full-suite robust
The first version aliased the import + inserted backend on sys.path, which patched
a module copy get_best_device's local 'from core.device_caps import detect_host_caps'
didn't resolve in the full suite (passed alone, failed in CI). Use the string-form
monkeypatch target; verified passing alongside the other device/model tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(changelog): fold #757 device-fallback entry into [0.3.8]; drop the merge's stale [Unreleased] dupe
---------
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Renames [Unreleased] to [0.3.8] — 2026-07-01 and merges the settings-hub
redesign, translation/network/factory-reset panes, the GPU-pool generate-hang
fix (#851), and the translation-banner fix into the release section so
release.yml extracts a complete, house-style body at tag time.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(tts): bound + reset the GPU pool on a hung generate so it can't brick the backend (#730 class)
A GPU job that wedges on some Windows+CUDA setups occupies its worker
forever — run_in_executor can't cancel the thread — so on the 1–2 worker
pools we ship, one stuck job starves every other request and the next
action surfaces as the misleading "Can't reach the local backend" even
though the process is alive.
ASR/dub/model-load already bound+reset the pool on hang (#730). The TTS
**generate** paths (generation.py, tts_stream.py) were the last unguarded
GPU dispatch — and the residual on-main reports (#850#802#755#723#721,
plus the 0.3.7 generate cohort) all fail on generate:start (audio).
- model_manager: add run_on_gpu_pool_guarded() + GpuJobTimeoutError, a
generalized version of the ASR guard so every GPU dispatch shares one
bound+reset recovery path. Env-tunable via OMNIVOICE_GENERATE_TIMEOUT_S
(default 300s).
- generation.py: route both inference branches + the reference-clip
transcribe through the guard; map a timeout to an actionable 503.
- tts_stream.py: same guard on the streaming path (timeout → error frame).
- test_generate_timeout_730: fail-before/pass-after regression (timeout
resets pool + restores capacity, happy path, env override, no-reset exec).
- docs + CHANGELOG: extend troubleshooting §14 to cover generate; document
the new env var.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(tts): extend the GPU-pool hang guard to batch/dub/archetype/openai-compat generate (#730 class)
The generate-hang class wasn't only in Studio + streaming: batch generate,
the dub per-segment + preview generate, archetype preview render, and the
OpenAI-compat /v1/audio/speech path all dispatched the TTS model to the GPU
pool with no wall-clock bound either. Any one of them wedging on a
Windows+CUDA hang starves the pool and bricks the backend the same way.
Route all of them through run_on_gpu_pool_guarded so the whole class is
closed — a hung generate anywhere resets the pool and returns an actionable
timeout instead of a dead backend. Batch/dub recover per-segment on a fresh
worker; drop the now-dead loop/_gpu_pool/asyncio locals ruff flagged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
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>
* wip(settings): partial sidebar-hub redesign (recovered from killed agent)
Shell (sidebar/search/categories/restart-badge) + new panes (Network/Translation/Storage/PerformanceDevice) + partial panel rewiring. Not yet verified.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(settings): finish + verify sidebar-hub Settings rebuild; changelog
Completes the partial sidebar-nav Settings redesign: confirmed all 16
categories are wired in Settings.jsx's renderCategory and render their real
panels with every store/pref/API binding preserved (theme→Appearance,
review-mode→General, proxy/ffmpeg→Network, provider keys→Translation — all
relocated, none dropped or duplicated). Verified search filtering, restart
badges, factory-reset dialog, narrow-width dropdown, and i18n key coverage.
Gates: vite build, oxlint (0), oxfmt --check, vitest (641 pass),
bun install --frozen-lockfile — all green. Adds the user-facing CHANGELOG
[Unreleased] entry required by the changelog hard rule.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* test(visual): refresh GeneralTab/AppearancePanel/StoragePanel baselines for the Settings redesign
The sidebar-hub rebuild changed three snapshotted panels: GeneralTab (lost
proxy/ffmpeg + theme, gained review mode), AppearancePanel (gained the
header-live-stats toggle), and StoragePanel (gained a RestartBadge header). The
recovery commit shipped stale baselines; regenerate all three across the default/
midnight/catppuccin themes so `bun run test:visual` is green against the new UI.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* i18n: backfill all 20 locales for the Settings redesign (and pre-existing drift)
The Settings rebuild added ~42 new keys to en.json; ran scripts/translate_all.py
to translate them into all 20 non-English locales (masking {{vars}}/<n> tags),
which also caught up pre-existing key drift — every locale is now at full parity
with en.json (0 missing keys). Satisfies the all-21-locales hard rule.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(settings): contain + tighten the whole Settings surface (measure cap, container-query stacking, wrap the shared rows)
Two systemic issues drove 'too spread out' + 'elements go out of view' across
many Settings pages:
1. Spread — .settings-content capped at 1280px, so on wide windows every
label-left/control-right row left a huge void. Introduce a --settings-measure
token (720px, macOS-like) + --settings-rail, and cap the content to it,
left-aligned under the nav. One token now controls the reading width.
2. Overflow + bad responsiveness — the row stack break was a *viewport* media
query (560px), but the 168px nav rail means a 760px-viewport window only has
~530px of content, so rows went side-by-side in a cramped box. Make
.settings-content a container (container-type: inline-size) and stack on the
CONTENT width via @container, keeping the viewport @media as a fallback for
the .st-row instances used outside Settings (Splash/FirstRun/Dub/SetupWizard).
3. The shared .perfpanel__row (button/badge row reused by 6+ panels:
RemoteBackend, HFMirror, LLMEndpoint, Pronunciation, MCPBindings, …) was an
inline-flex with no wrap and no max-width, so it ran off the right edge —
add flex-wrap + max-width:100% + min-width:0. Plus two rigid-width fixes that
escaped the row cap: ApiKeys input min-width:220→0, Appearance scale floor.
Frontend builds clean; tokens, @container query, and the wrap all verified in the
emitted CSS bundle.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(settings): center the settings block + tighten measure (kill the lopsided right void)
The capped content was left-aligned, so on a wide window everything jammed to the
left with a dead empty third on the right (screenshot). Center the whole settings
block (nav rail + content) as a unit via max-width + margin-inline:auto, and drop
the measure 720→660 so label→control rows read denser. The cap is computed from
the tokens (rail + gap + measure + page padding) so the content track lands
exactly at --settings-measure.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Set the release date to 2026-06-29, extend the #730 entry to note the chunked
dub-stream path is bounded + pool-reset too (#742), and add the bun desktop
dev-launch fix (#745) under CI. release.yml extracts this section verbatim as
the GitHub Release body, so it's now tag-ready.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
snapshot_download's resume trusts an existing file by size, so a present-but-
corrupt blob is never re-fetched: the resume-repair 'succeeds' yet the reload
still raises the truncated-cache OSError, and the user was sent to a manual
delete-and-reinstall. Add a force=True path (force_download) and wire it as a
last resort — on the post-resume reload failure, force a full re-download once
(replacing corrupt blobs) and retry the load before falling back to the
actionable message. Force is reached only after a plain resume-repair didn't
fix it, so the common missing-file case still avoids re-downloading everything.
Tests: corrupt cache force-repairs on the 2nd failure (resume then force),
force_download is set only when force=True, and an unfixable cache still
surfaces the 'could not be auto-repaired' message.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
_repair_model_cache attempted snapshot_download exactly once; a single transient
failure (the very cause of an interrupted download) returned False and sent the
user back to a manual delete-and-reinstall. Wrap the re-fetch in a bounded retry
loop (3 attempts default, linear backoff) — snapshot_download resumes between
attempts so retries are cheap and idempotent. Counts/backoff are env-tunable
(OMNIVOICE_MODEL_REPAIR_RETRIES / _BACKOFF_S) for restricted networks and set to
zero-backoff in tests. Offline mode + the actionable fallback message are
unchanged.
Tests: retry-then-succeed self-heals, exhausted-retries returns False after N
attempts, single-attempt tunable, backoff disabled so the suite stays fast.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A whisperx/CTranslate2 transcribe can hang hard on some Windows+CUDA setups and
never return. ASR shares the small (1-2 worker) _gpu_pool with TTS, so one stuck
worker starved every other request — the next TTS generate then surfaced as
"Can't reach the local backend" though the process was alive (#720/#721/#723).
Two parts:
- Bound the three remaining unguarded whole-file transcribe paths (dub
whole-file dub_core.py, batch.py, live-dictation capture_ws.py) with
run_transcribe_guarded, matching the dub-QC/dictation/OpenAI paths that were
already bounded by #656.
- On timeout, run_transcribe_guarded now calls executor.reset() when the pool
supports it (_ResilientGpuPool, already built for the model-load-timeout case
in #589/#599): the wedged worker is abandoned and the next submit gets a fresh
one, restoring capacity without an app restart. Best-effort — a plain
ThreadPoolExecutor (tests) just gets the bound + actionable error.
Regression tests: pool.reset() is invoked on timeout; a non-reset pool still
bounds cleanly.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A BrokenPipeError surfacing from generation means the backend's stdout/stderr
pipe to the desktop shell that launched it closed mid-render (an orphaned or
relaunched backend) — not out of memory. _oom_friendly_reraise mislabeled it
"ran out of memory — try Flush," which never helps. Add a BrokenPipeError /
[Errno 32] branch (same pattern as the #705 WinError-193 and #437 permission
branches) that tells the user to restart the app instead. main.py already wraps
sys.stdout/stderr to swallow EPIPE; this catches the C-level writes inside the
native engine/torch that escape that guard.
Regression test covers both the typed BrokenPipeError and a string-wrapped
"[Errno 32] Broken pipe".
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Dubbing a video URL on Windows (v0.3.8) failed with 'You have requested merging
of multiple formats but ffmpeg is not installed.' The download format selector
pulls separate video+audio streams, so yt-dlp muxes them via ffmpeg
(merge_output_format=mp4) — but yt-dlp only checks PATH, while OmniVoice's ffmpeg
is typically a bundled Tauri sidecar / imageio-ffmpeg binary that isn't on PATH.
yt_download_sync now sets ydl_opts['ffmpeg_location'] = find_ffmpeg() (the same
resolver the rest of the dub pipeline uses) when ffmpeg is resolvable; if it
isn't, the key is omitted so yt-dlp falls back to PATH as before (no regression).
Tests assert the location is passed when resolved and omitted when not.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A synth that already produced and saved its audio could still return a 500:
'no such table: generation_history' — a DB that somehow missed schema init
(init_db's executescript never took) made the history INSERT raise after the
clip was done, losing the user's generation to a logging side-effect.
- Add db.ensure_schema(): idempotent CREATE ... IF NOT EXISTS + additive column
reconcile (no _migrate/alembic), safe to call from a write path.
- Generation history write now self-heals: on a sqlite OperationalError it runs
ensure_schema() and retries once; if it still fails it logs and returns the
audio anyway. A history-logging failure can never fail the generation.
Regression test: the write raises 'no such table: generation_history' before the
heal and succeeds after (fail-before/pass-after), plus ensure_schema idempotency.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>