Commit Graph
219 Commits
Author SHA1 Message Date
d57ecea804 fix(test): DB migration-safety tests leak-proof against full-suite order (#909 follow-up) (#917)
The PR #909 data-safe-update tests passed in isolation but failed only in
full-suite CI order. Two independent, order-dependent leaks were at play:

1. Module-identity leak (the #878/#894 class). The `isolated_db`/`fresh_app`/
   `fresh_resolver` fixtures in tests/backend/** purge `core.*`/`services.*`
   from `sys.modules` and never restore them, so `sys.modules["core.db"]`
   afterward is a DIFFERENT object than the one the migration-safety tests
   imported at collection. `monkeypatch.setattr("core.db.DB_PATH", ...)`
   re-resolved the dotted string to the re-imported module, while
   `_run_alembic_upgrade`/`init_db` (bound at collection) kept reading the
   ORIGINAL module's globals — so the patch missed and the upgrade ran against
   the ambient session DB. Result: no backup at the asserted path, and the
   mid-flight-failure injection never hit the expected DB (DID NOT RAISE).
   The same divergence hit the lazy `from core import db_backup` inside
   `_run_alembic_upgrade`, so patching `MAX_BACKUP_DB_BYTES` was silently lost.

2. Logger-disable leak. Alembic's env.py called `fileConfig(...)` with the
   default `disable_existing_loggers=True`, which disabled the already-created
   `omnivoice.db.backup` logger the first time any earlier test ran a real
   `alembic upgrade` — so the oversized-DB "Skipping pre-migration DB backup"
   line was never emitted and the caplog assertion failed. This also silently
   mutes the live app's logging after a real startup migration.

Fixes:
- env.py: `fileConfig(..., disable_existing_loggers=False)` so a migration
  never mutes the app's (or another test's) loggers.
- core/db.py: import `db_backup`/`APP_VERSION` at module level so
  `_run_alembic_upgrade` uses a stable reference immune to a `sys.modules`
  purge, matching what tests patch at collection.
- test_db_migration_safety.py: patch DB_PATH on the imported `core.db` module
  object rather than the re-resolvable dotted string — the correct,
  self-contained seam.

Verified: the four migration-safety tests + the oversized-backup test pass in
full-suite order and in isolation; full `pytest tests/` is green
(2206 passed, 0 failed).

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 01:13:12 +05:30
8f71c90f20 feat(settings): LLM Skills — per-feature enable/route control for every LLM call (#912)
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>
2026-07-03 00:08:30 +05:30
af6690840e fix(translate): run Cinematic/Autofit on every engine (incl. default Argos), bound the fit pass, scrub provider errors (#910)
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>
2026-07-02 23:53:11 +05:30
75864a597f fix(dictation): refinement never stalls a final (~51s→≤4s), REST polish parity, real ASR preload reuse (#911)
P0 — Refinement blocked every dictation final with no timeout. With refinement
auto:true and a slow/dead LLM endpoint, maybe_refine ran unbounded and blocked
the final send in all three capture_ws handlers (~51s measured; the pill hung
"Transcribing…" until the widget's 15s fallback fired). Fix the class: a hard,
env-tunable budget (OMNIVOICE_REFINE_TIMEOUT_S, default 4s) via a new
maybe_refine_async — a slow/dead endpoint now falls back to the unrefined (but
polished) text within the budget and can NEVER delay the final beyond it. The
LLM HTTP call is bounded to the same budget so the orphaned worker unwinds
instead of holding a connection for the client's full 45s. Refinement is now
also fully best-effort in the legacy handler (it can't turn a good final into
an error frame).

P1 — REST /transcribe lacked polish parity. capture.py never applied
polish_text, so REST returned raw "…test" while the WS returned "…test."
Apply text_polish.polish_text to `text` and `refined_text` (segments stay raw),
so the widget POST fallback and MCP/CLI callers match the live socket.

P1 — The #888 "instant first dictation" preload was a no-op. The preload called
warmup() only `if hasattr`, but SherpaDictationBackend had none, and the WS
handlers built a FRESH backend per session so a warm singleton wasn't reused.
Add SherpaDictationBackend.warmup() (builds the recognizer) and share one warm
recognizer per model id across sessions (get_sherpa_dictation_backend, same
invalidation + a shared lock as the capture singleton); each session keeps its
own decode stream. First dictation no longer pays the 1.3–2.5s load.

P1 — llm_ready is a lie (feeds the P0). It only means "an endpoint is
configured", so a placeholder key reads as ready. The P0 timeout makes a dead
endpoint harmless; add last_refine_status so RefinementPanel flags a
configured-but-failing LLM and links to LLM Providers → Test.

Regression tests (fail-before/pass-after): slow-LLM WS final arrives < budget;
maybe_refine_async hard timeout + status; REST polish parity + refined_text
polish; warmup builds the recognizer and a second session reuses it; the panel
honesty note. Backend refinement/capture_ws/capture/sherpa suites, CJK + route
inventory gates, full vitest (733), lint (0 errors) and format all green.

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 23:52:45 +05:30
16294fed44 feat(updates): data-safe updates — pre-migration DB backups, guarded venv heal, release notes + changelog reader (#909)
Backend:
- core/db_backup.py: WAL-safe SQLite snapshot to omnivoice.db.backup-<version>-<n>
  before pending alembic migrations run; keep newest 3, prune older; skip >500MB
  with a log line. Restore is never automatic.
- core/db.py: _run_alembic_upgrade now plans the run (up_to_date / pending /
  unknown_revision), snapshots first when migrations will execute, and raises
  MigrationError on a mid-flight failure — startup stops with the backup path
  named instead of continuing on a half-migrated DB. The #552/#547
  unknown-revision class stays non-fatal (warn + additive reconcile).
- core/changelog.py + GET /api/settings/changelog: parse the shipped
  CHANGELOG.md (single-line and wrapped bullet styles) into structured releases.
- GET /api/settings/db-backup: newest pre-migration backup for the panel.

Rust (bootstrap.rs):
- #314 heal guard: an exit-signature match alone can no longer delete the venv —
  venv_rebuild_justified requires a structural problem or a failed direct
  interpreter probe; a venv that probes healthy is kept and the real error
  surfaced. Drift/repair remains in-place `uv sync` (non-destructive).
- CHANGELOG.md now ships as a bundle resource and is copied/refreshed into the
  project dir so the changelog endpoint works in packaged installs.

Frontend (Settings → Updates):
- Available update shows its actual release notes (updater metadata body)
  through a safe markdown-lite renderer (text nodes only, refs stay plain).
- "Your data is backed up before every update" line with the latest backup
  timestamp from the new endpoint.
- "What's new" changelog reader (accordion, newest expanded) over the shipped
  CHANGELOG.md; GitHub releases list reuses the same renderer.
- One-time, non-blocking "What's new" footer pill after an update
  (persisted last-seen version; fresh installs baseline silently).
- All strings via t() with en keys (other locales fall back to English).

Tests: db backup/rotation/failure-path units, migration-safety units, changelog
parser (both bullet styles + real CHANGELOG.md), endpoint tests, route
inventory regenerated, Rust decision-logic + probe tests, vitest suites for
renderer/viewer/panel/pill logic.

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 23:52:17 +05:30
e2c4ea93b0 fix(models-settings): surface async install errors, disk-space guard, cancel wiring, honest restart (#908)
Live-audit fixes for the Models settings surface — the P1s were cases where
the feature silently didn't work for the user.

P1-A — Async install errors were invisible. The `install_error` SSE event
carries excellent mirror-aware text (#890 core/failure.py), but the Model
Store auto-purged the errored row ~800ms later (same as a success) and the
first-run WizardLibrary DELETED the row without ever reading `ev.error`. The
SSE→rowState reduction is now a pure, tested reducer (downloadReducer.js /
reduceWizardDownloadEvent); only SUCCESS terminals auto-purge
(isAutoPurgeTerminal), an error persists on the row with inline text + Retry +
Dismiss (Model Store) / a Retry (wizard).

P1-B — No disk-space check on install. `POST /models/install` now compares the
FDL-05 plan's exact `to_download_bytes` (+ MIN_FREE_GB headroom) against
`shutil.disk_usage(cache).free` BEFORE downloading and emits an actionable
install_error naming the sizes (needs X, headroom Y, have Z) instead of failing
mid-download. `/models` also surfaces `disk_free_gb` in the header. MIN_FREE_GB
+ disk_free_bytes are single-sourced in setup/models.py (wizard delegates).

P2-A — Wired the orphaned cancel. `POST /models/install/cancel` (FDL-11) had
zero frontend refs; the in-progress row now shows a Cancel button that calls it
and transitions the row to install_cancelled.

P2-B — Honest restart_required. The HF-mirror PUT returned restart_required:true
unconditionally; it now returns true only when the persisted value actually
changed, with accurate copy (Model Store downloads use the new mirror
immediately — resolved per-call; only transformers model loads need a restart).

P3 — i18n the un-localized panels (HFMirrorPanel, ApiKeysPanel source
labels/help/status, MODEL_ROLE_LABEL) via new en.json keys; other locales fall
back to en.

Tests: new tests/test_install_disk_space.py (reject-when-over-budget incl. the
worker wiring; allow-when-fits; degrade on unknown size/unprobeable volume),
updated tests/test_hf_mirror_settings.py (change-only restart_required), and new
frontend reducer + column-render tests for install_error persistence, Retry,
Dismiss, and Cancel.

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 23:40:15 +05:30
b3c18db33f feat(settings): Storage panel — real disk usage, category breakdown, and low-space warnings (#906)
Settings → Storage now opens with a Disk usage panel backed by a new
loopback-gated GET /api/settings/storage endpoint:

- Per-volume totals (grouped by st_dev) + du-style sizes for everything
  the app owns: the HF model cache (with its ~10 largest models), the
  app data dir broken into voices/outputs/dub_jobs/batch/preview/
  database/logs/other subtotals, engine venvs (backend/engines/*/.venv
  + the app venv), and omnivoice* entries in the OS temp dir.
- Bounded scanning: per-category 10 s deadline → partial totals with an
  "unreadable" warning instead of a hung request; results cached
  in-process for 5 minutes, ?refresh=1 forces a rescan; the walk runs
  in a worker thread so the event loop never blocks.
- Server-side warnings reuse the setup wizard's MIN_FREE_GB: free <
  min → critical, free < 2×min → low, volume holding the cache/data
  >90% full → volume_pressure, unreadable/timed-out paths → unreadable.

The panel renders severity-colored banners, a data-volume gauge,
proportion bars per category, Open-folder buttons (existing
/export/reveal pattern), a Model Store jump for reclaiming model
space, and the existing clear-logs action on the logs row. A critical
warning is also surfaced outside Settings via the app-wide toast —
once per session. All strings via i18n (en fallback).

Tests: tests/test_storage_report.py (sizes, thresholds, cache/refresh,
timeout partials, endpoint wiring) + StorageUsagePanel.test.jsx
(categories, banners, once-per-session toast, refresh=1, error state);
route added to tests/fixtures/api_routes.txt via the dump script.

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 23:39:49 +05:30
e7fc37d438 fix(settings): retire legacy LLM endpoint panel, surface env overrides, fix Cloudflare account + fast-fail probes (#907)
Live-audit fixes for Settings → LLM Providers / Translation.

Retire the legacy LLMEndpointPanel from the UI (backend endpoint kept).
TranslationTab no longer embeds the inline endpoint panel — it now points to
Settings → LLM Providers (openSettingsTab('llm-providers')), which fully covers
it via the `custom` provider (a lone TRANSLATE_BASE_URL still resolves to
`custom`). Kills the panel's lying "reachable" badge, its hardcoded-English
strings, and one of three duplicate TRANSLATE_* surfaces. The third duplicate —
TranslationTab's "Provider keys" collapsible — drops the TRANSLATE_* trio
(now owned by LLM Providers) and keeps only the DeepL/Microsoft translator
keys; its toast no longer claims "saved for session" (these are in
PERSISTENT_KEYS, restored at startup). GET/PUT /api/settings/llm-endpoint is
untouched (DubTab gates Cinematic off it; tests + route inventory cover it).

Surface env overrides. describe() now reports base_url_from_env / model_from_env
/ active_from_env (mirroring key_from_env). The panel disables env-pinned
base_url/model/account fields with an explainer, and — when
LLM_DEFAULT_PROVIDER pins the active provider — disables make-active and shows a
banner, instead of silently reverting the user's edit / no-oping the button.

Fix the Cloudflare account-id flow (broken two ways): describe() now returns the
stored account_id (the field no longer resets to empty) and shows the RAW
base_url template ({account_id} kept literal) instead of the substituted value;
save_overrides drops a base_url override equal to the built-in default, so the
UI posting the shown value back can't freeze the URL — later account-id changes
take effect again (also self-heals if a default URL changes in a release).

Fast-fail the Test / Fetch-models probes. Pass max_retries=0 to the probe
OpenAI clients so a 429/timeout returns in seconds instead of ~34s on the SDK's
default retry ladder. /models now returns truncated:true when capped at 200 and
the UI hint reads "first 200 shown".

Tests: registry env-flag + Cloudflare round-trip/no-freeze regressions; router
truncation + max_retries=0 assertions; panel disabled+explained + banner;
new TranslationTab test (pointer wired, legacy panel gone, TRANSLATE_* dropped).

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 23:36:50 +05:30
b825d99337 fix(engines): revive dead license dialog, refresh matrix on select, surface routing verdict (#905)
Six live-audit fixes for the Engines settings surface:

- P1-A: the Supertonic license dialog was dead since #101 — `useState`
  threw away the state value (`const [, setLicenseDialogFor]`) and the
  imported dialog was never mounted, so "Accept license" did nothing.
  Keep the value and render LICENSE_DIALOGS[selected] with open/onClose/
  onAccepted (accept → matrix reload).
- P1-B: the matrix went stale after "Use" — active badge, Use buttons and
  family-tab captions stayed old until a manual Refresh. Await onSelect,
  then reload() so the picked engine reflects immediately.
- P2-A: consume the /engines/select routing echo. A `cpu_fallback` pick now
  shows a warn-tone toast naming the reason ("running on CPU — …"); the
  plain success toast stays for accelerated/cpu_only. Shared helper used by
  both Settings→Engines and the first-run WizardLibrary.
- P2-B: a CPU-native engine (gpu_compat == ("cpu",)) has nothing to fall
  back FROM, yet on a GPU/MPS host it was mis-classed cpu_fallback (warn).
  New routing rule classifies ("cpu",) as cpu_only (neutral) on any
  accelerator host; multi-target engines that could accelerate elsewhere
  are untouched.
- P3-A: the routing reason was only a badge `title` (unreachable on
  keyboard/touch) — surface it as small visible text under the badge.
- P3-B: an in-process "Test engine" pass is an import/liveness check, not a
  synthesis test — label it "deps OK" instead of a misleading "0 ms"
  latency; subprocess rows keep their real ping latency.

Adds RTL + unit regression tests for all six and updates the routing unit
tests to the corrected cpu-native intent. i18n keys added to en.json.

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 23:22:49 +05:30
958a79ef7f fix(generate): device-aware timeout guidance — stop telling CPU hosts to switch to CPU (#896) (#902)
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 22:26:24 +05:30
83e71c5689 fix(asr): close the #730 residuals — chunked dub wedge shares the guarded reset; repeated timeouts recommend the crash-isolated engine (#895)
Residual A — the chunked dub-stream had a PARALLEL wedge mechanism (its own
ping-loop timeout, its own _reset_pool_on_wedge, a dead-end "Try restarting
the server" message). A wedged chunk now routes through the SAME
run_transcribe_guarded bound+reset as the whole-file paths (#731/#851): the
guard resets the poisoned pool once per wedged attempt (no double-reset on
retry) and the user sees the actionable ASRTimeoutError. The reset logic is
extracted to asr_backend.reset_pool_after_wedge — one shared mechanism, so
the semantics can't drift again. run_transcribe_guarded also gains a
timeout_env param so chunk errors name OMNIVOICE_TRANSCRIBE_CHUNK_TIMEOUT_S
instead of the whole-file knob.

Residual B — the crash-isolated ASR sidecar (#393, faster-whisper-isolated)
is wired as an explicit ESCAPE HATCH, not a default:
- selectable end-to-end: Settings engine list gets an explanatory
  install_hint; honest gpu_compat ("cuda","cpu" — it wraps the same
  CTranslate2 engine as faster-whisper); get_active_asr_backend now hands
  back a process-wide singleton for subprocess-isolated backends (a fresh
  instance per request would leak atexit hooks and respawn the sidecar —
  reloading its model — on every transcribe).
- on the SECOND consecutive guarded timeout-with-reset in one session
  (resets aren't recovering the hang; the wedged thread keeps its VRAM),
  the error the user sees + the log recommend switching to the isolated
  engine in Settings → Engines. Never auto-switched (owner rule: no silent
  behavior divergence); a completed transcribe resets the streak.

Tests (fail-before/pass-after verified against origin/main): wedged-chunk
SSE integration (reset count + actionable error + recommendation surfaces),
consecutive-timeout streak (fires at 2, resets on success, suppressed when
already on the isolated engine), timeout_env parametrization, shared-reset
helper, isolated backend in list_backends with hint + honest availability,
singleton caching, gpu_compat matrix entry. Docs: troubleshooting §14 gains
the chunk knob + escape-hatch guidance.

Closes the residuals tracked on #730.

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 19:34:28 +05:30
3bb401f4e5 test: make LLM-provider state leaks between tests impossible (#878) (#894)
Root cause: LLM provider selection reads three process-global surfaces —
env vars (LLM_DEFAULT_PROVIDER, per-provider *_API_KEY/*_BASE_URL,
TRANSLATE_*), the SQLite settings store (llm.active_provider & co.), and
prefs.json (llm_backend). Importing `main` (TestClient fixtures do)
dotenv-loads the developer's .env and ~/.config/omnivoice/env straight
into os.environ, and several tests/endpoints mutate these surfaces
without teardown — so whichever test imported the app first flipped what
later tests' active_backend_id()/active_provider_id() resolved to
(order-dependent failures in test_engines.py,
test_llm_endpoint_settings.py, test_llm_providers.py).

Fix the class, not the instances:
- tests/conftest.py: redirect OMNIVOICE_DATA_DIR to a per-session tmp dir
  and OMNIVOICE_ENV_FILE into it (before collection freezes
  core.config.DATA_DIR), so tests never read or write the developer's
  real app state and local runs behave like clean CI.
- tests/conftest.py: autouse `_isolate_llm_provider_state` fixture
  snapshots env (derived from llm_providers._PROVIDERS, so new providers
  are guarded automatically), llm.* / secret.llm_key.* settings rows, and
  the prefs llm_backend/env.TRANSLATE* keys before every test and
  restores them exactly afterwards.
- shared `clean_llm_env` fixture clears the FULL provider env surface;
  the four LLM test modules' hand-picked partial delenv lists (which left
  e.g. LLM_DEFAULT_PROVIDER / OPENROUTER_API_KEY standing) now use it.
- tests/test_llm_state_isolation.py: deterministic fail-before/pass-after
  regression pair — pollutes all three surfaces without cleanup, then
  asserts the guard restored them.

Verified: the issue's two-test repro passes; the five LLM-related test
files pass in order; full suite green (2046 passed, 20 skipped,
10 xfailed, 4 xpassed).

Fixes #878

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 19:06:14 +05:30
6e600c48cb fix(generation): classify network/download failures — stop mislabeling every unknown error as OOM (#880) (#893)
A kittentts first-use HuggingFace download died with httpx's "Cannot send
a request, as the client has been closed", and the generation error
classifier's catch-all fallback told the user (CPU-only ~80 MB ONNX engine,
12 GB-VRAM box) they were OUT OF MEMORY and to press Flush — the wrong
remedy for a network failure.

Three-part class fix:

- generation.py: new #880 branch (before the OOM hint) classifies
  httpx/requests transport failures — matched over the whole exception
  chain (type names like ConnectError/ReadTimeout plus stringified
  signatures like "client has been closed") — as a download/network
  problem with a retry/check-connection remedy.
- generation.py (the real class bug): the OOM hint is no longer the
  catch-all. It now requires an actual OOM signature (typed
  OutOfMemoryError/MemoryError anywhere in the chain, or CUDA/MPS/CPU
  allocator wording); genuinely unknown errors surface as unrecognized
  with the underlying detail instead of a false "ran out of memory".
- tts_backend.py: KittenTTS's first-use load retries exactly once with a
  fresh HF Hub client (huggingface_hub.utils.close_session()) on the
  specific closed-client failure — hub ≥1.x shares one global httpx
  client, and a closed one is recoverable, so the download self-heals
  instead of failing the generation.

Fail-before/pass-after tests: classifier (closed-client message, wrapped
httpx type names, unknown error, real OOM signatures incl. typed
OutOfMemoryError, WinError 1455) + the retry helper (recovers once,
walks the chain, no retry on unrelated errors, single-shot).

Fixes #880

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 18:38:02 +05:30
14f1257d1f fix(errors): name the configured HF mirror when a model download fails (#874) (#890)
When a non-default HF_ENDPOINT (Settings → Models → Hugging Face mirror,
e.g. hf-mirror.com) is configured and a model load/download fails with a
connectivity error, the raw transformers message ("We couldn't connect to
'https://hf-mirror.com' to load the files…") leaked to the UI as a bare 500
with no next step.

Class fix — one shared classifier in core/failure.py covers every surface:

- classify()/build_failure(): new HF_MIRROR_UNREACHABLE class with a dynamic
  hint that names the configured mirror, says it may be down, points at
  Settings → Models → Hugging Face mirror, suggests the official endpoint
  when the model isn't cached, and notes the restart requirement (HF reads
  HF_ENDPOINT at backend start). Checked before the video-download network
  class so a model download's "timed out" no longer gets the "video server"
  hint. Feeds /model/status and every build_failure event (dub, tasks).
- main.py global 500 handler: appends the hint to the surfaced detail, so
  ALL routes that can leak a model-load error benefit (generate, dub,
  archetypes, …), not just TTS generate.
- setup/download.py install SSE: the install_error event gets the same hint.
- error_journal: "couldn't connect to" / "max retries exceeded" now classify
  as NETWORK_ERROR (was UNKNOWN) for auto-attached bug reports.
- model_manager (#886 family): the "cache incomplete and could not be
  auto-repaired" message now names WHY the auto-repair failed (mirror
  outage, offline mode, full disk no longer read identically), which also
  lets the mirror hint fire on that surface when applicable.

Fail-before/pass-after regression tests in tests/test_hf_mirror_error_class.py
(12 of 13 fail on main).

Fixes #874

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 18:23:47 +05:30
d58010fe1b feat(dictation): rebuild to Wispr-Flow quality — live waveform, streaming commits, honest insertion, polished text (#888)
* feat(dictation): rebuild to instant-feedback quality — waveform, streaming commits, honest insertion, text polish

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(changelog): dictation rebuild entry

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(lint): Array.from over new Array(n) — oxlint no-array-constructor

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 17:57:56 +05:30
da9315815d feat(settings): LLM provider testing pass — latency + classified errors, model discovery, full i18n, router tests (#887)
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 17:03:13 +05:30
bb492086c9 fix(desktop): enforce maximize() at startup — macOS can ignore the conf flag with Overlay title bar (#881 follow-up) (#884)
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 15:16:34 +05:30
62035435e8 fix(desktop): always open maximized (not fullscreen) — stop window-state restoring stale geometry (#881)
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 14:03:23 +05:30
4eed552153 fix(engines): Confucius4-TTS validated E2E — clone sys.path import, 22.05 kHz, real install docs (#590) (#872)
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 04:49:50 +05:30
ae516cae63 fix(asr): un-gate Parakeet TDT from CUDA-only — measured ~10× realtime on CPU (#871)
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 04:49:42 +05:30
85fd9ca799 fix(asr): VRAM preflight before whisperx load — no more native OOM abort on 8 GB cards (#723) (#870)
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 04:48:51 +05:30
Palash Debnathandmergetest 86c701bff9 fix(translate): bound the whole cinematic/autofit pass so a slow LLM can't hang "Translating…" (#868)
The per-segment call already has a 45s timeout and concurrency is capped, but a
slow or rate-limited provider on a large dub (hundreds of segments) can still keep
the "Translating…" spinner spinning for minutes as segments queue through the
bounded pool. There was no ceiling on the *whole* pass.

Add an overall wall-clock budget, OMNIVOICE_CINEMATIC_BUDGET_S (default 180s,
<=0 disables). Segments that finish in time keep their cinematic refine; any still
in-flight when the budget hits is cancelled and degrades to its literal (Fast)
translation with error="cinematic-budget", so the translate ALWAYS returns instead
of hanging. Order and length of the result are preserved. Abandoned executor
threads follow the same fire-and-forget pattern as the GPU-pool wedge guard (#730).

Regression tests: a 3s-per-segment refine under a 0.3s budget returns in <2s with
literal fallbacks; budget<=0 runs every segment to completion.

Co-authored-by: mergetest <test@local>
2026-07-02 00:39:36 +05:30
3d0705fdb7 feat(engines): Confucius4-TTS — finalized (API-validated + unit-tested; opt-in, GPU run pending) (#590) (#637)
* feat(engines): Confucius4-TTS scaffold (opt-in, needs hardware validation) (#590)

Plumbing for netease-youdao's Confucius4-TTS — LLM-based 14-language
cross-lingual zero-shot voice cloning, Apache-2.0 — mirroring the opt-in
subprocess-venv pattern of dots.tts / MOSS-TTS-v1.5:

- engines/confucius4/__init__.py: Confucius4Backend(SubprocessBackend), CUDA-only
  (gpu_compat=("cuda",)), language passthrough, ref_audio→prompt_wav. is_available
  reports a clear reason and stays unavailable without a clone.
- bootstrap.py: dedicated Python 3.10 venv resolution (user clone-level venv →
  package venv → uv bootstrap), import-probed on `confuciustts`.
- main.py: sidecar speaking the same length-prefixed JSON-over-stdio protocol as
  the other engines, calling ConfuciusTTS(config_path, device).generate(text,
  lang, prompt_wav).
- Registered lazily in _LAZY_REGISTRY; docs/engines/confucius4-tts.md.

Gated behind OMNIVOICE_CONFUCIUS4_TTS_DIR — inert on every default install, never
imports the upstream package unless opted in. The sidecar's synthesis API is
derived from the upstream README and is NOT yet validated on a CUDA box; the
module, docs, and CHANGELOG all flag this. 4 tests pin registration +
inert-by-default. No version bump.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(#590): register Confucius4 in install-hints + docs inventory (CI gates)

Registering the engine tripped two completeness gates: every backend needs an
install_hint (test_issue_fixes) and every registry engine must appear in the
tts_engines docs inventory + README (check-docs-drift). Add the install_hint,
the docs/features.yaml entry, and the README engine-table row (with the scaffold
caveat). Docs-drift clean; gates pass. No version bump.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(confucius4): finalize — validate API vs upstream, add 22 sidecar unit tests, document external deps (Amphion/w2v-bert/weights)

The synthesis API (ConfuciusTTS(config_path, device) → generate(text, lang,
prompt_wav) → tensor, model.sample_rate) is confirmed against the
netease-youdao/Confucius4-TTS repo. Added runnable unit tests for the sidecar's
pure logic (language norm, tensor→PCM mono/stereo/clip, config resolution, wire
framing, synthesize dispatch with the model mocked) — 22 cases, all green.
Docs now list the external deps (Amphion/MaskGCT codec, facebook/w2v-bert-2.0,
~2-4GB HF checkpoint) and CUDA 12.6. Softened the scaffold warnings to reflect
API-validated + unit-tested status; a one-time CUDA GPU run is still needed to
confirm live inference + true sample rate.

---------

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 23:37:40 +05:30
8f7b242610 fix(theme): remove stray token-border frames + make accent family theme-track (#864)
Task 1 — physically remove the token-based structural border utilities that
kept rendering stray frames (history panels, cards, rows, settings) whenever a
`--*-border` token didn't resolve transparent (theme re-declare, or bare
`border` = currentColor under Tailwind v4). Converted every
`border[-trbl]-[var(--chrome-border…)]` / `[var(--color-border…)]` (83
occurrences across 32 components/pages) to `border-transparent` — keeps the 1px
box (no layout shift, matches the badge.tsx convention), drops the frame, and
active/selected state stays visible via the existing bg-tint/text cues. Also
converted button.tsx's `border-border`/`border-input` variants and Panel's
header divider. Kept: focus-visible rings, aria-invalid, dashed drop-zones, and
the waveform/segment editor. Strengthened tests/test_no_literal_borders.py with
`test_no_token_border_utilities_in_jsx` so a reintroduced token border fails CI
(allowlists the editor + shadcn form-control primitives).

Task 2 — aliased the accent family in the base :root to the themed brand token
(`--chrome-accent: var(--color-brand)`, `-bg`/`-border` via color-mix), so
donate/support/commercial CTAs, active tabs, .btn-primary, status pills and
GoalBar/Pip track the active theme instead of the fixed pink. Replaced the
hardcoded `#d3869b`/`#f3a5b6`/`rgba(243,165,182,…)` pinks and the DONATE_HUE
constant in SupportPage.jsx with `var(--color-brand)` tints.

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 22:06:56 +05:30
1550ce2976 feat(ui): app-wide decorative border/divider removal (keep focus rings, bg cues) (#857)
Remove decorative borders, hairlines, dividers, and panel frames across the
frontend for a flat, frameless look. Selection/active state and input fields
stay perceivable via background/elevation cues; keyboard :focus-visible focus
rings are preserved.

index.css:
- Append a final `:root, [data-theme]` block zeroing every border token
  (--color-border[-strong|-warm], --chrome-border[-strong], --chrome-accent-
  border, --glass-border) → transparent. Kept last so it wins over the default
  root and all [data-theme] overrides. --color-ring / --focus-ring untouched.
- .glass-panel::before decorative top-highlight → display:none.
- Zero 22 neutral (white/black rgba) literal hairline borders (history divider,
  segment table, override toggles, etc.).
- Selection cue: .project-active border → transparent, stronger bg tint.
- Inputs: .input-base / textarea.input-base get a recessed --color-bg-elev-2
  fill (was --chrome-hover-bg / --chrome-bg which equalled the panel bg) so
  fields stay visible without a border; subtle elevation shift on focus.
- .history-kind--audio colored pill border → bg tint.

JSX/TSX:
- 79 literal-color border utilities (border-white/black, border-[#|rgba|
  color-mix]) → border-transparent (width kept: app ships without Preflight,
  so a bare button keeps a UA border).
- badge.tsx / button.tsx colored tone/active variants → border-transparent
  (bg fill + text color carry the tone); outline badge gains a bg.
- Bare `border` on shadcn card/dialog/select/dropdown content + ui/Tabs →
  border-transparent (no-Preflight currentColor line).
- Active/selected chips (StoriesEditor track, HfTokenCard, FirstRunSetup
  option, WorkspaceHistory/Sidebar/WorkspaceVoices kind pills) → background
  tint instead of accent border.
- 9 inline style borderColor → removed or swapped to a background tint
  (selection/error stay perceivable).

Kept intentionally: :focus-visible / border-ring focus rings, aria-invalid
error borders, the waveform segment editor (SegmentTrack) functional
boundaries/handles/selection, drag-active dropzone accent, and severity-token
state cues — these are functional affordances, not decorative chrome.

Guardrail: tests/test_no_literal_borders.py fails if the regression class
reappears (neutral literal borders in index.css, literal-color border
utilities / inline borderColor in jsx/tsx) and asserts focus tokens survive.

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 20:02:18 +05:30
7c0b0c2572 fix(diagnostics): harden the bug-report scrubber (5 audited leak/correctness gaps) (#856)
* 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>
2026-07-01 19:26:38 +05:30
29269b9cf0 feat: LLM Providers page + Autofit translation quality (fit-to-segment-time) (#838) (#854)
* 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>
2026-07-01 18:55:59 +05:30
c5c57508b3 fix(device): fall back to CPU when the GPU arch is unsupported, not 500 every generate (#756) (#757)
* 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>
2026-07-01 17:59:49 +05:30
e347f99542 fix(tts): bound + reset the GPU pool on a hung generate so it can't brick the backend (#730 class) (#851)
* 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>
2026-07-01 16:44:02 +05:30
522bbddccf feat(translate): highlighted Install affordance for uninstalled engines + dismissable/auto-clearing error banner (#847)
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>
2026-07-01 11:16:13 +05:30
4a01ecdfae fix(tts): force re-download a corrupt-but-right-size model blob before giving up (#739) (#744)
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>
2026-06-29 11:30:54 +05:30
cc95f526e0 fix(asr): reset the GPU pool when a chunked dub-stream chunk wedges too (#730) (#742)
The whole-file transcribe paths recover from a wedged worker via
run_transcribe_guarded's pool reset (#731), but the chunked dub transcribe-stream
only recorded a per-chunk timeout error and moved on — leaving the stuck thread
holding its GPU-pool worker, so subsequent chunks / a concurrent TTS generate
could still starve into 'can't reach backend'. Reset the pool on the per-chunk
TimeoutError via a small _reset_pool_on_wedge() helper (best-effort, no-op for a
plain executor). Closes the residual on #730.

Tests: helper resets a reset-capable pool and no-ops a plain ThreadPoolExecutor.

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 04:36:45 +05:30
9fc18dbd89 fix(tts): retry the incomplete-cache auto-repair so a transient blip doesn't dead-end (#739) (#741)
_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>
2026-06-29 04:12:43 +05:30
a9948de99e fix(generate): classify [Errno 32] Broken pipe as a lost-pipe error, not OOM (#715) (#722)
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>
2026-06-28 15:12:50 +05:30
935b38a962 fix(dub): pass OmniVoice's ffmpeg to yt-dlp so URL merge works off PATH (#712) (#716)
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>
2026-06-26 15:16:45 +05:30
ea4d5e7839 fix(generate): self-heal schema + don't 500 a generated clip on a history-write fail (#710) (#714)
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>
2026-06-26 15:04:31 +05:30
2c2e493df8 fix(dub): stream segments to disk to stop long-video RAM spikes (#639) (#709)
Takes over and completes #639 (original work by @trungthanh1288). Dub generation
held every segment's audio in RAM until final mix, so long/feature-length dubs
and big batches could exhaust memory. Segments now stream to disk as rendered;
the final track assembles from those files via a 30s-chunk memmap writer, so
peak memory stays flat regardless of length.

Completed on top of the original PR:
- Watermarking: keep the project's 'every OmniVoice audio carries the signature'
  guarantee without double-marking. Since seg_<id>.wav is BOTH the downloadable
  file AND the assembly input, mark each fresh segment once at synthesis and drop
  the per-chunk embed in the memmap writer (the final mix inherits the mark) —
  main's proven policy. Verified with real AudioSeal: 0.9999 detect confidence on
  the final track and on seg WAVs; cached/silence not re-marked.
- Fix a crash regression: zero/negative-duration segments returned an in-memory
  zero-length entry instead of writing empty audio (which raised). Regression test
  added.
- Perf: drop per-segment gc.collect(); throttle empty_cache() to every 16th call
  (the replaced code batched I/O to keep this off the hot path).
- Clean up the mix_<id> temp WAVs after assembly.
- Rewrite the watermark test for the multi-chunk (>30s) path; assert both the
  final track and the seg WAV are marked, with no double-mark.

212 passed / 1 skipped; route inventory clean.

Co-authored-by: mergetest <test@local>
Co-authored-by: trungthanh1288 <trungthanh1288@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 14:29:38 +05:30
aa17e3319f fix(model): route every OMNIVOICE_MODEL read through the resolver; tighten WinError 193 match (#693, #705) (#707)
Follow-up from independent verification of #693/#705.

#693 (whole-class): the resolver only guarded the model-load site. A leaked
engine id in OMNIVOICE_MODEL still hit four other raw reads — most importantly
preload_model()'s model_info() probe, which failed on the bad value and
SILENTLY disabled warm-up (first /generate then ate the full load). Plus the
Settings 'model_checkpoint' display, the loaded-models list, and the engine_id
baked into exported persona bundles. Route all of them through
resolve_omnivoice_checkpoint() (personas keeps its '' unset marker, sanitizing
only a set value). Add a source-level recurrence guard so a future raw read
can't reintroduce the class.

#705: tighten 'winerror 193' -> '[winerror 193]' so the substring can't also
match WinError 1930-1939 (the portable 'is not a valid win32 application'
clause still covers non-Windows formatting).

48 tests pass (resolver + guard + audio-guard + route inventory); edited
routers/services import clean.

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 03:37:46 +05:30
883a06e9c0 fix(generate): classify WinError 193 as a corrupt native component, not OOM (#705) (#706)
A synth failure from a corrupt or wrong-architecture native binary on Windows
([WinError 193] %1 is not a valid Win32 application — torch, ffmpeg, or a
bundled engine binary) fell through to the generic OOM message ('ran out of
memory — try Flush'), sending the user down a path that can't help.

_oom_friendly_reraise() now detects the WinError 193 / 'is not a valid Win32
application' signature (before the OOM fallback, joining the existing
torch.compile / decode-glitch / bad-instruct cases) and surfaces an actionable
'reinstall or repair that component; Flush won't help' message. Regression test
added.

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 02:13:21 +05:30
ea3b565f9c fix(asr): fall back instead of crashing when CTranslate2's .so won't load (#692) (#699)
On hardened kernels / newer glibc (e.g. WSL2 glibc 2.43) CTranslate2's shared
object is rejected at load with 'libctranslate2…cannot enable executable stack'
— an OSError, not ImportError. The WhisperX/faster-whisper is_available() probes
only caught ImportError, so the OSError escaped and crashed the ASR/dub
preflight ('ASR backend initialization failed: …').

- Both probes now also catch the non-ImportError load failure and REPORT
  (False, 'failed to load …') instead of raising — a probe must never raise.
- _auto_detect() routes every probe through a never-raising _probe_available()
  so no exploding probe can crash engine selection; it falls through to
  pytorch-whisper (transformers, no CTranslate2), which works on CUDA/CPU.

Regression tests cover the raising probe, the .so-load OSError surfacing as
unavailable, and auto-detect falling back to pytorch-whisper.

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 00:04:54 +05:30
97a020ecf0 fix(model): self-heal a leaked engine id in OMNIVOICE_MODEL instead of 500 (#693) (#698)
A stale/misconfigured OMNIVOICE_MODEL holding a bare TTS *engine id* (e.g.
"omnivoice") was passed straight to OmniVoice.from_pretrained(), which 500s
with "omnivoice is not a local folder and is not a valid model identifier
listed on huggingface.co/models".

Add resolve_omnivoice_checkpoint(): honor only a HF repo id (org/repo) or an
explicit local path (absolute / contains a separator); any bare token self-heals
to k2-fsa/OmniVoice with a logged warning — so a bad value can't brick model
load (and can't be faked by a cwd-relative folder of the same name). Regression
tests cover the leak, valid repo ids, absolute local dirs, and blanks.

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 00:04:47 +05:30
ee638bda6c feat(tts): user pronunciation dictionary (expressive-tts slice 1) (#685)
* feat(tts): user pronunciation dictionary (expressive-tts slice 1)

Per-term, per-language pronunciation overrides applied to text before synthesis,
so names, brands, and acronyms come out right across generate, longform, and dub.
Closes part of the #1 perceived-quality gap vs ElevenLabs (pronunciation
dictionaries). First slice of docs/specs/01-expressive-tts.md.

- Schema: additive `pronunciation_entries` table (alembic 0008, mirrored into
  _BASE_SCHEMA; tested upgrade — idempotent, downgrade, converge, back-compat).
- Service: extend pronunciation.py to load enabled entries (cached) and apply
  longest-first, word-boundary-aware, per-language (global '*' + lang match,
  lang overrides global), reusing the existing ReDoS-safe matcher.
- Inline one-off `[[term|replacement]]` overrides that don't persist and don't
  collide with [voice:]/[pause]/[Name]/SSML-lite (resolved pre-chunking).
- API: /pronunciation CRUD + /test dry-run + import/export (loopback-guarded).
- Apply point: generation.py after language resolves, before chunking — covers
  native + pluggable engines.
- UI: PronunciationPanel in Settings → General; all strings via i18n.
- Tests: migration lifecycle, CRUD, per-language, precedence, inline override,
  apply-at-synth. Route snapshot regenerated (+7).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(security): bound inline-override regex (ReDoS) + annotate parameterized UPDATE

CodeQL flagged py/polynomial-redos on the [[...]] inline-override regex: [^\]]
also matches [, so an unterminated run of [ allowed O(n) rescans from O(n)
positions. Bound the inner class to {0,256} (linear; an inline override is a
short respelling). Annotate the dynamic UPDATE (B608) — its column fragments are
fixed literals and every value is a bound parameter; not an injection vector.

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>
2026-06-25 03:16:25 +05:30
022a3bd6b9 feat(dictation): live local dictation via sherpa-onnx + Voice settings panel (#683)
* feat(dictation): live local dictation via sherpa-onnx + Voice settings panel

Add a sherpa-onnx ASR engine alongside the existing Whisper/NeMo dictation
path, powering a genuinely live experience: as you speak, words type straight
into the focused field (streaming partials via a new simulate_type command,
self-correcting with backspaces) and commit per pause.

Backend:
- SherpaDictationBackend + sherpa_dictation registry of the 7 models (Parakeet
  TDT v3/v2, streaming Zipformer EN/ZH/bilingual, Paraformer bilingual, Whisper
  Tiny) from csukuangfj/* int8 HF repos; CPU provider for cross-platform parity.
- /dictation/models + /dictation/prefs router; get_capture_asr_backend() honors
  the selected dictation model. get_active_asr_backend() (dub transcription) and
  the legacy WebM/Opus capture path are untouched.
- True streaming over /ws/transcribe (OnlineRecognizer: live partials +
  per-endpoint finals); offline models surface partials via short re-decode.

Frontend:
- New "Voice" settings panel (enable, Toggle/Hold mode, model picker with
  offline/streaming/recommended badges + per-model download/delete).
- Live word-by-word typing via simulate_type (enigo) with prefix-diff delta and
  backspace correction; paste fallback retained, no double-insertion.

Deps: sherpa-onnx>=1.13.3 (+ sherpa-onnx-core); uv.lock regenerated, Docker
frozen-install verified. API route-inventory snapshot updated. 40+ new tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(dictation): register sherpa-onnx-asr engine in README + features inventory

Fixes the docs-drift CI guard: the new sherpa-onnx-asr ASR engine existed in
the registry but not in docs/features.yaml or README. Adds the live-dictation
engine row to the ASR Engines table, bumps the engine counts (8→9), and adds
the inventory entry.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(changelog): fold live-dictation into the [0.3.8] section

main is 0.3.8 (untagged), so the dictation feature belongs in that release, not
a separate [Unreleased] block. Merge the two Added lists under one [0.3.8],
refresh the headline to lead with live dictation, and correct the capture
description to reflect live word-by-word typing (not paste-on-pause).

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>
2026-06-25 02:43:53 +05:30
2339cc8e85 fix(model): actionable "reinstall transformers" hint on a corrupted-install model-load error (#676)
A model load failed with `[Errno 2] No such file or directory:
'…/site-packages/transformers/models/qwen3/modeling_qwen3.py'` — the user's
transformers install was incomplete (the file is missing while a correct 5.3.0
install has it; an interrupted `uv sync` / antivirus / partial update drops it).
The System Check showed the raw path + "Check logs and try restarting", which is
useless — restarting can't restore a missing file.

Two fixes:
1. core.failure.classify(): recognize this corrupted-install variant. It's a
   FileNotFoundError, not an ImportError, so the existing TRANSFORMERS_IMPORT
   match ("could not import module"/"AutoFeatureExtractor") missed it. Now also
   matches a "no such file"/"errno 2" + "transformers" + "site-packages" signal
   (substrings checked separately so it works on POSIX `/` and Windows `\`
   paths). An unrelated package's missing file is NOT mislabelled.
2. model_manager._load(): build the /model/status error via build_failure so it
   carries the classified hint AND strips the home dir, instead of storing the
   raw str(exc). The System Check now shows "Your transformers install is
   incomplete. Reinstall it (uv pip install --reinstall transformers) or switch
   ASR to faster-whisper" — the existing TRANSFORMERS_IMPORT hint.

Docs: troubleshooting §1a documents the error + the reinstall fix.

Test: test_failure_classify.py pins the POSIX + Windows path forms classify as
TRANSFORMERS_IMPORT with a "reinstall" hint, and that an unrelated package's
missing file does not.

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 14:56:29 +05:30
b7cecde57e feat(setup): faster downloads by default + prominent, encouraged HF-token entry (#669)
Two changes that make first-run downloads faster and easier to speed up further.

1. Segmented (multi-connection) downloader is now ON by default. The app forces
   the legacy-LFS path (HF_HUB_DISABLE_XET=1) for clear progress, but that path
   is single-stream and slow — which is why downloads felt sluggish. The built-in
   IDM/uGet-style segmented accelerator (parallel byte-ranges, live speed/ETA)
   was already implemented but defaulted OFF. Flip it ON: it only engages when
   Xet is inactive (the default), and ANY failure falls back to snapshot_download
   ("can never compromise a correct install"). Pure-httpx, cross-platform,
   auth-safe (token never forwarded to a CDN). Override with
   OMNIVOICE_SEGMENTED_DOWNLOAD=0.

2. The Hugging Face token field is now a prominent, always-visible card right
   above Continue — was a collapsed "advanced" fold almost nobody opened. A free
   token gives authenticated downloads (higher rate limits, fewer stalls), so it
   pairs with change #1 to keep the parallel fetch from getting throttled. The
   card leads with the speed benefit, shows a saved-state, and adds a one-click
   "Get one free →" link to huggingface.co/settings/tokens.

Docs: downloading-models.md updated — the legacy-LFS section now documents the
default-on segmented accelerator + the HF-token speed tip, and the tuning table
reflects OMNIVOICE_SEGMENTED_DOWNLOAD=0 as the disable knob (docs-sync).

Test: test_segmented_download_default.py pins the new default ON and that the
env override still disables it; existing FDL-08 behavior tests stay green.

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 12:35:57 +05:30
81a007552b fix(generate): classify a bad-instruct error as a 400, not a 500 "ran out of memory" (#664) (#665)
A user typed free-form prose ("Speak with high energy … like a podcast host")
into the voice-design instruct field and got a **500** whose message read "TTS
engine stopped mid-generation. This usually means it ran out of memory. Try the
Flush button …" — with the real cause ("Unsupported instruct items found …")
buried as the underlying error. The user is told to Flush for an OOM that never
happened; the actual problem is a rejected instruct.

Root cause: `_resolve_instruct` raises on unknown/conflicting instruct items, but
by the time the error reaches `_oom_friendly_reraise` it's no longer a bare
`ValueError` (a lower layer wraps it), so the route's `except ValueError -> 400`
guard misses it and it falls through to the generic OOM `RuntimeError`. v0.3.7
has had that guard since v0.3.6 yet still produced the OOM message — proving the
error arrives wrapped, so type-based detection is insufficient.

Fix: in `_oom_friendly_reraise`, detect the instruct-validation **message
signature** ("unsupported instruct items" / "conflicting instruct items" / "in a
single instruct") regardless of exception type and re-raise a clean `ValueError`,
so the route returns a **400 with the instruct guidance** instead of a 500 OOM.
This is version-independent and complements the client-side guard (#658/#612):
it also covers API/MCP callers and stored profiles whose instruct slips through.

Test: two cases in test_generation_audio_guard.py — a bare instruct `ValueError`
and one wrapped in a `RuntimeError` both reclassify to a ValueError without the
"ran out of memory" text; the generic OOM path is unchanged.

Closes #664

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 07:22:51 +05:30
41e866b46c test(onboarding): guard demo clip stays un-ignored + a bundled resource (#621) (#648)
A Windows user's log showed 'Demo audio not found … demo_voice.wav — skipping
onboarding seed'. The local bundle DOES ship the clip (verified), so that user
just has a pre-#633 build — but the existing test only checks the file exists in
the repo. It misses the two ways the clip could silently drop from ALL builds
while still sitting in the repo: (1) the .gitignore un-ignore allowlist
(!backend/assets/samples/*.wav) being weakened — gitignore-aware build walkers
would then skip it; (2) backend/ being removed from tauri.conf.json bundle
resources. Pin both. test-only.

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 16:57:48 +05:30
Palash Debnathandmergetest 8e4044bb37 fix(i18n): clear orphan-key advisory — add en bootstrap.lines, drop dead gallery.cat_* (#646)
The locale orphan-key judge flagged 20 non-en locales carrying keys absent from
en. Two distinct causes:

1. bootstrap.lines (used at BootstrapSplash.jsx:467, t('bootstrap.lines',{count}))
   existed in de/es/fr/ja but NOT en — so English (and 16 locales falling back to
   it) rendered the literal key instead of '{{count}} lines'. Added to en.
2. gallery.cat_* (anime/books/celebs/disney/gaming/marvel/news/politicians) were
   renamed to archetypes.use_* long ago (VoiceGallery.jsx:309) but left orphaned
   in 20 locales — 160 dead keys. Removed.

Zero orphans remain. Flipped the probe test to assert the judge now PASSES
(regression guard). Locale files edited losslessly (json indent=2, ensure_ascii
=False, trailing-newline preserved). No version bump.

Co-authored-by: mergetest <test@local>
2026-06-23 13:27:43 +05:30
d8b059813a fix(startup): timeout-bound MCP session-manager start to stop M1 startup hang (#632) (#645)
* fix(startup): timeout-bound MCP session-manager start to stop M1 hang (#632)

A reporter's faulthandler thread dump showed the asyncio loop alive but the
lifespan suspended at an await with an idle pool worker + a leaked semaphore —
the MCP Streamable-HTTP session manager hanging on its anyio task group during
startup (Apple-Silicon M1). Because `enter_async_context(_sm.run())` is awaited
before yield, the hang meant 'Application startup complete' never fired and the
backend was unreachable with no error — a P0 (default feature dead on a platform).

The MCP layer is explicitly best-effort, but the old guard only caught
exceptions, not hangs. Bound the start with asyncio.wait_for
(OMNIVOICE_MCP_START_TIMEOUT_S, default 30s): a hang → logged warning + backend
serves without MCP. Extracted _enter_mcp_session_manager + _mcp_start_timeout_s;
4 regression tests (hang→False fast, healthy→True, None→noop, env override). No
version bump.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(startup): run MCP in its own task (anyio task-affinity) — fix CI cancel-scope error

The first attempt wrapped enter_async_context in wait_for, which entered the MCP
anyio task group in a throwaway sub-task while the AsyncExitStack exited it on the
lifespan task → 'Attempted to exit cancel scope in a different task' (caught by
test_coverage_critic's real backend boot). Correct fix: _serve_mcp owns the full
enter→exit in ONE task; _start_mcp_session_manager only waits (with timeout) on a
ready Event. A hang still can't block startup, and enter/exit share a task.
Shutdown signals stop + bounded-awaits the task. Tests updated (5; incl broken-
manager case). test_coverage_critic now boots+shuts down clean.

---------

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 12:49:39 +05:30
6819feb8b2 fix(dub): skip yt-dlp mtime stamp to avoid [Errno 22] on Windows (#642) (#644)
Dubbing a URL could fail with 'Unable to download video: [Errno 22] Invalid
argument' on Windows: yt-dlp stamps the downloaded file's mtime with the video's
upload date, and an out-of-range/invalid timestamp makes os.utime raise
[Errno 22], aborting the ingest. We download to a throwaway original.* and never
use its mtime, so set updatetime=False (yt-dlp --no-mtime). Regression test
asserts the opt is set. No version bump.

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 11:53:44 +05:30