Backend process deaths (OOM kills, shutdown races) were only diagnosable through the DESKTOP shell's crash markers (src-tauri/src/crash.rs). A 'bun run dev' browser session, Docker, or a LAN-share client has no shell, so the same death produced "Can't reach the local OmniVoice backend" with zero diagnostics — issue #1164's exact shape (crash_log.txt only records CAUGHT route exceptions, never process death). New core/run_sentinel.py makes the backend self-forensicate, deployment-agnostically: - lifespan startup writes DATA_DIR/run_sentinel.json {pid, started_at, version, last_activity}; clean shutdown (incl. uvicorn --reload restarts, which run the lifespan shutdown) removes it — verified by test_write_then_clean_clear_yields_no_crash. - next startup: a leftover sentinel with a dead pid = the previous run died uncleanly → a record in DATA_DIR/last_run_crash.json with the death window (ended_between), last known activity, version, and a scrubbed 40-line tail of omnivoice.log (core/scrub.py). - a leftover sentinel whose pid is ALIVE (psutil, with create_time defeating pid reuse) is a concurrent second instance — never reported as a crash, its sentinel left untouched. - touch_activity() marks meaningful work starts (task-manager worker, /generate with engine id, dub transcribe, cold TTS model load) — closed-set identifiers only, throttled to one small write per 2 s, never raises. Record-store semantics deliberately mirror crash.rs: newest-first cap of 3, ack is a timestamp watermark (never deletion — bug reports keep the evidence), reads are version-gated in memory and never write. API: GET /system/last-run-crash (+acknowledged), POST /system/last-run-crash/ack, and a 'last-run-crash-<ms>' error notification in /system/notifications (id embeds detected_at so a re-crash re-notifies; coexists with the crash-last-session heuristic). Fail-before/pass-after: backend/tests/test_run_sentinel.py (14 tests) — an unclean shutdown previously yielded no backend-side record at all. docs(specs): dictation flow program — local WhisperFlow-class dictation on Parakeet Three-agent research synthesis (product landscape, in-repo capability map, Parakeet/Nemotron feasibility) distilled into a six-phase plan: VAD + true-streaming Parakeet, personal dictionary + hotwords, app-aware/agent-prompting modes, insertion reliability + Wayland chain, local command mode, docs/evals. docs: add Trendshift badge to the GitHub and Docker Hub readmes docs(changelog): add the #1162/#1161 fixes to [Unreleased] fix: prevent probeAudioDuration from hanging on unresolved Promise (#1162) * fix: prevent probeAudioDuration from hanging on unresolved Promise The original Promise constructor only accepted `resolve` — no `reject` callback. The `error` event handler called `resolve(null)` instead of rejecting. If the Audio element never emits `loadedmetadata` or `error` (rare browser conditions, GC races, invalid blob URLs), the Promise hangs forever with no settlement path. Added: - 10-second timeout that rejects if neither event fires - Proper rejection on error event - `{ once: true }` on listeners to prevent double-invocation - Dedicated cleanup function * fix: settle probeAudioDuration with null on error/timeout instead of rejecting The 10s timeout stays (a media element that never fires any event genuinely hung this promise forever — verified). But both failure paths now resolve(null) rather than reject: the only caller, ingestRefAudio, awaits without a try/catch, and a clip this webview can't decode must still be accepted — the backend decodes it with ffmpeg (Tauri WebKit lacks several codecs). Regression test covers all four behaviors, fail-before verified against the reject() version. * style: oxfmt pass on format.js --------- Co-authored-by: mergetest <nizam4103@gmail.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> fix: return empty text instead of partial garbage when EPUB HTML parse fails (#1161) * fix: return empty text instead of partial garbage when EPUB HTML parse fails `_html_to_title_body` used `except Exception: pass` when the HTMLParser raised. The parser accumulates state incrementally across `handle_starttag`/`handle_data`/etc. callbacks — so a mid-parse failure returns whatever partial/corrupted `title` and `_parts` were accumulated before the error, with zero indication anything went wrong. EPUB chapter content would be silently garbled. Now returns ("", "") and logs the parse failure with traceback. * fix: keep partial chapter text when EPUB HTML parsing fails mid-chapter The warning log stays — a silent 'except: pass' hid real failures. But returning empty text made the caller's 'if not body.strip(): continue' silently drop the whole chapter from the audiobook, trading a possibly- truncated chapter for a definitely-missing one. Keep whatever the extractor collected before the failure and log the event. Regression test builds a real 3-chapter EPUB, injects a mid-chapter parser failure, and asserts the chapter survives with its pre-failure text (fail-before verified against the empty-return version). --------- Co-authored-by: mergetest <nizam4103@gmail.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> docs(readme-cn): full retranslation — sync with the current English README docs(readme-cn): mirror today's README refresh into the Chinese translation docs(readme): give the maker's other apps their due — logos, details, and a sponsor-section cross-link The 'more local open-source' section grows from a bare two-row table into proper cards: verified logo assets from each repo, a one-line pitch plus grounded detail (from the repos' own metadata), star badges, and links. The Sponsor/Donate section gets a one-line cross-promo — 'more apps from the creator' — since a star on a sibling project is support too. docs(readme): tighter, kinder to new users Cut the repetition, keep the charm: the local-first pitch was made three times before the first screenshot (quote + cell table + CTA block — now just the quote), Discord had four CTAs (now two), and the Intel-Mac caveat appeared three times (now once per context, linked). Screenshots trimmed 8 -> 6. Engine name lists that repeated the validated matrices inline (Why table, FAQ) now point at the matrices instead. Fixed the FAQ claiming '10 TTS engines' (it's 14). Added a download button to the hero so a new user's path from first glance to installed is one click. Docs-drift + install-docs validators and the CJK guard all pass. docs(docker): refresh the Docker Hub overview and docker guide - Add a what-you-need line (RAM/disk/GPU from the README requirements table, compressed pull sizes measured from the registry) so homelab users can size the deployment before pulling. - Update stale version examples (:0.3.6 / :0.3.17 -> :0.3.22). - Fix the 'main is always one patch ahead' claim — with AUTO_VERSION_BUMP off, main can equal the released version; say 'at or ahead of the last release', which is true in both modes. chore(release): codify all deployment channels as release rules; preview always builds from main A release now has an explicit channel checklist (docs/RELEASING.md §5b): GH Release + stable updater manifest, preview updater channel, GHCR + Docker Hub in both CUDA and ROCm flavors, and the Docker Hub overview sync (whose continue-on-error step must be verified by step log — it 403s silently on tokens without description-edit scope). Preview/RC policy is now enforced, not just documented: release.yml's preview-gate fails publish_preview dispatches from any branch but main, since the preview manifest and rolling Docker tags all track main. Also fixes docs/RELEASING.md §4-5, which still described the pre-2026-06 versioning scheme (tauri.conf.json + Cargo.toml as sources, 'Tauri ignores package.json') — the exact opposite of the current single-source rule — and docs/update-channels.md, which invited previews off feature branches. docs(changelog): add the #1159/#1160/#1158 fixes to [Unreleased] #1159 and #1160 merged without changelog entries; also covers the useAppData silent-catch logging absorbed from declined PR #1158 and the #1160 follow-up traceback sweep. docs(event-bus): document the single-loop no-await invariant in _broadcast (#1163) The QueueFull-recovery block was misread as racing with consumers (an AI-generated PR proposed "fixing" it). Pin down why it is correct so the misreading does not recur. Comment-only change. fix(frontend): log swallowed data-loading failures in useAppData (absorbed from #1158) The five data-loading callbacks (profiles, generation history, dub history, projects, export history) swallowed API failures with empty catch blocks — a failed fetch looked identical to an empty library, and "my voices vanished" reports carried nothing to diagnose. Each catch now console.warn-s with an accurate per-fetch message (the declined PR copy-pasted mismatched ones). The backend-startup retry loop and the localStorage-restore catch are left silent on purpose: failing there is the expected path they exist to absorb. Co-authored-by: bultodepapas <bultodepapas@gmail.com> fix(backend): stop discarding tracebacks in error-level exception logs (#1160 follow-up) #1160 fixed one traceback-losing logger.error in dub_pipeline.save_job; this sweeps the remaining class. 19 sites across 10 files where a real, unexpected failure was summarized as "...: %s" at ERROR level — losing the stack trace that makes crash reports diagnosable — now use logger.exception (diarization/ASR crashes, dictation load/final failures, ffmpeg mixes that silently degrade output, Smart Fit retime fallbacks, RVC init/inference, models.yaml catalog load, gallery search/download 500s, dub-history JSON decode, MCP CLI fatal exit). Deliberately left alone: WARNING/INFO/DEBUG logs, expected classes with self-sufficient messages (GPU/ASR timeouts, request validation, cryptography-availability checks), sites that re-raise immediately (db migration, _ensure_mcp), subprocess returncode checks where stderr IS the diagnosis, and sites already logging exc_info/format_exc. test(bootstrap): render regression test for the stage="failed" TDZ crash (#1159) The #1159 fix merged without a test: the existing BootstrapSplashFailedRecovery.test.jsx only exercises the useBootstrapStage hook, so nothing ever rendered <BootstrapSplash stage="failed"> and CI could not see the ReferenceError. These tests mount the failed card directly (recoverable + unrecoverable variants); both throw on the pre-fix line order and pass on the fixed one. fix: log full traceback when dub job persistence fails (#1160) `save_job`'s `except` block caught the re-raised exception from `db_conn` (which rolls back and re-raises on failure) but only logged the error message without the traceback — making the root cause undiagnosable in production. Changed `logger.error` to `logger.exception` so the full stack trace is preserved. fix: prevent TDZ ReferenceError crash when bootstrap fails (#1159) `const isUnrecoverable` on line 384 referenced `logs` which was declared on line 385 via `useState([])`. When `isFailed` is true (backend setup failure), the short-circuit `&&` accesses `logs` in its temporal dead zone, throwing ReferenceError and crashing the splash screen — precisely when the user most needs the error UI. Moved the useState declaration above isUnrecoverable. feat(docker): publish ROCm/AMD GPU image variant (#1165) (#1166) The Docker image was CUDA-only, so AMD GPUs (e.g. RX 7900 XTX under Podman) silently ran on CPU. Every preview and release now also ships a ROCm variant built from the same Dockerfile: - deploy/Dockerfile: parameterize the runtime base with a BASE_IMAGE build-arg (default unchanged: pytorch/pytorch 2.8.0 CUDA). Add PIP/UV_BREAK_SYSTEM_PACKAGES for the ROCm base's PEP-668-marked Ubuntu 24.04 Python (no-op on the conda CUDA base), and a build-time GPU_FLAVOR guard asserting the dependency install did not clobber the base image's GPU torch/torchaudio — a future dep bump that forces a torch reinstall now fails the build instead of shipping a CPU-only "ROCm" image. - .github/workflows/docker.yml: new build-and-push-rocm job (separate job for runner disk — the ROCm base is ~25 GB unpacked, so it frees the preinstalled toolchains first). Tags mirror the CUDA semantics with a -rocm suffix (:rocm rolling preview, :stable-rocm, :X.Y.Z-rocm, :X.Y-rocm, :sha-xxxx-rocm) on both GHCR and Docker Hub, same secret gating. flavor latest=false so release tags can't clobber :latest. No cache-to: the ROCm layers would blow the 10 GB GHA cache budget. - deploy/docker-compose.yml: new opt-in 'rocm' profile passing the GPU through via /dev/kfd + /dev/dri, with HSA_OVERRIDE_GFX_VERSION=11.0.0 documented (user-set, not baked in — backend auto-sets it for known consumer GFX IDs). - Docs-sync: docker.md (ROCm quick start incl. Podman/Quadlet, tag table, troubleshooting), dockerhub-overview.md, README AMD note, linux.md ROCm section cross-link, CHANGELOG [Unreleased]. Base image: rocm/pytorch:rocm7.2.4_ubuntu24.04_py3.12_pytorch_release_2.8.0 — torch 2.8.0 exactly matches the CUDA image (identical resolution, so uv keeps it), py3.12 satisfies requires-python >=3.11 (the ubuntu22.04 variants are py3.10 and do not). Closes #1165 Co-authored-by: mergetest <nizam4103@gmail.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> docs(changelog): add the #1156/#1153/#1155/#1152 fixes to [Unreleased] fix(dub): diagnose export failures honestly; dodge the Windows 32K argv limit (#1152) A Windows dub export died at ffmpeg *spawn* with [WinError 206] — the mux argv scales with tracks/segments and exceeded CreateProcess's 32,767- char limit — but the catch-all told the user their (8-char) filename was too long AND to check whether ffmpeg was installed. - explain_ffmpeg_failure() maps the three real failure modes to their own advice: argv-too-long (names the limit + the actual size, suggests fewer languages per export), ffmpeg-unlaunchable (the only case that suggests checking the install / FFMPEG_PATH), and ffmpeg-ran-and-failed (surfaces ffmpeg's stderr, no install advice). All three dub_export catch sites (video mux, audio export, MP3 encode) now use it. - run_ffmpeg() on Windows moves an oversized -filter_complex graph (the dominant argv consumer — the bed-mix/apad branches grow per track) into a -filter_complex_script temp file, so the spawn never hits the limit in the first place; the script file is removed after the run. Regression tests: tests/test_ffmpeg_failure_diagnosis.py. fix(win): stop the Fortran-runtime console-close abort and cp1252 UnicodeEncodeError crash classes (#1153, #1155) Two Windows-only backend crash classes, one boundary (process spawn/stdio): forrtl: error (200) (#1153 and the crash markers in #1155/#1152): MKL's Intel Fortran runtime installs a console CTRL handler that aborts the whole backend (exit 2 / 0xC000013A) when a console CLOSE/LOGOFF event reaches it. The backend was spawned with no console isolation, so OS console events could reach it mid-session. Now: - the desktop shell spawns the backend with CREATE_NO_WINDOW | CREATE_NEW_PROCESS_GROUP (no console → no console events, stdio is piped anyway) and sets FOR_DISABLE_CONSOLE_CTRL_HANDLER=1; - backend/main.py setdefaults the same var before torch/numpy can load MKL, covering scripts/run.sh and bare uvicorn launches too. 'charmap' codec can't encode (#1155): kittentts print()s the user's text on every generate; on Windows the child's stdout is cp1252, so Vietnamese text raised UnicodeEncodeError and surfaced as a bogus '400 Bad Request'. The process-wide SafeFileWrapper only swallowed OSError (its EPIPE job). Now: - stdio is reconfigured to UTF-8 (errors=backslashreplace) at startup; - SafeFileWrapper also swallows UnicodeError — logs are best-effort, synthesis is not; - the shell sets PYTHONUTF8=1 for the child (Windows→parity with macOS/Linux; process env wins for power users); - the crash-log append opens with encoding=utf-8 so tracebacks carrying user text can't re-trip the same codec. Regression tests: tests/test_windows_stdio_guards.py (cp1252 stream write must not raise; main must set the Fortran guard + UTF-8 stdio). fix(backend,shell): a missing MCP SDK can no longer kill the backend — and a failed setup now self-heals (#1156) Root cause: mcp_server._ensure_mcp() called sys.exit(1) when the mcp import failed; SystemExit is a BaseException, so main.py's best-effort 'except Exception' around the /mcp mount never caught it and the whole backend died with exit code 1 on startup. - _ensure_mcp raises ImportError (catchable) with the underlying error — the import can fail with the package present (broken pywin32 transitive import on Windows), so 'not installed' was a misdiagnosis. The standalone CLI keeps its exit(1) contract. - New mcp_server.mount_mcp(app) contains Exception AND SystemExit at the integration boundary (same exit-containment class as #1143's engine boundary); main.py's mount guard catches SystemExit too. - The 'Setup failed' splash card now auto-dismisses when the backend becomes healthy: 'failed' used to stop the IPC poll loop while the successful IPC reply had already disarmed the #879 HTTP watchdog, so nothing could observe a recovered backend. A /health recovery poll now runs for the failed stage (startHealthRecoveryPoll). - Relaunching the app while bootstrap is Failed now retries the backend spawn (same path as the Retry button) instead of just refocusing a dead window (tauri single-instance callback). Regression tests: tests/test_mcp_graceful_degradation.py (SystemExit → ImportError, mount containment, CLI exit contract) and frontend/src/test/BootstrapSplashFailedRecovery.test.jsx (failed → ready on health, stays failed while dead). chore(gitignore): ignore locally-installed third-party skill packs under .claude/skills/ Follows the existing speckit-* precedent: skill dirs are ignored by default, and skills meant to ship with the repo (omnivoice) are re-negated explicitly. docs(changelog): start [Unreleased] with the #1154 remote-auth API-key gate fix fix(remote-auth): show an API-key gate (not PIN) for API-key 401s in remote-backend mode (#1154) * fix(remote-auth): route API-key 401 to an API-key gate, not the PIN form When OMNIVOICE_API_KEY is set (remote-backend mode), a non-loopback browser gets 401 "API key required" from BearerKeyMiddleware. But client.ts fired `ov:pin-required` on every 401, surfacing the PIN gate — whose payload (sessionStorage ov_pin / X-OmniVoice-Pin) can never satisfy the API-key middleware. A remote user was stuck on a PIN form they could not pass. Read the 401 `detail` and dispatch a single `ov:auth-required` CustomEvent carrying the mode; RemoteAuthGate renders the matching PIN or API-key form. Adds a `?api_key=` deep-link bootstrap (one-shot — scrubbed from the URL so a reload can't re-clobber a corrected key) and a guarded saveApiKey helper. Backend is unchanged — the two 401s are distinguishable by their `detail` body ("API key required" vs "PIN required"). Docs: remote-gpu.md gains a "From a browser" subsection for the new ?api_key= deep link. * fix(remote-auth): preserve URL hash when scrubbing credentials The replaceState that scrubs ?api_key=/?pin= rebuilt the URL from pathname (+ optional query) and dropped url.hash, nuking any deep-link fragment (e.g. #settings). Rebuild with pathname + (?query) + hash. Addresses greptile + coderabbit review feedback on #1154. * fix(remote-auth): guard 401 routing against a non-string/malformed detail String(detail) can itself throw on a 401 detail whose toString is broken (e.g. { toString: null }), aborting the auth-event dispatch. Match only real strings with typeof; anything else falls back to PIN mode. Addresses coderabbit's 17:03 re-review finding on #1154. * fix(remote-auth): read the deep-link API key from the URL fragment (#api_key=) Move the remote-backend deep link from ?api_key= (query) to #api_key= (fragment): fragments are never sent to the server, so the durable key stays out of the GPU box's and any reverse proxy's request logs on the page load (greptile P1). ?pin= stays on the query (QR flow, session PIN). The bootstrap is extracted into a pure, unit-tested _parseDeepLinkCredentials helper (pin from the query, api_key from the fragment, one-shot scrub of both, plus a legacy ?api_key= scrubbed-without-reading so a stray query key never lingers). Docs document #api_key= with encoding guidance for keys containing + / & / # / =. release: freeze v0.3.22 — version bump, lockfiles, changelog (#1150) The dubbing release. package.json (source of truth) + the three mirrors (Cargo.toml, pyproject.toml, version.py fallback) to 0.3.22; uv.lock + Cargo.lock refreshed; CHANGELOG's Unreleased section (29 entries) becomes ## [0.3.22] — 2026-07-14 with the headline, split Added blocks merged. Gates on the frozen content, all green before any version mutation: backend 3033 + 204, frontend 1253, format, lockstep 6/6. Co-authored-by: mergetest <nizam4103@gmail.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> fix(dub): hydrate partial translations on tab switch; dialect guard moves into the store (#1149) * fix(dub): hydrate partial translations on tab switch; dialect guard moves into the store Review round on #1148, both findings real: - Greptile P1 "missing translations leave mixed text": the in-browser translations map can be PARTIAL (tracks generated before per-language persistence, partial regens); the non-destructive switch then left those rows in the previous language under a single-language preview. New GET /dub/segments-text/{job}?lang= exposes segments_i18n (the authoritative per-language map every generate rebuilds); the tab click hydrates only the gap rows, failure-silent, and skips stale responses if the user switched again mid-fetch. - CodeRabbit "clear stale dialect": the dropdown paths each cleared a non-matching dubDialect by hand; the guard now lives inside switchDubLangCode so every caller (dropdown, multi-language loop, preview tabs, future ones) inherits it. Matching dialects survive. Tests: endpoint (i18n map served, never-generated track -> empty map, legacy job -> empty map), hydration (stored rows swap instantly, missing row hydrates from the mock backend and is cached into translations), dialect guard (cleared on mismatch, kept on match). Suites: dub sweep 262, frontend 1253, both green. * test(api): register /dub/segments-text in the route-inventory snapshot The inventory guard caught the new endpoint exactly as designed. --------- Co-authored-by: mergetest <nizam4103@gmail.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> fix(dub): Export-step language tabs switch the transcript segments too (#1148) Owner request with screenshot: the Original/Bengali/German/… pills above a finished dub only swapped the preview VIDEO; the segment list kept showing the last generated/edited language — German audio playing over Bengali text. The pills now also route through switchDubLangCode (the P1.2 user-driven language switch: outgoing text snapshotted into translations[prev], incoming swapped in, non-destructive when no saved entry exists) plus setDubLang — exactly what the language dropdown and the multi-language generate loop already do, so fingerprint/staleness semantics are identical. The Original pill deliberately leaves the editing language untouched: there is no 'original' editing language, and every row already renders the original line under its translation. Tests: clicking the German pill swaps segment text to the stored German translation, snapshots the outgoing Bengali, and sets dubLangCode; the Original pill leaves dubLangCode alone. Fail-before verified (wiring stashed → text swap test fails). Full frontend suite: 1251 passed. Co-authored-by: mergetest <nizam4103@gmail.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> fix(shell): version-gate crash markers, pin the WebView repair contract, run the Rust suite in CI (#1145) * fix(shell): version-gate crash markers, pin the WebView repair contract, run the Rust suite in CI Two deferred items from the recurrence audit, plus the CI gap that made them possible: - crash.rs: a persisted "backend crashed" marker now only surfaces for the release that wrote it. After an upgrade, markers from the previous version (quite possibly the build whose crash the upgrade fixed) are ignored and pruned on read instead of resurfacing unacknowledged as if the new build had crashed. backend_version gains #[serde(default)] so legacy version-less markers still deserialize — as "", which the gate treats as stale by design. Preview stamps (X.Y.Z-N) count as their release. - commands.rs: the #879 WebView2 cache repair's filesystem half is extracted into clear_webview_cache_at() (paths + retry policy as parameters, zero behavior change) and its contract is pinned by tests: no marker → nothing touched; marker consumed first, unconditionally (one-shot — a failing repair can never loop across launches); missing cache is success; a locked cache is retried then abandoned with a log, never bricking startup. - ci.yml: the Tauri shell check only ran `cargo check`, which neither compiles nor runs #[cfg(test)] code — so the shell's ~90 unit tests (crash.rs, reset.rs, bootstrap.rs, …) never executed anywhere in CI. `cargo test --lib` now runs them natively on all three OSes. * fix(shell): crash-notice read path is strictly read-only — a prune-save there could destroy a fresh marker Greptile's P1 is real, and hotter than stated: get_last_backend_crash is not just a startup check — streamDropError (#1119) polls it every second for 8 s after a stream drops, which is exactly when the death watcher is inside record_crash's load→push→save. The previous commit's read path did load→prune→save when stale-version markers existed (the post-upgrade state), so a poll could load the pre-crash snapshot, lose the race, and save over the freshly recorded marker — silently deleting the only evidence of the crash it was being polled to find. Smallest fix: reads never write. The read path (extracted as read_notice_from(path, version) so the contract is testable) filters stale-version markers in memory only; disk pruning stays on the write paths (record_crash, acknowledge_backend_crash), where load-modify-save already existed pre-PR and is paced by a crash or a user click rather than a 1 Hz poll. Regression test pins the file as byte-identical across reads, stale markers filtered and current ones surfacing as before. --------- Co-authored-by: mergetest <nizam4103@gmail.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> feat(dub): Voice match toggle — per-line prosody vs one consistent reference per speaker (#1147) * feat(dub): Voice match toggle — per-line prosody vs one consistent reference per speaker Owner report: "still 4 segments different in voice as they are 4 times done from each segment?" — Wave 3.2 clones each dub line from a reference cut from its OWN source audio (great prosody match), but the voice IDENTITY drifts line to line, and heuristic-diarized jobs have no pooled speaker clones to anchor it. The precedence was hardcoded; now it's a per-dub-job setting. DubRequest.voice_match: - "per_line" (DEFAULT, unchanged): segment clip preferred, speaker clone fallback — byte-identical to the previous behaviour. - "consistent": ONE reference per speaker for the whole dub. `auto:` bindings use the pooled speaker clone; when none exists (heuristic diarization skips extraction entirely — the key case) a deterministic pick among that speaker's segment clips (longest ≥3 s, tie-break lowest segment id) is reused for every line. Server-default self `auto-seg:` bindings join the pick (they're what prepare stamps on heuristic jobs — the Voice dropdown can't even render them, so no user choice is overridden); explicit CROSS auto-seg bindings still honour their clip. The shared pick is multi-use, so it stays warm in the clone-prompt cache (#1132 cache_ref semantics) at both the main generate and the OOM-retry call site. voice_match is part of the segment fingerprint when non-default (mixed in like track_lang, so all stored hashes keep their values): flipping the toggle marks segments stale instead of letting "Regen changed" splice mixed-identity voices (#281 class). The client sends the mode on both /tools/incremental recompute paths. UI: a compact Voice-match Segmented control next to the Timing picker in the dub panel, persisted in the prefs slice; labels + tooltips in all 21 locales. Tests: resolution through the real dub_generate path for both modes (incl. the 4-segment heuristic job unifying on one ref — fail-before/pass-after), pick determinism + tie-breaks, schema validation, fingerprint semantics, and frontend store→request wiring. * docs(changelog): Voice match toggle entry under Unreleased (#1147) --------- Co-authored-by: mergetest <nizam4103@gmail.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> fix(engines): bundle en_core_web_sm — no mid-generation GitHub download (#1146) * fix(engines): bundle en_core_web_sm — no mid-generation GitHub download Post-merge review finding on #1144 (valid): with pip present, misaki/spaCy's first-use auto-download WORKS now — meaning the first English MLX-Audio generation performs a raw GitHub release download that (a) bypasses the app's entire HF-mirror/endpoint system (restricted-network users have no recourse mid-generation) and (b) fails offline. Local-first says default features shouldn't spring surprise outbound requests at generation time. en_core_web_sm-3.8.0 is now a pinned URL dependency in pyproject/uv.lock (~12 MB wheel): it arrives at install/update time via the normal dependency flow (where network failures are visible and retried), survives drift-sync by construction, and spacy.util.is_package() finds it so misaki never triggers its downloader at all. The #1143 containment stays as the backstop for any other CLI-shaped dependency. Also clarifies the venv test per review: pytest's interpreter IS the uv-synced venv in CI and the packaged app, so find_spec verifies the lock; the test now also pins the bundled model. Validated: uv sync --frozen clean; en_core_web_sm importable. * fix(engines): direct-URL dependency + frozen-bundle collection (review) Two of three review P1s were real: - Docker build break: `uv add` wrote a bare "en-core-web-sm" dependency with the URL only in [tool.uv.sources] — Docker's `uv pip install --system .` reads project metadata only, would resolve the bare name against PyPI (where spaCy models don't exist), and the image build fails. Now a direct "name @ url" dependency, the same form kittentts has always used, so every installer (uv sync, pip, Docker) sees the same source. Re-locked; uv sync --frozen clean. - Frozen bundle: backend.spec ships mlx_audio, whose Kokoro path loads en_core_web_sm DYNAMICALLY (spacy.load by name) — PyInstaller never sees the import, so a frozen build would hit misaki's downloader at first English generation. collect_all('en_core_web_sm') added inside the mac-ARM block (plain data package, no nanobind hazard — the reason collect_all is banned for mlx itself doesn't apply). Declined with precedent: "hard-coded GitHub URL breaks restricted networks" — kittentts has shipped as exactly this GitHub-release URL form in the same dependency list since it was added; install-time GitHub fetches are the project's accepted pattern (the bootstrap's gh-proxy mirror exists for restricted networks), unlike mid-generation fetches, which this PR removes. --------- Co-authored-by: mergetest <nizam4103@gmail.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> fix(engines): ship pip in the managed venv — the #1133 root trigger (#1144) The containment fix (#1143) makes a CLI-shaped dependency's sys.exit survivable; this removes the reason it fired at all. mlx-audio's Kokoro phonemizer (misaki) auto-downloads en_core_web_sm via spacy.cli.download, which shells out to `python -m pip install <url>` — and uv-managed venvs ship no pip, so the download always failed. Why a real dependency instead of installing pip (or the model) ad-hoc at engine load: the updater's drift sync reconciles the venv against the lockfile (#1029/#1030, --inexact), so anything outside the lock is stripped on the next update — the failure would quietly return after every release. pip in pyproject/uv.lock survives sync by construction. Validated against all lock consumers: uv sync --frozen clean; Docker's `uv pip install --system .` reads pyproject; version-lockstep test reads only the version field. Regression test asserts pip is importable in the managed env. Co-authored-by: mergetest <nizam4103@gmail.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> fix(engines): contain SystemExit at the pool boundary — a CLI-shaped dependency killed the backend (#1133) (#1143) * fix(engines): contain SystemExit at the pool boundary — a CLI-shaped dependency killed the backend (#1133) Auto-report #1133 (8GB M1, v0.3.21, engine mlx-audio, exit code 1 at 21s uptime) carried the whole story in its stderr tail: mlx-audio's Kokoro pipeline uses misaki's G2P, whose __init__ runs spacy.cli.download() IN PROCESS when en_core_web_sm is missing. spaCy's downloader is written as a CLI: with no pip in the venv (uv-managed venvs ship none), its error printer calls sys.exit(1). SystemExit is not an Exception, so every except Exception on the path waved it through; it rode the executor future into the event loop, where uvicorn treats SystemExit as "shut down" — backend dead. Class fix, not a spacy special-case: _contain_system_exit() wraps every callable dispatched through run_on_gpu_pool_guarded (all engine loads AND generates funnel through it, #1033) and asr_backend.run_transcribe_guarded, converting SystemExit into a RuntimeError that names the real failure mode. Any engine dependency written as a CLI is now covered on both the TTS and ASR sides. Not done here (follow-up candidates): pre-provisioning en_core_web_sm for the Kokoro/mlx-audio path so the download never triggers, and/or shipping pip into the managed venv. Both are provisioning decisions; this PR makes the failure survivable and honest first. Tests: SystemExit from a pool job -> RuntimeError naming SystemExit(code), executor still usable afterwards; same for the transcribe guard. Both fail with the containment reverted. Full suite: 3016 passed. * fix(engines): containment helper moves to a leaf module (CodeQL cyclic-import) utils/containment is stdlib-only, so model_manager and asr_backend both import it at module top with no cycle — the call-time back-import CodeQL flagged is gone. --------- Co-authored-by: mergetest <nizam4103@gmail.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> fix(tts): Vietnamese consistency — Voice vs Audiobook divergences (#1142) * fix(tts): Vietnamese consistency — Voice vs Audiobook divergences (#1139) Three root causes behind "Vietnamese Voice generation is inconsistent compared to Audiobook": 1. Numbers: num2words' vi cardinals are wrong for 2001-2099 (misused "lẻ": 2024 → "hai nghìn lẻ hai mươi bốn") and vi has no year form, so normalization mangled the years the engine used to read natively. Vietnamese now keeps its digits, and _num2words_lang's display-name path now gates on _NUM2WORDS_LANGS like the ISO path (the loophole that let "Vietnamese" bypass the vetting "vi" would have failed). 2. Seed: the longform resolver fetched a profile's pinned seed but only the cache signature ever used it — book renders ran unseeded. Both longform synth wrappers now seed torch per segment via the new pure segment_seed(base_seed, text) helper (crc32-decorrelated, order- and cache-independent, mirroring /generate's used_seed + i). 3. Quality preset: the audiobook synth inherited num_step=32 / guidance_scale=2.0 from model-config defaults by accident of omission while /generate defaults to 16 — the main audible gap. Now explicit (LONGFORM_NUM_STEP / LONGFORM_GUIDANCE_SCALE), pinned by a test so upstream default drift can't silently change books. The Voice-page fast default (16) is deliberately unchanged. Also (issue part 3): the finished audiobook's player + Download link lived in component useState and evaporated on tab switch — the last render's filename is now store-backed and persisted. Regression tests fail-before/pass-after (verified by stashing the fix): vi digit passthrough + vetted-set gate invariant; segment_seed + seeding in both synth branches + explicit preset kwargs; lastOutput store round-trip. Full backend suite 3004 passed; frontend 1237 passed. * fix(ui): loadProject clears lastOutput; document longform seeding contracts (review) Review-bot findings on #1142, evaluated: - FIXED (Greptile P1 "Output Escapes Its Project" + CodeRabbit): loadProject now resets lastOutput like newProject already did, so loading project B never presents A's finished render as B's output. Regression test added (set lastOutput → loadProject → cleared). - REFUTED (P1 "Global RNG Races Between Workers"): the exposure is identical to /generate's existing #526 seeding — generation.py calls torch.manual_seed on the same global RNG inside the same GPU pool, and has since that PR. The pool is 1 worker on MPS/CPU and small-VRAM CUDA (model_manager._pick_gpu_workers), where determinism is strict; a >1-worker CUDA pool is best-effort for BOTH paths. A race-free fix means threading a per-call torch.Generator through the model's samplers app-wide (covering /generate too) — out of scope for this PR and pointless to do one-sided. Contract now documented on _seed_segment_rng. - REFUTED (P2 "Repeated Text Reuses One Seed"): identical takes for identical repeated lines is the pipeline's shipped semantic — the content-addressed SegmentCache (segment_cache_key hashes text + voice sig, not position) already replays one WAV for every identical span — and seeding only activates when the user pinned a seed, i.e. asked for reproducibility. Position-based keys would shift every later span's seed on a one-paragraph insert, breaking the cache-independent partial re-render guarantee. Documented on segment_seed. - DECLINED (P2 "Persisted Filename Can Outlive File"): longform outputs in OUTPUTS_DIR are not auto-pruned (prune_cache_dir bounds only longform_cache), so a dangling name requires manual deletion; auto-clearing on an <audio> error would instead wipe a valid link whenever the backend is briefly down at mount. Projects → Audiobooks stays the authoritative library. Also rebased onto main past #1141 (CHANGELOG resolved keeping both Unreleased→Fixed entries, this PR's on top). Affected suites: 264 passed; frontend format clean, 1245 tests passed. --------- Co-authored-by: mergetest <nizam4103@gmail.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> feat(hardening): six recurrence guards from the closed-issue-history audit (#1141) * feat(hardening): six recurrence guards from the closed-issue-history audit An agent audit swept every closed issue, clustered the error classes, and checked each for fix + regression test + upgrade/reinstall survival. Six of the "fixed but fragile" gaps are closed here; each guard has a regression test in tests/test_recurrence_hardening.py (9 tests). 1. Evict-then-load (class 1, ~90 issues): a plain TTS load on a tight unified-memory box could still be OS-killed — the dub path frees memory before ASR loads (#1119) but nothing did before a TTS load. _make_room_before_tts_load() releases the idle capture-ASR model, clone prompts, and allocator caches when free RAM < the unified headroom. Deliberately NOT admission control: the #1111 decision (advisory-only, never refuse a load on an estimate) stands; this only does earlier what idle reclaim does later, and roomy machines skip it entirely. 2. Honest SIGKILL attribution (class 1): crashCauseHint() says "the OS ran out of memory (RAM)" for signal 9 instead of guessing VRAM on machines that have none. VRAM guidance kept for real GPU aborts (signal 6 etc.). 3. Clone-kind save sanitize (class 3, recurred 3x): the server-side instruct heal was gated to design-kind; a clone profile saved by any bypassing client could persist prose that 400s on every use. profiles.py now sanitizes both kinds at the single choke point. 4. Stale user_env validation (class 5): ~/.config/omnivoice/env is inherited verbatim by reinstalls; path-valued keys (OMNIVOICE_CACHE_DIR/DATA_DIR) that don't exist and can't be created are dropped for the run with a loud log line (file untouched — replugging the drive restores the setting). The two #480 precedence tests updated to use creatable paths (they test precedence, not path validity). 5. omni_ui schema guard (class 6): sanitizeOmniUi() whitelists + shape-checks every persisted field before restore — one malformed field used to throw mid-restore and silently discard everything after it, and every future field re-opened the #1067 class. Includes a lockstep test failing when useAppData reads a field missing from the schema. 6. safe_replace EXDEV helper (class 7): os.replace across devices raises EXDEV (the Windows D:-drive Errno 18/22 class); utils/fsops.safe_replace degrades to copy+fsync+replace. Adopted at the two cross-directory movers (log rotation, persona restore); temp-sibling writers stay on os.replace. Plus: the generate timeout scales with text length (class 4's 503 wave — +1s per 40 chars past the first 1200, env floor respected), so long texts on slow hardware stop dying at exactly 300s with a "set an env var" remedy. Deliberately NOT done, with reasons: - ASR auto-promotion to the crash-isolated engine after a wedge: the code records an explicit owner rule against silent engine switching (asr_backend.py "we never switch engines automatically") — flagged to the owner instead of overridden. - Rust items (webview cache-clear unit test, crash-marker versioning across updates): deferred to their own PR — the local cargo target was reclaimed for disk space, so they can't be verified locally right now. Full suite: 2999 backend + 1243 frontend. * docs(changelog): correct PR ref to #1141 * fix(hardening): review round — reclaim at the shared load boundary, write-probe path validation Both Greptile P1s were real: - "Startup preload skips reclaim": _make_room_before_tts_load() ran only in get_model(); preload_model() calls _load_model_with_timeout() directly, so a memory-tight machine was protected on demand loads but could still be OS-killed during the startup preload — the exact window the guard exists for. The reclaim now lives in _load_model_with_timeout(), the boundary both callers share. - "Read-only paths pass validation": an existing directory on a read-only mount passes makedirs+isdir but fails on first real use, so the stale setting survived validation only to break downloads later. The check now probes actual write capability (create+delete a probe file). New test with a chmod-0o500 dir (skipped under root, where the probe cannot fail). - CodeQL: the two intentional best-effort excepts in fsops.py now carry their explanatory comments. Full suite: 3000 passed. --------- Co-authored-by: mergetest <nizam4103@gmail.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> fix(dub): stereo, full-band music bed — separate the HQ extraction, pin the mix to stereo (#1138) * fix(dub): stereo, full-band music bed — separate the HQ extraction, pin the mix to stereo Owner asked for a channels/Hz/samples comparison of a dub against its original to tune generation toward the source. The measurements found a class, not a knob: L/R correlation: original 0.754, dub 1.000 (mono in a stereo container) stereo width (S/M): 0.375 vs 0.003 LUFS: -17.8 vs -17.2 (already fine) Two stacked causes: 1. INGEST: Demucs separated audio.wav — the 16 kHz MONO extraction made for ASR. The music bed therefore inherited mono AND an 8 kHz bandwidth ceiling at its source (Demucs upsamples to 44.1 kHz internally, so the stems LOOKED like 44.1k stereo files while carrying neither). Ingest now extracts a second full-quality file (44.1 kHz stereo, pcm_s16le) just for separation; ASR keeps its 16 kHz mono file; Demucs cost is ~unchanged (it resampled to 44.1 kHz internally either way). Best-effort: if the HQ extraction fails, separation falls back to the ASR file — exactly the old behavior. The stem-move path follows the input's basename. 2. MIX: amix negotiates ONE channel layout across inputs, and the synthesized voice is mono — so even a true-stereo bed was collapsed at the mix. bed_mix_filter now pins BOTH legs to stereo (aformat=channel_layouts=stereo); upmixing the mono voice duplicates it dead-center, which is where dubbed dialogue belongs anyway. Verified with real ffmpeg: the new graph preserves a stereo bed's width through the mix (and the ingest test pins that demucs receives audio_hq.wav with -ac 2 -ar 44100 while ASR keeps -ac 1 -ar 16000). Both tests fail with their half of the fix reverted. Full suite: 2989 passed. * fix(dub): pre-HQ stem caches are not reused (review) Greptile P1, real: the content-hash cache restores a previous job's stems for the same video and skips Demucs — so every video processed BEFORE the HQ-extraction change would keep its 16 kHz-mono-derived bed forever, and the fix would never apply to exactly the videos users re-upload to hear the difference. find_cached_job now requires the audio_hq.wav marker in the cached job dir; older candidates are skipped with a log line and separation reruns once at full quality. Regression test covers both directions of the gate. --------- Co-authored-by: mergetest <nizam4103@gmail.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> feat(dub): underrun fill — short dubbed lines are slowed toward their slot instead of leaving dead air (#1137) * feat(dub): underrun fill — short dubbed lines are slowed toward their slot instead of leaving dead air The dub pipeline has always handled audio that is too LONG for its slot (atempo compression, Smart Fit's audio/video split, trims). Audio that is too SHORT was start-aligned and abandoned — and that is the common case, not the corner: translations routinely speak faster than the source delivery. Measured on a real 4-segment dub, 8.8 of 18.7 seconds of original speech time had no dubbed voice. What fills those holes is the separated bed's under-speech residue (37% of the original energy, measured), so the user hears them as BOTH "little silences" AND "the music is numbed" — and sees them as lip-sync failure, since the mouth keeps moving after the dub stopped. The fill: when a line's natural duration covers less than UNDERRUN_TOLERANCE (95%) of its slot, slow it toward the slot with the same pitch-preserving atempo pipe the compression path uses, bounded at min_audio_rate (default 0.85x — comfortably natural; atempo handles <1 natively). Wired into both fitting strategies: - fit_planner._fit_one: need < 1 now resolves to audio_rate=max(need, floor), status "audio_slowed" — planner stays a pure function; golden fixtures regenerated per their own instructions (10 substantive lines: five underrun segments across four scenarios flip to audio_slowed@0.85). - dub_generate smart_fit branch: applies the rate in both directions (the target formula was already direction-agnostic). - dub_generate strict_slot branch: mirror of its compression arm. - stretch_video and concise strategies deliberately untouched (natural-rate by design / never-intervene by design). OMNIVOICE_UNDERRUN_MIN_RATE overrides the floor (1.0 disables; clamped to atempo's sane range). The per-segment fit badge shows "slowed N.NNx" with a tooltip, translated in all 21 locales. Tests: planner contracts (fill bounded by floor, tolerance zone untouched, disable switch, empty-audio guard), the flipped unit/golden/integration expectations updated with the rationale, and the existing smart_fit integration test now exercises the fill through the real mix loop (its seg0 comes out audio_slowed@0.85 end to end). Full suite: 2987 backend + 1236 frontend. * fix(dub): strict-slot slow-downs report themselves honestly (review); ru pitch wording Review round on #1137: - Greptile P1 "slowdown reports fits" — REAL: the strict_slot underrun fill fell through to the unconditional {"status": "fits"} entry, so a slowed segment's badge hid the applied rate (and compression_applied mislabeled it). The branch now emits {"status": "audio_slowed", "audio_rate": …} like the smart_fit path — same honesty contract everywhere. - Greptile P1 "padded audio hides underruns" — REFUTED with evidence: nothing pads strict-slot audio before the check (_load_entry_wav returns the natural-length WAV; only error/silence slots are slot-sized, and those are synthetic silence by design). On-disk segment WAVs measure both shorter and longer than their slots, which pre-padding would make impossible. - CodeRabbit: Russian tooltip now says "высота тона сохранена" (pitch), not "высота сохранена" (height). --------- Co-authored-by: mergetest <nizam4103@gmail.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> fix(dub): background bed no longer plays quiet and muffled — cancel amix normalization, mix at 48 kHz (#1136) * fix(dub): background bed no longer plays quiet and muffled — cancel amix normalization, mix at 48 kHz Reported live: "background music is so much not like the original." Two stacked fidelity bugs in every bed-mix site, measured with real ffmpeg on a real dub job: 1. LEVEL — ffmpeg's amix NORMALIZES its inputs, so the per-site weight strings meant "favor dialogue slightly" but actually played the music bed at ~57% of its original level (batch.py stacked an explicit volume=0.15 under the same normalization, leaving its bed near 8%). 2. BANDWIDTH — the voice track is synthesized at 24 kHz and amix negotiates one common rate, so the 44.1 kHz bed was silently downsampled to 24 kHz: everything above 12 kHz (cymbals, air, brightness) vanished. Six call sites carried six hand-rolled variants of the same filter string (dub_export x5, batch x1) with inconsistent input ordering — the same copy-divergence pattern that orphaned the clone-prompt cache (#1130). They now share one builder, services.ffmpeg_utils.bed_mix_filter(): both inputs resampled to 48 kHz before the mix, a compensating volume multiply that cancels amix's normalization exactly (the weights ARE the absolute gains: bed 0.9, voice 1.1), and a transparent peak limiter for the rare summed peak that full-scale mixing makes possible. Measured A/B on the reporting user's job (bed vs bed-through-mix, silent voice): 57% -> 90% of original level, 24 kHz -> 48 kHz output. The remaining -0.9 dB is deliberate dialogue headroom, one constant to change if policy shifts. Tests: the export command must carry the resample + compensation + limiter (fails on the old strings), builder label-uniqueness for multi-track graphs, and the existing export suites unchanged. * fix(dub): amix renormalizes when a stream ends — disable normalization instead of compensating for it Greptile P1 on this PR, confirmed real by measurement: amix's normalization is DYNAMIC — it rescales the remaining inputs whenever one ends. The previous commit cancelled it with a constant post-mix multiply, which is exact only while both streams are active; once the (even marginally shorter) voice track ends, the bed's internal scale jumps to 1.0 and the fixed multiply BOOSTS the tail music into the limiter. Measured on the real job with a deliberately short voice: bed at 90% while the voice runs, 189% after it ends. The original A/B used equal-length streams, which is why this never showed. Fix: amix normalize=0 (a plain sum) with per-input volume gains — levels are exact for the whole timeline regardless of stream lifetimes. Same measurement now: 90% / 90%. normalize= arrived in ffmpeg 5.x, and system-ffmpeg users can be older, where an unknown option rejects the whole graph (= no export at all). The builder probes `ffmpeg -h filter=amix` once per process and falls back to the compensated form on legacy builds — its tail quirk is the lesser evil next to a failed export, and every bundled/imageio tier ships 7.x. Tests: both paths pinned (normalize=0 + per-input gains on modern; the compensation multiply on legacy), probe monkeypatched per test. * test(dub): anchor the amix monkeypatches to the call chain — module aliases miss under random order --------- Co-authored-by: mergetest <nizam4103@gmail.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> fix(dub): a rate-limited polish pass no longer skips fitting, fails the UI, or ignores Retry-After (#1135) * fix(dub): a rate-limited polish pass no longer skips fitting, fails the UI, or ignores Retry-After Observed live (owner's Bengali dub, 4 segments): every cinematic reflect call 429'd against a free-tier OpenRouter model and the UI declared "4/4 segment(s) failed" over a translate that succeeded. Root-causing that surfaced a class, not a message bug: The cinematic reflect/adapt chain is OPTIONAL polish — on any failure the segment keeps its literal translation and is fully usable. But every such degradation (no-llm, reflect/adapt errors, adapt-diverged, wrong-script, cinematic-budget) was reported under the same "error" key as real translation failures. Three consumers took that at face value: 1. useDubWorkflow counted the rows as failed -> the red N/N toast; 2. _stamp_predicted_rate_ratio and _stamp_duration_plan skipped them -> no rate badges, no fits/tight/impossible verdicts; 3. _apply_fit_pass and the condense pass skipped them -> overlong lines went to synthesis unfitted and came out audibly time-compressed at mix. This is a direct contributor to "later segments got worse" in rate-limited Cinematic dubs. Split the vocabulary: "error" now means the row has no usable text (base translation failed); optional-pass fallbacks ride a separate "degraded" key. Downstream filters keep gating on "error" only, so degraded rows flow through every fitting pass. The UI shows an amber "translated, polish skipped (<reason>)" toast and a mild row tooltip instead of a red failure, and editing a row clears the stale annotation. And the retry that makes most of this moot: _chat now honors a 429's Retry-After once (capped at 30s, jittered so the 6-wide segment fan-out does not re-stampede the same window). OpenRouter's free pool says "Retry-After: 2" - giving up instantly turned a two-second wait into a whole failed pass. Tests: producer contract (every cinematic fallback returns degraded, never error - 5 updated + retained), consumer contract (degraded rows still get rate-ratio prediction and duration plans; error rows stay excluded), and the retry (honors small Retry-After with jitter, caps absurd ones, one retry only, non-429s never retry). Full suite: 2981 backend + 1236 frontend. * docs(changelog): correct PR ref to #1135 * fix(dub): review round — localize the degraded strings, un-suppress the mixed toast, clear stale annotations on edit Three review findings, all valid: - Localization parity (Greptile): the two new user-facing keys existed only in en.json. Every other key in these namespaces is translated in all 21 locales, so the fallback-to-English behavior would have been a regression of the repo's parity convention. Both keys now translated in all 20 non-en locales, inserted beside their siblings. - Mixed responses suppressed the degraded story (Greptile): when a translate returned both real failures and degraded rows, only the red failure toast fired. The degraded warning now fires alongside it — real failures don't erase what happened to the rows that succeeded plainly. - Ordinary edits kept stale annotations (CodeRabbit): the restore path cleared translate_error/translate_degraded but a normal text edit didn't, so a row kept wearing "polish pass skipped" over words the user had just written. Editing the text now clears both annotations. Frontend suite: 1236 passed; i18n probe green across all 21 locales. --------- Co-authored-by: mergetest <nizam4103@gmail.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> perf(dub): single-use per-segment refs no longer evict the prompts a dub reuses; add docs/performance.md (#1132) * perf(dub): single-use per-segment refs no longer evict the prompts a dub reuses; add docs/performance.md The scan-resistance fix: A dub cuts a distinct reference clip per segment (Wave 3.2 / #486 — each line clones its own source delivery) and falls back to the per-speaker clone for segments under 3 s. Both paths flow through the voice-clone prompt cache — an LRU of 8. Streaming hundreds of one-shot per-segment clips through that LRU evicts the per-speaker and locked-profile prompts that every fallback segment reuses, so the speaker ref was re-encoded (~0.4 s each, measured with scripts/bench_pipeline.py) again and again across the render. Note what this deliberately does NOT do: the bench's "166 misses vs 2 speakers" framing suggested keying refs per speaker — but per-segment refs are the intentional prosody-matching feature, and the re-transcription behind them is the #1004 correctness fix. Their encode cost is the price of the feature, not waste. The waste was only the eviction side-effect, and that's what this removes: _get_clone_prompt(store=False) still reads the cache (a hit is free) but never inserts, and the dub loop marks exactly the segment-scoped refs (auto-seg: bindings and auto: bindings resolved to a segment clip) as single-use. Per-speaker, locked-profile, and preview refs cache as before. cache_ref is popped in generate_with_cached_ref before the model call — the model's generate() has an explicit signature and would TypeError — and unknown engines ignore it (**kw adapters). The doc: docs/performance.md is the first performance documentation in the repo — none of the ~15 perf env vars appeared anywhere in docs/, the Performance panel's only control is Windows-only, and slowness reports (#1032) arrived as mysteries instead of settings checks. Covers the three classic causes of "it got slow", where generation/dub time goes, every knob with defaults and warnings (raising OMNIVOICE_GPU_WORKERS on a small GPU is the #567 crash, not a speedup), platform notes, and how to run the bench so reports carry numbers. Linked from README's install section. Tests: store=False semantics (encodes, never inserts, still reads), the flood scenario end to end (a speaker prompt stays warm through 3x the cache cap of one-shots), and the pop contract (cache_ref never reaches the model). Full suite: 2974 passed. * docs,dub: review round — qualify the per-file cache claim; note the OOM-retry tradeoff - CodeRabbit: docs/performance.md's "the reference encode is cached per file" now carves out the dub's per-line clips (single-use by design — nothing for a cache to save). - Greptile P2 (OOM retry re-encodes a single-use ref): acknowledged in a code comment as deliberate — caching the retry's ref would reintroduce the eviction this flag prevents, to optimize a path that only runs after an OOM already cost seconds. * docs(performance): probe-based torch.compile wording; honest accelerator + cache claims (review) Greptile's repeated OOM-retry finding is deliberately skipped: retaining the prompt across the retry would require passing prompt objects through the adapter protocol (backend.generate takes paths), to save 0.4s on a path that only runs after an OOM already cost seconds — the tradeoff is documented at the call site. --------- Co-authored-by: mergetest <nizam4103@gmail.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> fix(engines): an Install click during the mount status probe was silently dropped (#1131) * fix(engines): an Install click during the mount status probe was silently dropped refreshInstall serializes status requests per engine so a slow backend can't land responses out of order (an old 'running' overwriting a newer 'succeeded' would restart the poller forever). But the guard dropped ALL overlapping callers, including the one that must never be dropped: the Install click's first status refresh. If the click landed while the mount-time re-attach probe still held the slot, the refresh returned null, the state kept the pre-install 'idle' snapshot, the 1.5s poller (which only watches 'running' jobs) never started, and the progress panel never appeared — no error, no retry. The backend install DID start; the UI just never showed it. Fast machines win the race almost every time, which is why this surfaced as a rare CI-only failure of "a failed job renders the error with its remediation and offers Retry" (observed on #1130's run, a PR with zero frontend changes). The inflight guard now maps id -> the in-flight promise; advisory callers (the poller, the probe) still drop on overlap, but the click passes force: true and waits the in-flight request out before fetching its own fresh snapshot — strictly ordered, never dropped. The regression test holds the mount probe open with a gated promise, clicks during the window, and only then releases the probe — deterministic where the CI flake was scheduler-luck. Fails before the fix (panel never renders, 3s timeout), passes after. Full frontend suite: 1234 passed. * fix(engines): bound the forced wait, serialize rapid clicks, reject stale responses by epoch Round-2 review findings on #1131, both real: - Greptile P1 "forced waiters break serialization": two rapid Install clicks waking from the SAME awaited probe both proceeded without re-checking the slot — two concurrent requests, out-of-order responses possible again. The forced wait is now a loop that re-checks the map after every await. - Greptile P1 "install inherits probe stall": a wedged probe (no abort signal) made the forced click wait forever — trading "silently dropped" for "silently stuck". The wait is now bounded (FORCE_WAIT_TIMEOUT_MS, 5s), and a per-engine request EPOCH makes proceeding safe: a response may only be applied if no newer request started since it was issued, so the wedged request's eventual stale response is discarded instead of clobbering the fresh 'running' snapshot. The epoch is now the actual ordering guarantee; the inflight slot is just throttling. - CodeQL js/missing-await on `=== req`: intentional promise-identity compare, restructured to compare a plain { promise } wrapper object so the alert class can't fire. Both new tests fail against the round-1 fix (maxActive=2; panel never appears after a 5s fake-timer advance) and pass now. Frontend suite: 1236 passed. * fix(engines): clear the losing race leg's 5s timer (review) --------- Co-authored-by: mergetest <nizam4103@gmail.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> perf(tts,dub): the reference clip was re-encoded on every chunk; the dub loaded a 3 GB model to throw it away (#1130) * perf(tts,dub): the reference clip was re-encoded on every chunk; the dub loaded a 3 GB model to throw it away Two independent pieces of pure waste on the generate path, both measured with scripts/bench_pipeline.py on a 16 GB M2 (a reference encode costs 0.40s). 1. The voice-clone prompt cache was built, then orphaned. #427/#473 added a bounded LRU that encodes a reference clip once and reuses it, because "every cloned generation re-encodes the reference audio from scratch". It was wired into OmniVoiceBackend — the *adapter* path. But /generate for the default engine forks to the *native* model path (that fork predates the cache, #324) and passed ref_audio=<path> straight through, so the codec encoder re-ran the reference on every model.generate() call: once per text chunk, once per pause-span, once per audiobook segment, and once per request. That perf PR has therefore only ever sped up /v1/audio/speech. The Generate button never touched it. Every native call site now goes through one helper (generate_with_cached_ref) so the rule lives in a single place: chunked /generate, its streaming twin (#1088), the [pause] stitcher (#276), and the audiobook renderer. Saving is 0.40s x (calls - 1): ~3.6s on a 10-chunk text, ~66s on a 166-segment audiobook. Same-class bug found in the same cache: /v1/audio/speech accepts preprocess_prompt, but the adapter dropped it before it reached the model AND the cache key omitted it — so the flag was silently ignored, and honoring it without keying on it would have served (and poisoned) the wrong prompt. Both fixed together. 2. A dub loaded the TTS core just to free it again. The transcribe preflight called get_model() — pulling in the ~3 GB TTS model — for one reason: to read a preloaded `_asr_pipe` off it. That attribute only exists under OMNIVOICE_PRELOAD_TTS_ASR, which is off by default. So every dub loaded the model, harvested None, had offload_tts_for_asr() free it 60 lines later (on unified memory that is a full UNLOAD, #1119), and then cold-reloaded the same model in dub_generate (~8s). Load -> unload -> reload, for an attribute that was always None. It now loads only when there is something to harvest. Also fixes a latent NameError: asr_on_vocals was assigned only inside the model-loaded branch but read from _gen_body, so an early preflight bail raised NameError instead of the real error. Tests: the existing cache tests passed the whole time the cache was dead, because they test the cache in isolation with a stub model. The new tests assert the wiring instead — that a real render encodes the reference ONCE regardless of how many generate calls it takes. All four encode-count tests fail before this change and pass after; the dub tests likewise. * docs(changelog): the reference re-encode and the dub's throwaway model load (#1130) * fix(memory): unloading the TTS model must drop its cached reference prompts too Follow-on to the cache wiring in this PR, and a real gap it opened. clear_clone_prompt_cache() was called from exactly one place: OmniVoiceBackend.unload(). That was sufficient while the prompt cache was adapter-only — but the native /generate path now populates it, and the native path unloads through model_manager (idle_worker, _offload_unified_memory), not through the adapter. So cached prompt tensors would have survived an unload. That directly undercuts #1119: on unified memory offload_tts_for_asr() sets model = None precisely to hand the RAM to the ASR model. Prompts left behind sit in the memory the unload was trying to reclaim. The tensors are small (integer codes, not waveforms), so this is hygiene rather than a leak — but "unload means unload" is the whole point of that change, and the next thing cached here might not be small. model_manager.release_tts_side_caches() is now called wherever the global model is dropped. Best-effort by construction: cache hygiene must never be able to break an unload, because a failed unload is how the backend gets OOM-killed. The test binds services.tts_backend at CALL time, not import time: several suites purge sys.modules["services.*"] for DB isolation (test_model_load_timeout, test_model_manager_preload), so a module-level alias goes stale mid-run and the assertion would inspect a different module's cache than the code under test just filled. Production already imports it at call time. Full suite: 2968 passed, in both deterministic and random order. * fix(tts): keep the prompt cache best-effort, and stop the unload hook closing an import cycle Three review findings, all real. 1. Greptile P1 — the shared helper dropped the inline fallback. OmniVoiceBackend.generate() has always caught a failure from generate(voice_clone_prompt=...) and retried with the inline ref, so the cache stays a pure latency optimization. generate_with_cached_ref did not: a model that rejected a precomputed prompt would have turned a working /generate, streaming render, or audiobook job into a hard error. Moving the native path onto the cache would then have made it LESS robust than before it was cached at all. The helper now carries that fallback, and OmniVoiceBackend delegates to it instead of keeping a second copy. Two subtly-diverging copies of this logic is precisely how the cache ended up wired into the adapter and nowhere else; there is now exactly one. 2. CodeQL — cyclic import. release_tts_side_caches() imported services.tts_backend, which already imports model_manager: a real cycle, not a false positive. A registration hook fixed the cycle but replaced it with a worse problem — the hook runs at import time and pulls model_manager (and core.config) in earlier than before, which perturbs DATA_DIR binding and broke test_longform_jobs::test_route_handler_returns_jobs_envelope in the full suite (passed in isolation, failed in order — caught locally, not in CI). It now reaches the module through sys.modules instead: no import, no cycle, no import-time side effect. And it is the more correct expression of the invariant anyway — a module that was never imported has no cache to clear. 3. CodeRabbit — the audiobook and streaming call sites had no encode-count test. Added one for the audiobook synth path (the worst case: hundreds of segments on one voice). New tests fail before their respective fixes: stripping the try/except from the helper fails the prompt-rejection test. Full suite: 2970 passed, deterministic and random order. --------- Co-authored-by: mergetest <nizam4103@gmail.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> perf(bench): a memory-safe profiler for the pipeline — so "make it faster" stops being a guess (#1129) Every performance question this week ("can we batch by cores?", "why is dubbing slow?") was answerable only by measuring, and twice the intuitive answer was wrong: * Concurrency on Apple Silicon buys NOTHING. Measured, 4 segments: 1 worker 19.3s | 2 workers 20.7s (0.93x) | 3 workers 19.2s (1.00x) One GPU, already saturated — extra workers interleave. Scaling the GPU pool by free RAM (the "intelligent batching" that sounds obviously right) would have added OOM risk on a 16 GB box for zero throughput. _pick_gpu_workers()'s hardcoded `MPS -> 1` is correct, and now provably so. * The clone-prompt cache misses on every segment (a dub writes one reference per segment: 166 distinct keys, cache can never hit). That looked like the dub's hidden cost. It is 0.40s/segment — ~2% — and it is not even waste: each reference is genuinely different audio, and encoding it is the *feature* (per-line prosody). Dropping to per-speaker refs would save ~65s/dub and cost quality. Not a free win; not taken. What actually dominates is TTS itself, which scales with text length (3.2s for a short line, 8.7s for a 2.5x longer one) and is GPU-bound on a GPU that one inference already fills. The profiler is deliberately gentle with memory, because a profiler that OOMs the machine reproduces the very bug class it exists to fix (#1119): stages run one at a time, models are unloaded between them, a stage is SKIPPED if free RAM is under the floor rather than starting a load the OS would kill, and each measurement is a fixed small number of passes — no looping to convergence. Co-authored-by: mergetest <nizam4103@gmail.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> fix(asr): Apple Silicon dubbing ran Whisper on the CPU — the ~4x dub slowdown, the abandoned chunks, and the fictional ETA (#1128) * fix(asr): Apple Silicon dubbing ran Whisper on the CPU — pick the engine by hardware _auto_detect() probed WhisperX first, unconditionally, with no device check. WhisperX is always installed, so it always won — and WhisperX (like faster-whisper) is CTranslate2, which has NO Metal backend. On every Mac, dub transcription therefore ran whisper-large-v3 on the CPU while the GPU sat idle. The MPS branch below it was unreachable in practice. Measured on an M2, one 30 s dub chunk of large-v3: WhisperX (CPU) 90.4 s <- 3x SLOWER than realtime MLX (GPU) 20.5 s MLX (GPU) + forced align 20.3 s <- ~4.4x faster, identical word timings That is a 16-minute video taking ~48 minutes and looking like a hang. It is also why the slowest chunks exceeded OMNIVOICE_TRANSCRIBE_CHUNK_TIMEOUT_S (120 s) and were ABANDONED after 2 attempts (chunks 33/34 in the reported run), losing transcript outright — while #730's advice blamed a "VRAM-starved GPU" on a machine with no VRAM. The engine pick is now device-aware: Apple Silicon gets MLX, everywhere else is unchanged (on CUDA, WhisperX already uses the GPU and remains the default). Crucially this does NOT buy speed with lip-sync accuracy. WhisperX's value is its wav2vec2 forced alignment (±10-30 ms word boundaries vs Whisper's ±100-300 ms), and dub lip-sync depends on it. Alignment takes *plain segments*, so it is independent of whichever engine produced them: it is extracted into forced_align() and layered on MLX's GPU transcript. Verified — the boundaries agree with WhisperX's (multiple: 0.62 vs 0.71; different: 1.57 vs 1.55) and every word carries timing. Alignment prefers MPS (20.3 s vs 28.4 s, byte-identical timings) and falls back to CPU rather than silently dropping to loose timestamps. Also fixes the ETA, which was pure fiction: TranscribeOverlay estimated `ceil(duration/60)*3 + 8` seconds — an assumption of ~20x-realtime transcription. For a 16-minute video it predicted 56 s against a real ~48 min, then clamped to "~0s remaining" with the bar frozen at 95% for the rest of the job. The backend already streams a real progress fraction (dub_core.py emits `progress` on every `segments` event) and the UI simply ignored it. It now extrapolates from the observed rate, and shows nothing until it has a rate to extrapolate from. * style: oxfmt Formatting only — no behaviour change. CI's format:check gate (not run locally before the push) rejected the two new/edited dub files. --------- Co-authored-by: mergetest <nizam4103@gmail.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> fix(analytics): the backend never received the token — its half was dead in every build (#1126) core/analytics.py reads POSTHOG_PROJECT_TOKEN from its own environment at RUNTIME, but the backend runs on the *user's* machine, where nothing sets it. So in a shipped build token_configured() was false forever: every backend event — including the speech_generated capture — was silently dropped, no matter what secret CI held. Only the frontend half ever worked, and nothing would have told us. The token is really a build input. release.yml already passes the POSTHOG_PROJECT_TOKEN secret to the tauri-action step as VITE_POSTHOG_KEY, and that step compiles the Rust shell as well as the frontend bundle — so option_env! bakes it into the shell on exactly the builds that ship it, and spawn_backend() hands it to the child process. The guarantees are unchanged and now pinned by tests: - no token baked in (every source build) => nothing passed => no destination => the backend cannot transmit, and the toggle isn't offered; - a real process env var still wins, so a dev can point a local run at their own project; - consent remains a separate gate (prefs, default off) — a destination alone sends nothing. Hardened against silent recurrence, since this failure mode is invisible: build.rs gets rerun-if-env-changed (option_env! is compile-time, so a cached build would otherwise keep the token it first saw), and tests assert the whole chain — release.yml still passes the secret, backend.rs still bakes it, build.rs still busts the cache. Also fixes a CHANGELOG contradiction that would have shipped in the release notes: the Usage-panel entry still claimed PostHog "was proposed and rejected ... there is no analytics service, no token", directly under two entries announcing it. Co-authored-by: mergetest <nizam4103@gmail.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> feat(analytics): wire posthog-js — consent-gated, autocapture OFF (#1123) * feat(analytics): wire posthog-js — consent-gated, autocapture OFF The owner supplied the standard snippet: posthog.init(TOKEN, { api_host, defaults: '2026-05-30' }) Shipping that verbatim would have broken the guarantee we just made, twice: 1. It initialises AT MODULE LOAD — it starts tracking every user before they have consented to anything. The README now says "OmniVoice sends nothing out of the box"; this would have made that false on the very next release. Analytics is therefore started ONLY after the user opts in (Settings → Privacy), and the stored consent is what restores it at launch. 2. posthog-js AUTOCAPTURES by default, and `defaults: '2026-05-30'` turns that on. Autocapture sends the text content of the DOM elements a user interacts with. In THIS app the DOM holds the script they are about to synthesise, their voice names and their file names — exactly the content we promise never leaves the machine. It is explicitly disabled, along with session recording (which records the screen) and pageview capture. utils/analytics.ts: hardenedConfig() — autocapture false, disable_session_recording true, capture_pageview/pageleave false, mask_all_text + mask_all_element_attributes as defence in depth, and opt_out_capturing_by_default so init alone can never capture. Events pass sanitizeProps(), mirroring the backend allowlist: a key not on it is DROPPED and long strings refused, so a future caller cannot leak content by adding a field. Backend down / no consent / no destination → stays off. The token is taken from VITE_POSTHOG_KEY at BUILD time and is never committed — a token-shaped literal trips the secret scanner and is a bad habit regardless. release.yml injects it from a repo secret; the backend already reads POSTHOG_PROJECT_TOKEN the same way. No token => no destination => the Privacy toggle isn't offered and nothing can be sent, which is the right default for a source build. A test fails if a phc_ literal is ever committed to that file. posthog-js added to frontend/package.json; root bun.lock regenerated and `bun install --frozen-lockfile` verified (the Docker gate). 11 tests: autocapture/session-recording/pageview off, starts opted-out, allowlist drops text+paths+names, long strings refused, consent honoured in all three failure directions, and no token literal in source. Frontend suite 1229 passed. * test(analytics): guard the committed-token rule in the suite, not just in the scanner The frontend typecheck failed on the guard I added: it reached for `node:fs`, which has no type definitions in the frontend tsconfig (and would have been cwd-dependent at runtime anyway). Wrong layer. Source-scanning guards in this repo are Python tests (test_no_hardcoded_cjk, test_no_literal_borders), so this one moves there — and gets strictly stronger in the process: it scans every tracked file rather than analytics.ts alone, and matches a PostHog key by SHAPE (phc_[A-Za-z0-9]{20,}), so a *different* key can't slip through where the old test only knew about the one gitleaks caught. --------- Co-authored-by: mergetest <nizam4103@gmail.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> fix(memory): on unified memory, "offload" must mean UNLOAD — the 16 GB dub OOM (#1119) (#1122) offload_tts_for_asr() exists to make room before WhisperX large-v3 (~3 GB) loads for a dub. On CUDA it moves the TTS model to CPU. On Apple Silicon it did NOTHING — an early return with the comment "MPS / CPU / DirectML don't benefit from manual offloading". That reasoning is right about the STRATEGY and wrong about the CONCLUSION. On unified memory, moving a model "to CPU" frees nothing, because it is the same physical RAM. But that means the fix is to RELEASE the model — not to skip making room altogether. Measured on a 16 GB M2, at the moment a dub begins: TTS model resident 3,107 MB backend footprint 4,170 MB free RAM 4.17 GB large-v3 then wants ~3 GB of that, alongside the app and macOS. The OS kills the backend mid-transcription, and the stream "drops before emitting any segments". On a unified-memory host the TTS model is now actually released when free RAM is below a headroom threshold (default 6 GB, OMNIVOICE_UNIFIED_OFFLOAD_HEADROOM_GB), and left warm when there's room — so a roomy machine pays no reload. get_model() lazily reloads it on the next generation, so restore is correctly a no-op. The CUDA path is untouched. Verified end to end in a real process: model loaded → offload_tts_for_asr() → `mm.model is None` and free RAM recovered. Previously it returned immediately and freed nothing. 6 tests (releases when tight / stays warm when roomy / restore is a no-op / no model is a no-op / a failing probe never aborts the dub / CUDA path unchanged). This is a CAUSE, not another error-message fix. Refs #1119 #1113 Co-authored-by: mergetest <nizam4103@gmail.com> fix(dub): stop losing the race for the crash marker — the stream-drop guess is back (#1119) (#1121) Reported on v0.3.21, which ALREADY had the #1098 fix. The user still got "Transcribe stream dropped… Likely ASR backend failed to load" — the guess that fix was supposed to retire. Why: streamDropError() consults the crash marker before falling back to the guess, but it asked exactly ONCE, at the instant the stream dropped. The shell learns of a dead backend from a ~2 s poll — it must notice the child exit and write the marker. So the check raced that poll and lost: no marker yet ⇒ "no crash" ⇒ fall back to the guess, even when the backend had just died. That is precisely the race #1102 fixed for apiFetch. This path never got it — I fixed the symptom in one place and left the identical bug in the other. streamDropError now polls for the marker across a short window (8 s, 1 s apart) before believing there was no crash, so a late-arriving marker is found and the user gets the real cause — exit code + captured stderr, one click from the crash notice — instead of a guess. Outside the Tauri shell there is no marker to wait for, so it asks once and returns immediately (no 8 s stall for a browser/Docker user). Injectable sleep/clock so the race is directly unit-testable. 3 new tests (a LATE marker is found, not missed / no marker ever still yields the caller message / no shell asks exactly once). Frontend suite 1218 passed. Fixes #1119 Co-authored-by: mergetest <nizam4103@gmail.com> feat(privacy): opt-in analytics — hardened, off by default, enforced by code (#1120) The rejected PR #1110 had a genuinely careful PII-free event design, but shipped three things a local-first app can't: exception autocapture ON (raw tracebacks — home paths, and in this codebase HF tokens out of exception messages — bypassing core.failure.sanitize() entirely), no user consent or disclosure, and 3,069 lines of PostHog wizard scaffolding. This is the same capability with those fixed. core/analytics.py, three rules, each enforced and tested rather than promised: 1. OFF unless the user says yes. TWO gates must both be true: a build-provided POSTHOG_PROJECT_TOKEN *and* the user's analytics_enabled pref, default False. A default install transmits nothing, so "nothing leaves your machine" stays literally true for everyone who doesn't opt in. A broken prefs file fails CLOSED. OMNIVOICE_ANALYTICS_DISABLED=1 is a hard kill switch above both. Withdrawing consent tears the client down immediately — no restart. 2. NO exception autocapture. Explicitly disabled; a test asserts the constructor arg, because the SDK's default is the leak. 3. Metadata ONLY, by allowlist. Every property passes sanitize_properties(), which DROPS any key not on _ALLOWED_PROPS and refuses long strings — so no future caller can leak a take's text, a path, or a voice name by adding a field. text_length is the LENGTH; the text itself has no way through. The person id is a random per-install UUID — not hardware, hostname, or username. UI: Settings → Privacy → "Help improve OmniVoice" states in the panel exactly what is sent, exactly what never is, and that it can be turned off — rather than burying it in a policy. No destination in the build (any source build) → the toggle isn't shown, because an inert switch would be a lie. Docs: README FAQ answers "does OmniVoice collect any data about me?" honestly. Also fixed a bug I'd introduced in my own wiring: the generation event referenced variables not in scope, and the call site's bare `except: pass` swallowed the NameError — so the event would have silently never fired. The call site now logs. 12 tests (default-off / opt-in without token still can't transmit / both gates / kill switch / consent withdrawal / prefs failure fails closed / allowlist drops text+paths+names / long strings refused / autocapture OFF / never raises / random install id). Backend 2936 passed; frontend 1211 passed. Refs #1110 Co-authored-by: mergetest <nizam4103@gmail.com> fix(api): an alive-but-unresponsive backend now says so, instead of "it stopped" (#1113) (#1117) A v0.3.21 user hit "Can't reach the local OmniVoice backend — it may still be starting up, or it stopped" — on the release that was supposed to end that class. Reading the report tells us what happened WITHOUT reproducing it: they got the generic message, not the crash story. On 0.3.21 apiFetch consults the crash marker, and a real process death always writes one. No marker ⇒ the backend did not die. And the shell was still reporting `ready` ⇒ the process was alive. So both halves of that sentence were false: it had not stopped, and it was not starting. It was ALIVE and not answering — a job wedged holding the engine (troubleshooting §14: a generate/transcribe too heavy for the available memory starves the worker). Telling that user to "restart the app" is the wrong advice for a stuck job, and it buries the real cause. When the reconcile window expires and the shell STILL says `ready`, we now know the process is running, so say that: name the wedged-job cause, point at Settings → Logs → Backend for what it was last doing, and at a smaller model/engine as the usual fix. The genuine "stopped or starting" message stays for the case where the shell has no idea (no shell — browser/Docker), and the crash story still wins whenever a marker exists. This does not claim to stop the wedge — it stops the app from lying about it, and gives the next reporter the right words. 2 new tests; frontend suite 1213 passed. Refs #1113 Co-authored-by: mergetest <nizam4103@gmail.com> fix(bootstrap): stop clobbering the real failure reason with "never started" (#1112) (#1116) An Intel-Mac user got "Backend process exited (never started) — no error output captured", and reported that Retry and Clean & Retry did nothing at all. Both symptoms have one cause, and it destroys EVERY precise bootstrap diagnosis — not just the Intel one. ensure_venv_ready() diagnoses the real reason a start failed (Intel Macs can't run the backend — PyTorch ships no macOS x86_64 wheels, #889; a failed uv sync; a blocked GitHub) and records it via fail() as Failed{that reason}. It then returns None, spawn_backend returns None, and spawn_backend_and_wait — seeing no child — OVERWROTE the stage with the generic "Backend process exited (never started) — no error output captured". The honest cause was written and immediately bulldozed. Which also explains the dead buttons: the UI's hint matcher keys off the specific message text, so with it gone the Intel hint ("retrying can never help") never fired. The user was offered a Retry that re-failed identically every time, looking like the button did nothing. - bootstrap.rs: already_diagnosed() — a caller that knows the CAUSE outranks one that only knows the SYMPTOM. When the stage is already Failed, the spawn watcher keeps it. A real exec failure still forms the generic message (it writes its diagnostic to backend_err.log and leaves the stage un-Failed), and a genuine post-start crash is untouched. - BootstrapSplash: isUnrecoverableFailure() — an Intel Mac can never be retried into working, so don't offer the dead end; say so instead. Keyed off the same hint the matcher produces, so the two can't drift. 3 Rust tests + 2 frontend tests. Rust 81 passed; frontend 1213 passed. Fixes #1112 Co-authored-by: mergetest <nizam4103@gmail.com> feat(firstrun): show the app version beside the app name on all three first-run screens (#1115) The version was already in the Models & Engines masthead, but on Setup and Installing it was buried in a footer as "OVS · v0.3.x" — the two screens a user is most likely to screenshot when something goes wrong during install. Move it up beside the app name on both, so all three acts of the first run (setup → installing → models & engines) carry the same masthead and any screenshot identifies the build at a glance. The footers keep their real content (the download total on Setup); the duplicate version line is gone. Frontend suite 1211 passed (incl. the css-token guard, which is what catches a token that doesn't exist and silently renders nothing). Co-authored-by: mergetest <nizam4103@gmail.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> feat(privacy): Settings → Usage — local-only insights instead of cloud analytics (#1114) * feat(privacy): Settings → Usage — local-only insights, the answer to cloud analytics A PostHog integration was proposed and rejected (PR #1110, closed): sending usage events to a third-party endpoint would break the one promise this product is built on — nothing leaves your machine — and local-first is the reason people choose it over ElevenLabs. But the question analytics was meant to answer ("how am I using this?") is a fair one, so answer it locally. services/local_stats.py aggregates the history the app has ALREADY written to the user's own SQLite DB: takes, audio produced, compute time, starred, active days, voices/dubs/projects/exports, and distributions by mode and language. GET /stats/usage serves it over loopback; Settings → Usage renders it. The three properties that stop this becoming telemetry by accident: - READ-ONLY. No new table, column, or event stream. Delete the feature and not one byte of stored data changes. - NO CONTENT. Counts and totals only — the `text` column of a take is never read and never returned; no paths, no ids, no person. Pinned by a test that asserts the payload contains no take text, no /Users/ path, no row id. - NO NETWORK. There is no client, no endpoint, no token. It has no way to send anything anywhere. The panel states the guarantee in the UI, because a privacy promise the user can't see isn't worth much. Route added to the API-surface snapshot (the inventory guard caught it, as designed — one line: GET /stats/usage). 4 backend tests (aggregation / never-leaks-content / empty install / missing table degrades to 0) + 4 frontend tests. Backend suite 2924 passed; lint, format, typecheck clean. Closes the analytics question opened by #1110. * fix(settings): use the real --chrome-fg-dim token in UsageTab (css-token guard) cssTokens.test.js is a frontend guard that every var(--…) a component references actually exists — an undefined custom property with no fallback is an invalid declaration, so the style silently does nothing. UsageTab referenced --chrome-fg-subtle, which doesn't exist; the dim sub-label token is --chrome-fg-dim (what the other settings panels use). My miss: I ran the full BACKEND suite but only the two new frontend test files, so this guard never ran locally. Full frontend suite now green (1211 passed). --------- Co-authored-by: mergetest <nizam4103@gmail.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> feat(memory): honest /model/loaded accounting + a free-memory budget probe (#1111) Two gaps the model-management investigation surfaced, now closed. 1. /model/loaded reported only the OmniVoice core, so a resident second engine (mlx-audio, cosyvoice, …) and the warm dictation ASR were INVISIBLE — the memory picture looked ~2 GB lighter than reality on exactly the boxes that OOM. list_loaded() now enumerates the in-process engine instances (from the generate path's cache) and the capture ASR singleton too, and adds a `system` block: free/total RAM (and free VRAM on a dedicated GPU) plus a low-memory advisory. Verified live: after an mlx-audio generate the panel shows `engine:mlx-audio` and `system: {ram_available_gb, ram_total_gb}`, where before it showed nothing. 2. services/memory_budget.py: available_memory() reads FREE memory now (device caps only reports total, once per process) — free system RAM via psutil, free VRAM via torch.cuda.mem_get_info on a dedicated GPU; on MPS the RAM figure is what matters (unified memory). low_memory_warning() returns an advisory below a headroom threshold (OMNIVOICE_LOW_MEMORY_HEADROOM_GB, default 2). The generate path calls log_if_low() before a load, so a later OOM kill leaves a breadcrumb pointing at the load that tipped it instead of a silent death. Advisory only — nothing is blocked: the OS reclaims cache, and refusing a load on an estimate would brick machines that would cope. The single-active-engine eviction (#1105) is what actually reclaims room; this makes the picture honest and leaves forensics. 6 new unit tests (threshold logic / VRAM-precedence / never-raises); frontend LoadedModelsResponse typed for the new `system` field + id shapes. Backend suite 2918 passed; typecheck clean. Co-authored-by: mergetest <nizam4103@gmail.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> fix(uninstall,storage): remove the saved-env leftover; count sidecar engines in disk usage (#1108) Two recon findings from the reset work, fixed properly (whole class + tests + docs), plus the destructive reset path is now exercised end-to-end. 1. ~/.config/omnivoice/env survived every uninstall. The app persists the model-cache location (and a possible HF_TOKEN) there via backend/core/user_env.py, but the in-app "Remove all data" (uninstall.rs), uninstall.sh, and uninstall.ps1 all walked past it — so a reinstall silently inherited the old file and redirected downloads to a maybe-deleted location. All three now remove it. It's the same expanduser("~/.config/omnivoice/env") path on every OS, so the Windows script uses %USERPROFILE%\.config\omnivoice. is_recognizably_ours accepts it (contains "omnivoice"); docs tables updated. 2. Disk usage measured the wrong engines dir. storage_report.default_engines_dir() returned backend/engines (built-in engine *modules*, no venvs), while sidecar installs live in DATA_DIR/engines/<id>. So a multi-GB IndexTTS-2 install was invisible in the engine-venv category and rolled into data/"other". Now points at DATA_DIR/engines and sizes the WHOLE install (venv + checkout + weights), with the data category claiming that subtree so it isn't double-counted. Reset hardening: extracted purge_scopes() as a pure fs function (no AppHandle), so the actual delete loop runs in tests against a real on-disk install tree — "everything" wipes the install but spares the venv/foreign temp/sibling folders, a settings reset keeps content+config+models, and a poisoned data_dir="$HOME" deletes NOTHING. This is the live drive-through of the destructive path, minus the GUI. Also: gitignore the node_modules symlink form (the directory rule node_modules/ never matched a worktree symlink, so it kept slipping into commits). Tests: Rust 78 (6 new), storage_report 20 (2 new incl. once-not-twice count + default-dir guard), frontend 1207, i18n probe green, format+lint clean. Co-authored-by: mergetest <nizam4103@gmail.com> release: freeze v0.3.21 — version bump, lockfiles, changelog (#1107) package.json (single source of truth) + the three toolchain mirrors to 0.3.21; Cargo.lock + uv.lock regenerated (version lines only). CHANGELOG [Unreleased] renamed to [0.3.21] — 2026-07-12, "the memory release", sections merged into house style (one Added, one Fixed). Ships the 16 GB OOM class fixes end to end: idle-release the dictation ASR (#1104) + one TTS engine resident at a time (#1105), plus the scoped Settings → Storage reset/uninstall pair (#1089/#1099/#1100) and the release- asset-split workflow fix (#1106) so this tag uploads to a single release. test_app_version.py lockstep: 6 passed. bun install --frozen-lockfile: clean. Co-authored-by: mergetest <nizam4103@gmail.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> feat(memory): one TTS engine resident at a time — stop stacking models on 16 GB (#1105) Measured on a 16 GB M2: a generate on omnivoice (~2.8 GB core) followed by a generate on mlx-audio left BOTH resident (footprint 3.9 → 4.3 GB) because the OmniVoice core lives in model_manager.model while every other engine lives in engines._ENGINE_INSTANCES — two caches that never coordinated, and the latter was never unloaded. That accumulation is a direct contributor to the memory pressure behind the "Can't reach the local backend" OOM deaths. - services/engine_memory.py: evict_other_tts_engines(keep_id) unloads every OTHER resident TTS engine before the incoming one loads — spans both stores (the OmniVoice core under its async lock, and the instance cache). No-op when nothing else is resident, so steady-state single-engine use pays nothing; only a real switch evicts. Default on; OMNIVOICE_SINGLE_ENGINE_RESIDENT=0 to keep several warm. Wired into the /generate path right after the engine resolves. - TTSBackend.unload() (the ABC default) now actually frees the held model: it clears _MODEL_ATTRS (_model/_tts) and empties the device cache. Every in-process engine but OmniVoice previously inherited a NO-OP unload(), so an engine switch dropped the instance ref but left its model for GC with the GPU cache un-emptied. One change fixes all of them and is future-proof. - FasterWhisperBackend.unload() cleared self._asr — an attribute it never assigns — so its model in self._model was never freed. Fixed. Live-verified: omnivoice → mlx-audio now DROPS footprint 2300 → 1541 MB (core evicted) instead of climbing to 4305 with both resident. 7 new unit tests (eviction spans both stores / keeps the active engine / no-op when disabled / a failing unload doesn't abort the sweep / ABC unload frees + is idempotent), order-independent. Backend suite green. Refs the 16 GB OOM class (#1076 #1092 #1093 #1101) Co-authored-by: mergetest <nizam4103@gmail.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> fix(release): attach uninstall scripts with gh release upload, not a 2nd softprops publish (#1106) v0.3.20 shipped with ONLY the Linux AppImage — the macOS dmg and Windows msi were missing from the published release. Root cause: the uninstall-scripts job (added in #1097) ran softprops/action-gh-release@v2 as a SECOND publish for the tag, which raced tauri-action's per-matrix draft and split the platform installers across two releases (a draft holding mac/windows, a published one holding linux + checksums + the scripts). The updater manifest split too — each release's latest.json covered only its half of the platforms. Fix: attach the scripts with `gh release upload <tag> … --clobber` (which adds assets to the EXISTING release and can never create a second one) instead of softprops. `needs: [build]` guarantees the release exists first; --clobber keeps a re-run idempotent. (v0.3.20 itself was already repaired by hand — the mac/windows bundles were re-attached from the draft, the latest.json manifests merged into one covering all 8 platform keys, and the stray draft deleted. This prevents recurrence.) Co-authored-by: mergetest <nizam4103@gmail.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> feat(settings): factory reset gets scopes — preferences, settings, assets, everything (#1100) * feat(settings): factory reset gets scopes — preferences, settings, assets, everything Factory reset did exactly one thing: clear localStorage. The only other option was "Remove all data", which deletes the Python env and quits. Between "forget my theme" and "wipe the machine" sat every reset a user actually needs — drop a corrupt model download, remove a wedged sidecar engine, put the settings back without losing a single voice — and none of them existed. Settings → Storage → "Reset & remove" now offers four tiers (UI preferences / all settings / downloaded assets & models / everything OmniVoice did) plus a per-scope checklist. Every scope shows its real on-disk size, and the number on the confirm button is exactly what gets freed. Why the shell and not the backend: a loaded model memory-maps its weights out of the HF cache (locked on Windows while mapped), and ensure_dirs() runs at import, so a backend cannot delete voices/ or outputs/ and still write to them. reset.rs stops the backend, deletes, and starts it again — and that restart is also the repair: the fresh process re-runs ensure_dirs() and alembic, so a removed database comes back empty rather than missing. retry_bootstrap's respawn path is extracted to bootstrap::respawn_backend so both callers share one implementation. Deliberate scope choices: - "Everything" stops short of the managed Python env, so a reset hands back a working app on the first-run screen. The env is the uninstaller's business. - A settings reset keeps the storage locations (config.json, the user env file). Clearing the model-cache pointer would strand gigabytes at a path the app no longer looks in — install shape is not a preference. - content deletes the DB with the media: rows without files is how you get a library full of broken entries. - The shared HF cache is flagged as shared only when it IS — computed, so Windows and portable installs (app-private cache) get no caveat they don't need. Safety: nothing is removed unless it sits inside a validated root — one carrying an OmniVoice-owned path component OR holding an OmniVoice signature file, which is what lets a custom data dir on an external volume be cleared while a mis-set data_dir: "/" is refused. Voices/projects/audio need the word typed. 9 Rust tests (guard, scope composition, shared-cache computation) + 14 frontend (planning purity, typed confirm, disk-vs-frontend split, shared warning). Border utilities follow the design guard (tests/test_no_literal_borders.py). * feat(settings): give the Storage panels a design — proportional sizes, live totals, real tokens "Remove all data" listed four folders as a flat run of text: a 7.5 GB model cache and a 391-byte config file rendered at identical visual weight, so the one thing worth seeing — where the space actually went — was the one thing you couldn't. And the 391 B folder said "0 KB", which reads as "nothing here". - New shared StorageTargetRow, used by BOTH destructive panels so they read as one system: icon, label, dimmed path (truncated, full text on hover), size, and a proportional bar showing that row's share of what will be freed. Unticked rows claim none of the bar — the bars must sum to what the button promises. - The shared HF cache moves OUT of the confirm dialog into its own "Optional" row with the checkbox and the caveat in the list. Ticking it now moves the running total in front of the user, instead of springing a different number on them at the point of no return. The dialog lists exactly what is going. - One byte formatter for both panels (settings/bytes.js). models/format.fmtBytes floors at kilobytes, hence "0 KB"; it stays where it is for the model store. Real fix underneath: three of the tokens these panels styled with DO NOT EXIST (--chrome-fg-subtle, --chrome-bg-raised, --color-warning). An undefined var() makes the declaration invalid, the browser drops it, and the element silently inherits — which is why the paths that were meant to recede rendered at full body weight. That is a whole class of bug that fails invisibly, so it gets a guard: src/test/cssTokens.test.js fails on any var(--token) in JSX not defined in a stylesheet, with runtime-injected tokens (Radix, inline-style hues) allowlisted by reason. Six pre-existing offenders elsewhere in the app are recorded as known-broken and ratcheted so the list can only shrink — they are real bugs, but each is a visual change that wants its own review. Frontend suite 1196 → 1205 (6 UninstallPanel component tests incl. the live total and the bar proportions; 3 token-guard tests, verified fail-before). * fix: green CI + finish the token sweep + snapshot the panels Three things on top of the redesign: 1. CI was red on tests/probe/test_probe_i18n.py — removing the eight dead `factory_reset_*` keys from en.json orphaned them in all 20 other locales (the probe forbids a non-en key absent from en). Removed them everywhere. This guard scans locales at pytest time; a frontend-only run never sees it. 2. Finished the undefined-token sweep instead of grandfathering it. Six bare `var(--token)` references resolved to nothing; the only genuinely undefined, fallback-less one in shipping panels was `--chrome-input-bg` (input fields AND progress-bar tracks AND skeletons across StoragePanel, StorageUsagePanel, HistoryRetentionPanel, ModelStoreTab — tracks were rendering with no background at all). Repointed to --chrome-hover-bg. The rest (--chrome-menu-bg, --chrome-bg-inset, --border, --input-bg, --muted) already carry `var(--x, fallback)`, which is valid CSS. So cssTokens.test.js now checks only the BARE form and ships with zero exceptions — no known-broken ratchet, because there is nothing left broken. 3. Registered both Storage panels in the visual-regression harness (a Tauri `invoke` stub added to providers.jsx alongside the existing fetch stub) and committed baselines across all three themes. This is how I actually looked at the redesign: the bars render proportional (the 720 KB voices row fills, the 391 B row is a sliver), the shared-cache row sits in its own Optional group, and every token now resolves in default/midnight/catppuccin. `_forceAdvanced` on ResetPanel opens the checklist for the snapshot; no effect on the toggle. Full backend suite 2897 passed (incl. the i18n probe). Frontend 1205. * style: oxfmt the new panels and specs Format-check is a CI gate (oxfmt --check); the new files weren't run through oxfmt --write. No behavior change. * chore: stop tracking the node_modules symlink A worktree-local symlink slipped past .gitignore (which lists node_modules/ — the directory form — so it never matched the symlink file). Removed from the index; the symlink stays on disk for local test runs. --------- Co-authored-by: mergetest <nizam4103@gmail.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> fix(memory): idle-release the dictation ASR — the real cause of the 16 GB OOM deaths (#1104) Four "Can't reach the local OmniVoice backend" reports (#1076/#1092/#1093/#1101) all died at the same moment: during a generate, on a 16 GB machine. Measuring it (phys_footprint, not RSS — RSS badly undercounts MPS unified memory) showed the generate was never the problem: it costs ~116 MB. The problem is the BASELINE — the backend sits at ~6.2 GB *idle*: TTS 3.8 GB plus ~2 GB of warm capture ASR. The TTS model has always been idle-unloaded (model_manager.idle_worker). The capture/dictation ASR singleton never was — one dictation warmed it and it stayed resident for the life of the process. So the app dutifully freed 3.8 GB of TTS while silently holding 2 GB of ASR forever, and on a 16 GB Mac that baseline plus the app plus macOS is enough for the OS to kill the backend mid-generate. That death surfaces as the "can't reach the backend" error — the class #1102 made honest and this fixes at the source. - asr_backend.release_idle_capture_backend(idle_s): unloads the warm capture singleton once it's been unused that long; no-op under a live lease, when nothing's loaded, or when recently used; never raises (idle_worker calls it on a loop). - capture_lease(): pins the singleton for a live dictation session's whole life (the stream holds the backend without re-resolving it, so the reaper must not unload the model mid-sentence); wrapped around both sherpa handlers in capture_ws. Releasing restarts the idle clock. - Both capture getters stamp _touch_capture() so any handout resets the clock. - idle_worker runs the reaper each tick with the same idle timeout the TTS model uses, then free_vram(). Cost: a ~1.4 s model re-warm on the next dictation after a full idle timeout — the same bargain the TTS model already makes. 8 new unit tests (releases when idle / never while leased / never when recently used / lease released on raise / nested refcount / failing unload still drops the ref / no-op when empty). Backend suite 2905 passed; verified live against the running backend. Fixes the crash class behind #1076 #1092 #1093 #1101 Co-authored-by: mergetest <nizam4103@gmail.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> release: freeze v0.3.20 — version bump, lockfiles, changelog (#1103) package.json (single source of truth) + the three toolchain mirrors to 0.3.20; Cargo.lock + uv.lock regenerated (version lines only). CHANGELOG [Unreleased] renamed to [0.3.20] — 2026-07-12 with the release headline, sections merged into house style (one Added, one Fixed). Ships the #1101 stale-"ready" race fix (0.3.19 users are hitting it today), the in-app uninstaller (#1099), and the Linux/Windows backend-log-dir fix. test_app_version.py lockstep: 6 passed. bun install --frozen-lockfile: clean. Co-authored-by: mergetest <nizam4103@gmail.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> fix(api): don't believe a stale "ready" — close the #1094 race that #1101 hit (#1102) A 0.3.19 user still got "Can't reach the local OmniVoice backend" (#1101), on the very release that was supposed to end that class. The fix had a hole. apiFetch asks the shell whether a start/restart is in progress before erroring. But the shell's stage is a 2-SECOND POLL, not a live probe: when the backend dies mid-generate, supervise_backend needs up to ~2 s to notice the child exit, write the crash marker, and set_stage(StartingBackend). apiFetch asked exactly ONCE, at the end of the ~2.9 s transport cascade — so it very often still read `Ready` and fell straight through to the generic toast. Worse, the crash marker usually wasn't written yet either, so even the honest crash story (#941) was missed and the user got the vague message. The bug was trusting `ready` as authoritative. A transport failure CONTRADICTS it: if the backend were reachable, the fetch would have succeeded. So `ready` is now treated as a STALE belief — we keep retrying across a bounded reconciliation window (12 s), re-asking each time, which lets the supervisor catch up and flip to `starting` (→ the long wait + the restarting banner) and gives the crash marker time to land so the error can name the real cause. `failed` (the shell gave up) and `unknown` (no shell — browser/Docker) still error immediately, so a genuinely dead backend is as prompt as before. The reporter's trace is the signature: generate:start (design) → generate:stream-fallback → the toast, on a 16 GB M1/MPS box — i.e. the backend process died under memory pressure during generation, which is the underlying crash this now surfaces honestly instead of guessing at. Regression test reproduces it against the shipped 0.3.19 logic (fails) and passes on the fix. Frontend 1184 passed; backend 2897 passed. Refs #1101 Co-authored-by: mergetest <nizam4103@gmail.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> feat(settings): in-app uninstall — Settings → Storage → Remove all data (#1089) (#1099) * feat(settings): in-app uninstall — Settings → Storage → "Remove all data" The v0.3.19 uninstaller was a SCRIPT, which never reaches the people who need it: anyone who installed the .dmg / .msi / AppImage has no repo to run scripts/uninstall.sh from — exactly the reporter in #1089, an AppImage user. "Where is uninstall in the app?" had no answer. Now it does. New Tauri commands (uninstall.rs): - uninstall_scan — every folder this install owns, with real sizes, resolved through the same setup.rs helpers the app itself uses, so custom + portable locations are cleaned instead of the defaults being assumed. - uninstall_purge — stops the backend (marking the kill intentional so the #567 supervisor doesn't respawn one into the directories being deleted), removes the folders, and lets the UI quit the app: the Python env it runs on is gone, so there is nothing to return to. This lives in the Rust shell, not the backend, because the biggest thing to remove is the managed Python environment and the backend is RUNNING FROM IT — a process can't delete its own interpreter (and Windows locks the files). Safety: every path must pass is_recognizably_ours() before any remove_dir_all — absolute, not `/` or $HOME, and carrying an OmniVoice-owned component (unit tested both ways). The shared Hugging Face cache is reported separately and is OPT-IN behind its own checkbox with the caveat spelled out: it's the standard HF cache other ML tools share, so sweeping it up silently would delete models this app never downloaded. Deleting voices/projects is irreversible, so the confirm requires TYPING the word, not just a click. Also fixes a real bug in what shipped in v0.3.19: the scripts and docs missed where the BACKEND writes its logs — ~/.local/state/OmniVoice on Linux and %LOCALAPPDATA%\OmniVoice\Logs on Windows (backend_log_path(), backend.rs) — so every Linux/Windows uninstall left a stray log dir behind. Covered now in the scripts, the docs, and the in-app scan. And the scripts now ship as release assets, so cleanup is possible without launching the app at all. Rust: 2 new guard tests. Frontend: 6 new tests (the size on the confirm button must equal what actually gets deleted); suite 1182 passed. Docs synced. Refs #1089 * fix(settings): drop token border utilities from UninstallPanel (design guard) tests/test_no_literal_borders.py::test_no_token_border_utilities_in_jsx is a backend guard that scans JSX — so a frontend-only test run misses it. It forbids `border-[var(--chrome-border)]` structural utilities: the app-wide border removal converted every panel/row frame away from them, and they render a stray hairline the moment the token doesn't resolve transparent. Row dividers → spacing + an alternating `--chrome-hover-bg` tint; the opt-in checkbox card → a background tint; the confirm input → the sanctioned arbitrary `[border:1px_solid_var(--chrome-border)]` property form the other settings inputs already use (explicitly not flagged by the guard). Guard green. --------- Co-authored-by: mergetest <nizam4103@gmail.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> release: freeze v0.3.19 — version bump, lockfiles, changelog (#1096) package.json (single source of truth) + the three toolchain mirrors (Cargo.toml, pyproject.toml, core/version.py) to 0.3.19; Cargo.lock + uv.lock regenerated (version lines only). CHANGELOG [Unreleased] renamed to [0.3.19] — 2026-07-12 with the release headline; all six entries carry their (#PR) refs and extract cleanly as the GitHub Release body. Rebuilt on current main so the freeze covers everything that landed after the first cut: #1088 (streaming preview), #1097 (uninstaller), #1098 (stream-drop honesty), plus the backfilled #1088 changelog entry. test_app_version.py lockstep: 6 passed. bun install --frozen-lockfile: clean. Co-authored-by: mergetest <nizam4103@gmail.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> fix(dub): a stream that dies with no terminal event tells the truth, not a guess (#1098) "Transcribe stream dropped before emitting any segments. Likely ASR backend failed to load — check backend log + Settings → Models." (#1062) is a GUESS, and usually the wrong one. The backend is contract-bound to emit a terminal event on every stream even when it fails (guarded by test_transcribe_stream_never_closes_without_terminal_event), so a stream that simply goes SILENT did not "probably fail to load a model" — the backend PROCESS went away underneath it. On smaller GPUs the usual trigger is a native out-of-memory abort while loading the ASR model on top of a still-resident TTS model: it kills the process rather than raising a catchable Python error, so no terminal event can be emitted. The reporter's box (RTX 3050, 8 GB) had run three generates immediately before the dub, which is exactly that shape. New utils/backendCrash.streamDropError(): consult the shell's crash marker (#941) and, when one exists, surface the honest story — exit code, how long ago, the captured stderr one click away via the crash notice, and the real next step (free VRAM / smaller ASR model) instead of "check the backend log". With no marker (or outside the Tauri shell) the caller's own message stands, so nothing regresses in the web/Docker build. Wired into BOTH silent-drop paths in useDubWorkflow (the transcribe stream and the prep stream), killing the whole class rather than the one reported message. `getCrash` is an injectable seam (the endpoint_race idiom) so the branch logic is unit-testable without a shell. Frontend suite: 1176 passed (3 new). Closes #1062 Co-authored-by: mergetest <nizam4103@gmail.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> docs(changelog): backfill the streaming-TTS-preview entry (#1088) #1088 shipped a user-facing feature with no CHANGELOG entry, which the release-notes rule forbids — release.yml extracts the section verbatim as the GitHub Release body, so it would have shipped without its headline feature. Backfilled in house style ahead of the v0.3.19 freeze. feat(install): clean uninstaller + a straight answer to "where is my data?" (#1097) A Linux AppImage user asked which folders to delete to remove OmniVoice and whether an uninstaller exists (#1089) — they had to guess. They shouldn't have to: the app is fully local, so uninstalling IS just deleting the folders it wrote, and we never documented them. - scripts/uninstall.sh (macOS/Linux) + scripts/uninstall.ps1 (Windows): find every OmniVoice folder — app data, the multi-GB managed Python env, config, logs — plus, listed SEPARATELY because it is a shared cache, the Hugging Face model cache. Print each with its size as a DRY RUN and stop; delete only on --yes (--models / -Models to include the shared cache). They honor the same env overrides the app reads (OMNIVOICE_DATA_DIR, OMNIVOICE_CACHE_DIR, HF_HOME, HF_HUB_CACHE), and never touch the app binary or anything outside the paths they list. - docs/install/uninstall.md: the complete per-platform path table (what each folder holds and how big it is), the shared-HF-cache caveat, custom/portable locations, per-platform steps to remove the app itself, and what to keep if you plan to reinstall. - Linked from the README FAQ, SUPPORT.md, and install troubleshooting. Paths mirror backend/core/config.py + frontend/src-tauri/src/setup.rs. Verified on macOS: dry-run lists the real dirs; sandboxed HOME runs confirm --yes removes app folders while KEEPING the shared cache, --models removes it, and the env overrides retarget correctly. Closes #1089 Co-authored-by: mergetest <nizam4103@gmail.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> feat(tts): streaming playback preview — audio starts on the first chunk while the rest renders (#1088) * feat(tts): streaming playback preview — audio starts on the first chunk while the rest renders Long scripts meant staring at a spinner until the ENTIRE render finished. POST /generate now takes stream=true (NDJSON: start → N × chunk → done), synthesizing the existing Wave 1.2 sentence-boundary text chunks sequentially and yielding each chunk's audio (base64 PCM16, with the same effect chain the final take gets) the moment it's rendered — engine- agnostic by construction, no per-engine token streaming. The saved take is untouched: the raw chunks go through the SAME concat → effect-chain → watermark → save → history → retention pipeline as the classic path (extracted verbatim into _finalize_generation, now shared by both), so the on-disk file is byte-identical to a non-streamed render (regression-tested). [pause]-marker inputs and short single-chunk texts keep their unchanged single-shot pipeline and stream as one chunk. Each chunk gets its own generate budget on the GPU pool (model load already warmed under the #1039 load budget before streaming starts), so a long script can't time out merely for being long. Frontend: when the preview would auto-play anyway and Web Audio exists (all three Tauri webviews), useTTS drives the stream and the global mini-player (#1042) starts playback from the first received chunk — scheduled AudioBufferSource nodes with the backend's linear crossfade, live seek within the buffered region, progressive peaks, growing duration, and a "Streaming preview…" label that flips to "Generated audio" on completion. Underruns (synthesis slower than playback) wait at the buffered edge and resume when the next chunk lands. Any MID-stream failure (in-band error event, transport drop, Web Audio failure) tears down silently and falls back to the classic whole-file flow — the user sees nothing beyond the old wait. Pre-stream HTTP errors keep their ApiError identity so real 400/503s surface exactly as before, without a wasted second render. Tests: backend — incremental delivery proven at the ASGI boundary (fake engine with per-chunk delay; TestClient/httpx buffer whole bodies and can't observe it), final-file identity vs the classic path, mid-stream error → error event with no done/history/file, single-chunk short text, classic default unaffected. Frontend — NDJSON client, chunk player (seek/stop/label flip), PCM decode, peaks, fallback error taxonomy. Verified end-to-end over real HTTP (uvicorn): 1.0s chunk-arrival spread, saved file byte-identical to the classic take. * style: oxfmt line-length fix in streamingTts.js * test(tts): cover the native OmniVoice model path in streaming preview tests Per-chunk generate calls with duration=None + saved-take identity vs the classic render, mirroring the pluggable-engine coverage. * test(tts): make streaming preview tests hermetic — pin outputs dir + history DB The three streaming tests read the saved take back through core.config.OUTPUTS_DIR while the router writes it through api.routers.generation.OUTPUTS_DIR. Those are separate module bindings, and a full-suite run can split them apart — an earlier test that reloads core.config/main under a tmp data dir (test_dub_transcribe's app_client) moves one binding and not the other, so the save lands in one dir and the read-back looks in another: the CI FileNotFoundError under outputs/<uuid>.wav. Add an autouse fixture that pins BOTH OUTPUTS_DIR bindings and the history DB (via the ensure_schema.__globals__ get_db seam the takes suite already uses against the #909/#932 module-purge leak) at per-test throwaway paths, so each test is hermetic and order-independent. Deterministic fail-before (divergent bindings pre-seeded) → 3 failures identical to CI; pass-after → green. --------- Co-authored-by: mergetest <test@local> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: mergetest <nizam4103@gmail.com> chore: retire finished planning archives from the repo root (#1095) Removes ~110 files of process noise (all preserved in git history): .planning/ (GSD-era phases/quick-plans/issue-clusters; workflow retired 2026-07-08), specs/ (spec-kit specs for shipped features 001-007), design/ (pre-React ASCII mockups), research/ (legacy Gradio archive), and .agents/ (rules for a third-party agent tool no longer in use). The four load-bearing decision docs move to docs/adr/ with an archival note; every live pointer follows (gguf engine module docs + quant_map, inject-apprun.sh, pyproject/test comments, fixture README + its seed script — kept byte-identical). The CJK allowlist drops the deleted legacy_gradio entries; STRUCTURE.md and ROADMAP.md document the removal instead of linking into it. Backend suite: 2891 passed. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> fix: first-run wizard version + mirror-unreachable rescue + lifecycle-aware backend reachability (#1094) Three fixes from the same first-run session report: - SetupWizard shows v{APP_VERSION} in its masthead (same identity mark as the install splash footer), so setup screenshots identify the build. - A dead configured HF mirror no longer strands the wizard: the install_error SSE now carries docs_topic (core.failure.classify), and WizardLibrary renders the MirrorRescue quick-pick (extracted from SetupWizard, now including the official preset) next to the failed row, retrying it the moment a new endpoint is applied. PUT /hf-mirror clears the install cooldowns (no 429 on the immediate retry) and clearing to official also drops the legacy hf_endpoint pref that silently kept the dead mirror in effect. The hint's false "applied when the app starts" claim is corrected: downloads resolve the endpoint per call, retry first, restart only if it still fails. - "Can't reach the local OmniVoice backend" stops firing during real start/restart windows: a respawn takes 10-20+s (venv spawn + torch import) but the transport cascade gave up at ~2.9s. apiFetch now asks the shell (bootstrap_status via utils/backendLifecycle) whether a start/restart is in progress and keeps retrying while it is (capped at 120s, matching the supervisor's respawn budget); the new BackendRestartBanner finally implements the reconnecting banner the #567 supervisor has emitted events for all along. Truly dead backends (or non-Tauri deploys) still error promptly. Regression tests for all three layers; docs synced (downloading-models.md, troubleshooting.md §14b); CHANGELOG [Unreleased]. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> feat(asr): complete activation flow for the OpenAI-compatible remote ASR engine (#1087) The openai-compat-asr backend (#877) shipped with settings routes but no discoverable activation path: the config panel hid in Settings → Models, its hint text claimed "there's no in-app engine picker for ASR yet" (stale — the matrix has one), and there was no way to check a server actually answers before pointing a dub/dictation run at it. Configure → test → activate now live on one screen, Settings → Engines: - The ASR family tab mounts the config panel (URL / model / optional API key) below the engine matrix; saving refetches the matrix via a new reloadToken prop so the engine row flips unavailable → available and its "Use" button appears without a manual refresh. - New "Test connection" button + loopback-gated POST /api/settings/asr-openai-compat/test: saves first (same stale-config contract as /llm-providers/{id}/test), then probes GET {base_url}/models — no audio leaves the machine. The structured verdict maps to localized, actionable messages: latency + whether the configured model is listed on success; classified auth_failed / http_error / timeout / unreachable / ok_no_models failures. detail is core.scrub-ed; the key is never logged or echoed. - Engine reads persisted config fresh per transcribe (regression test) — config changes need no backend restart. Never default-active: ASR auto-detect only picks local engines. - i18n for every new string (en.json); no hardcoded CJK; identical behavior on macOS/Windows/Linux (pure HTTP + React). - Docs-sync: docs/engines/openai-compatible-asr.md rewritten around the one-screen flow with LM Studio / llama.cpp / Groq / OpenAI examples and the privacy note; README engine table cell updated. Verified end-to-end against a fake OpenAI-compatible server: UI drive (configure → test → row flip → Use) plus a real transcription through the backend's /v1/audio/transcriptions immediately after a config change, no restart. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> chore(backend): delete dead services/batched_tts.py + guard against orphaned service modules (#1086) services/batched_tts.py (added2cd1ab4f, April 2026) was an unintegrated throughput experiment with zero call sites anywhere in the repo — no router, service, test, spec, or frontend reference. Its headline "CPU/GPU pipelining" pre-loaded a ref-audio tensor it then never used (model.generate received the file path), it drove the raw OmniVoice model bypassing the engine-aware backend (resolve_generation_backend / applies_own_mastering, #311/#312), and it accumulated all segment tensors in memory — the RAM-spike class the live dub path fixed by streaming to disk (#639/#709). Everything it aimed at is superseded in place by the live routes (batch.py, dub_generate.py, model_manager's guarded GPU pool). Recurrence guard: tests/test_no_orphan_service_modules.py fails if any backend/services module has no referencer in backend/, tests/, or scripts/ (fail-before/pass-after verified: flagged exactly ['batched_tts'] before the deletion, green after). An _INTENTIONALLY_UNREFERENCED allowlist with mandatory justification covers legitimate exceptions and self-cleans on staleness. batched_tts was the only orphan in the services layer. Also fixes the stale batched_tts.py mention in the .planning architecture diagram (swapped for the live chunked_tts.py). Full backend suite green after deletion: 2877 passed, 20 skipped, 10 xfailed. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> docs: migration guide for stranded Real-Time-Voice-Cloning users + sharper local-first promise (#1085) RTVC (CorentinJ's 50k-star SV2TTS repo) is archived; its users need a maintained home. New docs/migration/real-time-voice-cloning.md maps every RTVC concept to its OmniVoice equivalent (encoder+utterance → reference clip, toolbox → app, vocoder choice → Settings → Engines, demo_cli.py → REST API/CLI/MCP), is honest about what RTVC did that we don't (research toolbox, three-stage training, MIT license, smaller footprint), and walks the first clone with verified UI labels only. Wired into docs/features.yaml's existence-checked docs list and linked from the README Quickstart. README tagline now states the local-first promise verbatim at the very top: "No accounts. No API keys. No cloud." — everything else on the front page is unchanged. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> release: freeze v0.3.18 — version bump, lockfiles, changelog (#1084) Co-authored-by: mergetest <test@local> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> feat(engines): one-click IndexTTS-2 sidecar install from Settings → Engines (#1083) * feat(engines): one-click IndexTTS-2 sidecar install from Settings → Engines IndexTTS-2 required four manual terminal steps (git clone, uv venv, uv pip install -e ., export OMNIVOICE_INDEXTTS_DIR). This turns that into a guided in-app install: - backend/services/sidecar_install.py — parametrized sidecar provisioner (SidecarSpec/SPECS so future sidecar engines are one entry, not another installer). Resumable background job with step-by-step status: disk-space preflight (needs-X/have-Y message), source fetch (git clone --depth 1 primary, GitHub tarball fallback when git is absent/fails), dedicated venv via uv (OMNIVOICE_BUNDLED_UV → PATH resolution; transformers<5 isolation preserved — the parent env is never touched), import-probe verification, IndexTeam/IndexTTS-2 weights into <checkout>/checkpoints (where the sidecar actually loads from) via snapshot_download with the auto-selected/configured HF endpoint + token — no hardcoded huggingface.co — and persistence of OMNIVOICE_INDEXTTS_DIR (os.environ for immediate use, prefs.json env.* for the next launch). Idempotent: partial installs repair, downloads resume, healthy installs (incl. a user's own clone) report already_installed and are never touched. - API: POST /engines/{id}/install starts the job, GET /engines/{id}/install/status polls it, DELETE /engines/{id}/install removes an app-managed install (loopback-gated; refuses user-managed clones). list_backends() gains one_click_install. - Frontend: Settings → Engines shows an Install button on the IndexTTS2 row with per-step progress, live log tail, weight-download %, and error+remediation; the manual setup snippet is demoted to a collapsed "Manual install" fallback. All strings via i18n (en.json). - OMNIVOICE_INDEXTTS_DIR joins the Settings env-var allowlist (single-sourced from the installer SPECS). - Docs: docs/engines/indextts.md leads with the one-click flow; manual steps become the fallback section. CHANGELOG Unreleased entry added. - Tests: tests/test_sidecar_install.py (24 cases — happy path, disk-space fail, git-absent/git-failing tarball fallback, partial-install repair, already-installed/running gating, uninstall safety, spec↔bootstrap contract, router wiring) + 6 new EngineCompatibilityMatrix RTL cases. API route snapshot regenerated. * fix(engines): harden the sidecar installer — review findings - Route namespace: /engines/sidecar/{id}/install — a dynamic /engines/{id}/install would shadow the literal POST /engines/sonitranslate/install (engines router registers first); regression-guarded by test_sidecar_routes_never_shadow_literal_engine_routes. - Weights completion marker: a killed-mid-download multi-shard weights dir (config.yaml + plausible shards) no longer passes for healthy; the marker is written only after snapshot_download returns, so re-runs resume. - _run_logged: drain thread + proc.wait(timeout) + POSIX process-group kill — a grandchild holding the stdout pipe can no longer hang the step past its timeout. - Job log lock: the status poll's list(deque) copy no longer races the worker's appends (RuntimeError under active logging). - Self-heal: a healthy managed install whose env var was lost (prefs wiped) is re-pointed by start_install instead of reported already_installed while the engine stays unavailable; legacy bootstrap installs (Probe-2 venv) are trusted via the engine's own probe. - Single-sourced uv/venv-layout resolution: engines.indextts.bootstrap now delegates _locate_uv/_venv_python_path to services.sidecar_install. - Frontend: stable poll interval (keyed on the running-id set, not the status map), reload on a job that finishes before the first poll, re-attach to an in-flight job on remount, i18n'd Install aria-label, manual-install <details> auto-opens on failure, snippet block hoisted out of the JSX IIFE. - list_backends: sidecar-installable set hoisted out of the per-engine loop; exhaustive-shape registry test updated for one_click_install. - Tests rebind the live services.sidecar_install module per test (other suites purge sys.modules["services"], which made router tests order-dependent). * docs(changelog): fill in the PR ref (#1083) * fix(security): validated tarball fallback + scanner-clean installer - The pre-filter= extractall fallback (Python < 3.11.4) now extracts member-by-member behind the same guards extractall(filter="data") enforces — regular files/dirs only, no absolute paths, no ../ escapes, resolved-path containment. Kills the new CodeQL py/tarslip (high) and Bandit B202 (error) alerts; regression-tested with a malicious tarball (test_safe_extract_members_blocks_tar_slip). - snapshot_download tracks the weights repo's default branch on purpose (same policy as every other model download; artifacts are checksum-verified by hf_hub) — documented + B615 waived at the call. - Explanatory comments on the intentional empty-except blocks (CodeQL py/empty-except notes). Verified locally: bandit -ll -ii on the module reports 0 MEDIUM+ findings. * fix(engines): address Greptile review — Windows tree kill, prefs write race, poll robustness - _kill_tree: Windows now uses taskkill /F /T so a git/uv helper spawned by the timed-out child can't keep writing into the checkout (POSIX already killed the process group). Unit-tested with os.name patched to nt. - core/prefs: mutations (set_/delete) are serialized behind a module lock — the installer worker persisting its env.* key concurrently with a Settings write could previously drop whichever key saved first (whole-class fix: every threaded prefs writer, not just the installer). Fail-before/ pass-after: tests/test_prefs_thread_safety.py. - Matrix polling: at most one in-flight status request per engine (an old 'running' response can no longer land after a newer 'succeeded' and restart the poller), and four consecutive poll failures drop the stale snapshot instead of showing "Installing…" and hammering a dead backend forever. --------- Co-authored-by: mergetest <test@local> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> feat(network): automatic Hugging Face endpoint selection — probe, pick, remember (#1082) * feat(network): automatic Hugging Face endpoint selection — probe, pick, remember Restricted-network first-runs (the #984 class: huggingface.co unreachable, user dead-ends before discovering the mirror setting) now self-heal by default, while explicit endpoint choices are never second-guessed. - New backend/services/endpoint_race.py: parallel HTTPS reachability + latency probes of huggingface.co and the hf-mirror.com community mirror (3s timeouts). Probes are the only signal — no geo-IP, no third-party calls. Reachable beats unreachable; with both reachable the official endpoint wins unless the mirror is decisively faster (anti-flap hysteresis). The pick is cached in prefs and re-raced only on first run, a network-classified download failure, staleness (>7 days), or an explicit "Test again". - Manual mode is sacred: HF_ENDPOINT env, an hf_endpoint pref, or any explicit Settings pick disables auto-switching entirely; OMNIVOICE_HF_ENDPOINT_MODE=manual is a hard opt-out. - Wiring: the wizard preflight races endpoints when nothing is configured (honest copy when the mirror wins; warn-not-block when nothing is reachable); Model Store installs and the model-cache auto-repair resolve their per-call endpoint= through the cached decision, and a network-classified failure re-races once per repo per process and retries on the new winner (same guard pattern as the cache-recovery ladder). - Settings → Models → Hugging Face mirror gains "Auto (recommended)": shows the current pick, measured latency, last-checked time, and a "Test again" button (POST /api/settings/hf-mirror/test). Existing explicit configs surface as the matching manual mode. Panel notes that hf_hub checksums every download regardless of endpoint. - Tests: policy/cache/failover matrices in tests/test_endpoint_race.py, preflight + settings + repair-failover integration with mocked probers, HFMirrorPanel mode tests, and a suite-wide conftest guard that pins the probers so no test can hit the real network. - Docs: downloading-models.md and install/troubleshooting.md describe the automatic default and both opt-outs. * docs(changelog): Unreleased entry for automatic HF endpoint selection * fix(tests): endpoint-probe pin uses an isolated MonkeyPatch and clears the decision cache; dtype guard tolerates stubbed torch The autouse probe pin requested the shared monkeypatch fixture, hoisting its setup earlier for every test and reordering teardown against the fp16 guard — which then ran torch.get_default_dtype() on test_torch_compile_gate's SimpleNamespace stub. The pin now uses its own MonkeyPatch context and also clears the prefs-cached endpoint decision per test (one test's auto pick leaked into other tests' preflight labels on CI ordering). The dtype guard additionally skips non-module torch stubs outright. * fix(tests): endpoint env vars can no longer leak out of the mirror-settings suite set_hf_mirror writes os.environ[HF_ENDPOINT] during the test, and monkeypatch.delenv(raising=False) on an absent var records nothing to undo — so the write leaked process-wide and flipped later suites' preflight network checks into the explicit-endpoint branch (the CI-order failures). Guaranteed save/restore autouse fixture at the source, plus defensive env shedding in the preflight suite. --------- Co-authored-by: mergetest <test@local> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> test: pay down export-router test debt + kill two test-order pollution classes (#1081) Four pieces of test debt, root-caused and hardened: 1. exports.py test coverage (was: zero dedicated tests): new tests/test_exports_api.py (26 tests) covering /export, /export/record, /export/history, /export/reveal — happy paths, traversal/containment guards (incl. symlink escape), destination validation, error mapping, and the mp4 watermark-overlay branch with its plain-copy fallback. Two real bugs found and fixed in the router: - _safe_destination checked isabs() on realpath()'s output, which is always absolute — dead check; a relative destination silently exported to a cwd-dependent location instead of the documented 400. - _safe_source let "." / ".." through the basename guard (caught only later by realpath containment as a confusing 404); now 400 up front. 2. CI-Linux fp16 default-dtype leak (test_prefers_vocals_over_mix, test_final_dub_track_and_seg_wav_are_watermarked): not reproducible on macOS — instrumenting torch.set_default_dtype across both tests records zero non-fp32 sets locally. Both tests now carry an opt-in torch_dtype_isolation fixture (save/restore, so the leak can never spread), and the conftest guard is demoted to pure insurance. A cheap permanent recorder wraps torch.set_default_dtype / set_default_tensor_type once torch appears and captures the setter's stack only on a non-fp32 set; both fixtures print that stack when they fire, so the next CI occurrence names the exact culprit call chain. 3. Test-order pollution (both reported combos): root cause was collection-time sys.modules stubbing in backend/tests — seven modules installed bare ModuleType stubs for core.config (and test_capture_ws.py for services.model_manager/asr_backend/ffmpeg_utils, now all lazily imported by the router anyway). pytest imports test modules during collection, so the stubs leaked process-wide before any test ran: - combo (a): monkeypatch.setattr("core.config.OUTPUTS_DIR", ...) in test_longform_e2e died with AttributeError (core never gets a .config attribute when the import is satisfied straight from sys.modules). - combo (b): test_router_smoke's `from main import app` died with ImportError: cannot import name 'find_ffmpeg' (unknown location). Fix at source: new backend/tests/conftest.py sets a hermetic OMNIVOICE_DATA_DIR (mirroring tests/conftest.py, #878) and the real core.config is imported everywhere — zero sys.modules surgery. New backend/tests/test_no_module_stubs.py guards the whole class (verified fail-before/pass-after against the old stub). Stale rationale comments in pyproject.toml and ci.yml updated to match. 4. batched_tts.py TODO(#312): investigated, comment corrected only — #312 is closed (the live routes are engine-aware); this module has zero call sites and stays an unintegrated experiment. See PR notes. Full tests/ suite: 2796 passed. backend/tests standalone: 130 passed. Both pollution combos re-run green in the reported orderings. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> fix(docker): ship alembic.ini in the image, add an image-level HEALTHCHECK (#1080) Migrations in Docker fell back to the additive-column self-heal because alembic.ini was never copied; the real migration chain now runs. The HEALTHCHECK covers plain docker-run (compose files keep their own), with a start period sized for first-boot schema creation. Docs example tag freshened. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> release: freeze v0.3.17 — version bump, lockfiles, changelog (#1079) Co-authored-by: mergetest <test@local> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> fix(scripts): desktop-fresh kill guard referenced the wrong dry-run flag (#1078) The kill-before-wipe block used DRY_RUN; the script's flag is dryRun — any run with a live instance crashed with ReferenceError before wiping. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> fix(shell): deep health probe before attaching to a running backend; scripts kill before wiping (#1077) A backend that keeps running while its install is deleted or replaced underneath it still answers /health and /system/info from memory — the launcher's version check passed and the UI attached to a process that 500s every DB-touching route (raw errors without CORS headers, so the webview reports access-control failures). The attach path now requires a DB-touching probe (/profiles) to return an actual 200 status line, and replaces the squatter otherwise — the status line is parsed explicitly because the raw HTTP helper previously returned 500 bodies as Ok. desktop-prod/desktop-fresh now terminate our own running processes (bundle, dev binary, app-scoped port-3900 listener) before wiping, which is how the zombie was produced. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> docs(changelog): unreleased entry for #1074 (#1075) Co-authored-by: mergetest <test@local> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> fix(ui): default UI scale is 100% — native size out of the box (#1074) New installs rendered at 130% zoom, which read as oversized on typical displays. Fresh sessions now start at 100%; anyone who already picked a scale keeps it (uiScale is persisted and wins over the default). Co-authored-by: mergetest <test@local> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> docs(changelog): unreleased entries for #1071 and #1072 (#1073) Co-authored-by: mergetest <test@local> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> feat(settings): Engines & Models go tabbed and compact — strict two-line rows, aligned columns (#1072) Settings → Engines collapses from three stacked full matrices (where one engine row could sprawl to 5+ lines and fill a viewport) into ONE section with a TTS / ASR / LLM tab strip (the matrix's Radix Segmented — roving tabindex + arrow keys, active engine named in each tab caption). The single mounted matrix still issues exactly one GET /engines + one GET /model/loaded per Settings open; switching tabs re-slices the fetched payload with no refetch. openSettingsTab('engines') deep-linking is unchanged (nothing in the app targets a specific family — audited). Every engine row is now strictly two lines inside a fixed h-16 shell: line 1 = EngineMark + truncated display name (full name via title, never wraps) + active/in-memory badges; line 2 = engine id, cloning chip, curated-model picker, one-line truncated hints (full text via title). Header and rows share one grid template (minmax(0,1fr) 108px 176px 92px 232px) so the STATUS / GPU COMPAT / ISOLATION / ACTIONS columns align on every row; actions sit right-aligned and vertically centered across both lines. Below 880px the three meta cells re-place onto the row's second line (same DOM nodes) instead of forcing a horizontal scroll. Unavailable-row details (reason, install hint, last error, setup snippet) move out of the row into an aria-expanded expansion panel that opens BELOW it, so sibling rows never lose alignment. The previously hardcoded column labels are i18n'd (engines.col*). Settings → Models already has its natural grouping as the role filter strip (All/TTS/ASR/… with counts) + search over one list, so no tabs were invented there; its rows get the same compactness treatment — 5px vertical padding + 52px min-height two-line rows (virtualizer estimate updated), with title attributes carrying the full label/repo text past the ellipsis. Tests: EnginesTab suite rewritten for the tabbed layout (tab strip renders, switching families doesn't refetch, single /engines + /model/loaded probes, Use-on-ASR flows through the tab); matrix suite updated for the expansion panel (reason/last-error/setup-snippet live behind the Why-unavailable toggle) and extended with layout regressions: fixed-height two-line shell, name truncation + title, header/rows sharing identical grid tracks, panel open/close as a sibling below the row. 1139 frontend tests pass; typecheck:ci, oxlint, oxfmt and vite build are clean. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> feat(setup): media tools become invisible — bundled by default, controllable in Settings (#1071) Most users should never learn what ffmpeg is. The Setup Wizard's SYSTEM PREFLIGHT stops listing FFmpeg / FFprobe / yt-dlp as user-installed requirements ("brew install ffmpeg…"): they are internal dependencies the app provisions for itself. Genuine user facts (OS, RAM, disk, GPU, network, Python) are untouched. Backend - New services/media_tools.py: per-tool status {version, path, origin: sidecar|bundled|system|custom}; background acquisition of a pinned, SHA-256-verified static ffmpeg+ffprobe build (immutable-commit fetch from the same upstream the static-ffmpeg pip package uses — that package itself was audited and rejected: mutable raw/main URL, no checksums, writes into site-packages); binaries are `-version`-probed via the existing _binary_runs before being trusted, installed under DATA_DIR (update-surviving, frozen-build-safe), zero new Python deps. - ffmpeg_utils resolution chain gains the acquired-bundled tier — and ffprobe finally has a bundled tier at all (imageio-ffmpeg ships none), closing the source-install gap. - New /media-tools router (loopback-gated, same contract as /system/set-env): status, acquire, {tool}/custom-path | use-system | restore, ytdlp/update | restore. Overrides persist via the existing env.FFMPEG_PATH / env.FFPROBE_PATH prefs convention — one store, no competing controls. - yt-dlp updates: audited in-venv pip/uv upgrade and rejected (venv is uv-managed with no pip; yt-dlp is a locked dep, so the updater's --inexact drift sync would revert it). Instead the newest wheel — verified against PyPI's own sha256 — lands in a DATA_DIR overlay prepended to sys.path at startup: survives app updates, works in frozen builds, and "Restore tested version" is just deleting the overlay. Gallery now runs yt-dlp via `python -m yt_dlp` (module, not PATH) so the CLI can never be a user-install task either. - /setup/preflight drops the three tool rows, carries a media_tools verdict, and self-heals: kicks the bundled download in the background when no tier resolves (never re-fires after a failure — the wizard's card owns Retry). diagnose + the ffmpeg-missing notification now point at Settings → Audio tools instead of package managers. Frontend - Wizard: new MediaEngineCard — renders NOTHING when the engine is ready, a one-line progress while acquiring, and only on failure an actionable card (Retry / Use a system copy / Choose file…). - Settings → Audio tools (new category, System group): FFmpeg + FFprobe rows with version, path, origin badge, Use system copy / Choose file… / Restore bundled, header-level "Update bundled build"; yt-dlp row with Update + Restore tested version (+ restart affordance). Package-manager commands appear only as copyable prose, never executed. - The FFmpeg-path override moved out of Settings → Network (pointer row deep-links to Audio tools; no second writer of env.FFMPEG_PATH). Notifications gain a settings-tab action type. - All strings i18n (en + defaultValue), a11y labels on every control. Tests: 29 new backend (origin classification, checksum/size/probe rejection, override persistence, overlay update/restore, router gating + route-shadowing) + preflight contract tests (tool rows gone, verdict present, auto-acquire fires once); 14 new frontend (wizard hide/progress/ failure-card, Audio tools rows/badges/actions). Route snapshot regenerated. Docs (macos/linux install, troubleshooting §7b) describe the new reality in the same commit. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> fix(scripts): desktop-prod builds clean, desktop-fresh emulates a brand-new machine (#1070) desktop-prod fixes: - `tauri build --debug` used to produce every bundle and THEN exit 1 at the updater-artifact signing step (no TAURI_SIGNING_PRIVATE_KEY on dev machines); the script papered over it with a blanket "non-fatal bundle error" grep that also swallowed real bundling failures. Local emulation builds now pass `--config '{"bundle":{"createUpdaterArtifacts":false}}'` and only build the bundle the script launches (--bundles app / appimage, --no-bundle on Windows), so the build exits 0. Any nonzero exit now FAILS the script — the sole tolerated case is a specifically-detected linuxdeploy/FUSE failure on Linux when the raw debug binary was produced. - The HF cache wipe ran `rm -rf ~/.cache/huggingface` on macOS/Linux — the SHARED global cache (backend/core/config.py only relocates it on Windows), deleting models unrelated to OmniVoice. Non-app-scoped cache paths are now kept with a "models will be reused" notice; FRESH_NUKE_HF=1 opts in. - Honest clean marks (removed ✓ / already-clean ○ instead of ✗ for success), `open -n` always (plain `open` focused a stale running instance instead of launching the freshly built one), stale-AppImage removal on Linux. New `bun desktop-fresh` (+ desktop-fresh:run), macOS-only with explicit refusal elsewhere: true new-user emulation. - Blank slate: everything desktop-prod cleans PLUS the traces that survive a reinstall + data wipe — ~/Library/WebKit (webview localStorage), Caches, HTTPStorages*, Preferences plist (+ defaults delete), Saved Application State. Per-path found/removed/absent status with sizes; --dry-run prints the full plan without touching anything. - Dev-machine camouflage: launches by direct exec of the bundle's Mach-O (which inherits env — `open` hands off to launchd and drops it) with PATH stripped of /opt/homebrew/{bin,sbin} + /usr/local/bin and HF_TOKEN / HUGGING_FACE_HUB_TOKEN / HF_HOME / HF_HUB_CACHE / HF_ENDPOINT / OMNIVOICE_* unset, and prints a banner of what is hidden. Shared pure helpers live in scripts/desktop-common.mjs, covered by 9 node tests (tests/frontend/desktopScripts.test.mjs): every cleanable path is app-scoped and under $HOME, the PATH/env sanitizers strip exactly the intended entries, and the build args carry the updater-artifacts-off config. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> docs(changelog): unreleased entry for #1067 (#1068) Co-authored-by: mergetest <test@local> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> fix(dub): interrupted dub sessions no longer trap the app in an eternal spinner on relaunch (#1067) The omni_ui session persisted dubStep verbatim, including in-flight values (uploading/transcribing/generating/stopping). Quitting or crashing mid-dub froze that step into localStorage, and every relaunch restored a wait on work that died with the process — a blank Dub pane with an eternal spinner that even reinstalling couldn't clear (the webview's localStorage survives). Restores now clamp to settled states: editing when the session has segments, idle otherwise; unknown/corrupt values are treated as transient. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> docs(changelog): unreleased entries for #1058-#1064 (#1065) Co-authored-by: mergetest <test@local> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> fix(settings): make provider/token panels honest — real Test now, gated probes, MCP bindings i18n + confirm (#1064) HuggingFace token (ApiKeysPanel): - "Test now" actually re-runs whoami: GET /api/settings/hf-token/state gains ?fresh=1 which drops the resolver's 300s validation cache (the invalidate hook existed but was never wired to any endpoint), so a fixed network or rotated token no longer shows a stale verdict for up to 5 minutes. Plain panel mounts keep the cache. - Initial load renders a "Checking token sources…" placeholder instead of flashing a false amber "not set" for all three sources. - Source rows are now a valid ARIA list (the old role="table" had rows with no cells, hiding the status from screen readers). - Enter in the token input respects the in-flight guard the Save button already had (no duplicate POSTs). LLM Providers: - Test / Fetch models abort when the implicit save fails, instead of probing the previously-stored config and pairing a green "Test ok" badge with a save error. - A failed initial load now offers a Retry button instead of dead-ending until the panel remounts. LLM Skills: the per-skill provider Select carries an accessible name ("Provider for <skill>") instead of announcing as an unlabeled combobox. MCP voice bindings: - All user-facing strings go through i18n (the panel was the only Settings surface with hardcoded English throughout). - First-run guidance moved out of per-row hints (which never rendered with zero bindings and duplicated per row) into the section header + an InfoHint that links to docs/mcp.md; an empty state invites the first add. - Delete asks for confirmation via the shared askConfirm, disables the row's button while in flight, and re-syncs the list even when the DELETE fails (a 404 row no longer lingers on screen). - The add row exposes the optional label the API already accepted (the row title rendered b.label without any way to set it); default_engine stays MCP-side-only and is documented as such. - First component test file for the panel (load/empty/add/delete/error/a11y). Tests: backend fail-before/pass-after for the fresh=1 cache bust; new frontend coverage for every behavioral change above. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> fix(settings): network & privacy panels — clearable proxy after reload, HF-mirror panel never vanishes, guarded remote-backend save, honest privacy claims (#1063) Settings → Network / Models / Sharing / Privacy / OpenAPI fixes: - NetworkTab: a proxy persisted in a previous session can now be cleared — the Clear button and "Set" badge derive from the backend-persisted value (sysInfo.proxy_url), not only from a save in the current session. Proxy row copy now matches its real semantics ("Applies now" badge; desc/toast no longer claim a restart is needed or leak yt-dlp jargon — reworded in all 21 locales). FFmpeg path placeholder is platform-appropriate instead of Windows-only on every OS. - HFMirrorPanel: the panel no longer disappears when the initial GET fails — the section shell always renders, with a loading state and an error + Retry affordance. Saving now toasts, the active preset is marked (aria-pressed), and the custom-URL row is labelled "Custom mirror URL" instead of raw HF_ENDPOINT jargon (env var moved to the row note). - RemoteBackendPanel: full i18n (was 100% hardcoded English); Save & reload now validates the URL (http/https, parseable) and asks for confirmation before saving a URL that hasn't passed a connection test — a typo'd base no longer bricks every API call after reload. Dropped the contradictory "Restart required" badge (saving reloads the app itself; description says so). docs/remote-gpu.md updated to match (docs-sync). - PrivacyTab: the "Network calls" row no longer shows the green "Offline translator" assurance when the backend is down or reports 'unknown' — green is reserved for confirmed-offline providers (nllb/argos/ libretranslate), everything unconfirmed shows a neutral "Unknown" badge. The online-translator warning now deep-links to Translation settings. - OpenApiPanel: a failed clipboard copy toasts an error instead of silence. - a11y: all five text inputs across these panels now carry accessible names (aria-label), previously announced only by their vanishing placeholders. Tests: new colocated suites for NetworkTab, HFMirrorPanel, RemoteBackendPanel, PrivacyTab; OpenApiPanel suite extended with copy success/failure. Frontend suite 140 files / 1061 tests green; i18n parity probes green (new keys en-only with defaultValue, reworded keys updated in every locale). Co-authored-by: mergetest <test@local> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> fix(settings): factory reset covers all prefs, guarded log clearing, temp reclaim, full Performance i18n (#1061) Settings system-group cleanup — every fix keeps existing behavior contracts and adds a fail-before/pass-after regression test: - Factory reset now does what it promises: clears every locally-persisted preference via a single registry (utils/prefKeys.js) instead of only the zustand blob — nav-rail side, capture live-typing, stories speed, logs footer state, last settings category, dismissed tips, donate prompts, and the legacy omni_ui blob included. User data and connection state (omni_transcriptions, ov_backend_url, ov_api_key) are explicitly preserved, and prefKeys.test.js scans the source tree so any future localStorage key must be categorized or CI fails. The failure toast now carries the actual error message. - Disk-usage "Clear logs" is confirm-gated with the same wording as Settings → Logs — it truncates the crash log (the bug-report artifact), so it can no longer be a single stray click. - Temporary files got a reclaim action: a confirmed "Clear temp files" button backed by POST /api/settings/storage/temp/clear, which deletes only the omnivoice* entries in the OS temp dir (symlinks unlinked, never followed) and invalidates the cached report. - Performance panel goes through i18n end to end (title, row, note, hint, errors, aria-label) — it was the last fully hardcoded panel; the non-Windows subtitle now reads "Windows only — not needed on this platform" instead of "not applicable". - History retention: GET failures now surface an alert and hold Save until a load succeeds (404 from older backends stays silent), Enter saves, the dead !res.ok branch is gone, and the bespoke button is the shared Button. - Logs tab: "Open folder" reveals the log file, "Copy visible log" copies the tail, the viewer autoscrolls to the newest lines, and the scroll box is keyboard-focusable (role=log) with a labelled source switcher. - Storage paths: the app-data row is labelled "App data stored at" (it was borrowing the Privacy tab's "Uploads stored at"), and all three path rows gained Open folder. - i18n stragglers routed through t(): storage load/open/clear fallbacks, the backend-status badge, and the frontend log buffer label. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> fix(settings): dictation & pronunciation panels — error states, language-scoped preview, i18n, a11y, dictionary import/export (#1060) Settings → Dictation / Pronunciation / remote-ASR panel fixes: - RefinementPanel no longer vanishes when the initial GET fails (backend down/restarting): the section shell always renders, with the error, a Retry button, and a loading line — matching its sibling panels. - RefinementPanel offers the "Open LLM Providers" deep-link as soon as llm_ready is false, not only after the first refinement failure. - Pronunciation test preview gains a preview-language selector and sends it to POST /pronunciation/test, so language-scoped entries finally show up in the preview instead of a misleading "No entries match"; a hint explains that Global previews skip language-scoped entries. - Preview requests are debounced and sequence-guarded (a slow stale response can never overwrite a newer one), failures surface as a "preview unavailable" note instead of silently blanking, and the preview re-runs after add/toggle/delete/import so it never goes stale. - Dictionary backup & restore: Export JSON / Import JSON buttons wired to the existing GET /pronunciation/export and POST /pronunciation/import endpoints (import prompts replace-vs-merge when entries exist). - a11y: each entry's enable switch is named after its term ("Enable GIF"), all add-form/test inputs and selects carry aria-labels, and the cramped language field gets a short placeholder with the long explanation moved to the row hint. - RefinementPanel + AecPanel converted to i18n (`dictation.*` keys; the refine-failure helper now returns a key instead of hardcoded English), per the all-UI-strings-through-i18n convention; "experimental" is now sentence-cased via its key. - Copy: empty state says "Add one below" (the form is below the list, in every locale) and the test row is titled "Test a sentence" instead of its ellipsized placeholder. - AsrOpenAICompatPanel: Save is disabled until a field actually differs from the server values and shows a "Saved" confirmation after saving — URL/model-only edits are no longer silently ambiguous. - Enter submits the pronunciation add form; Add is disabled while the term is blank. New pronunciation locale keys are translated in all 21 locales (keeping that namespace fully covered); the new `dictation.*` namespace is en-only with fallback, matching the `models.asrOpenAICompat*` precedent. No backend changes — /pronunciation/test already accepted `language`. Tests: RefinementPanel + AsrOpenAICompatPanel component tests added, PronunciationPanel tests extended (language-scoped preview, stale-response guard, preview error, re-run after add, Enter-to-add, per-entry switch names, export/import round-trip), refine-note test updated for i18n keys. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> fix(settings): correct About links, wire RTL, and close core Settings UX gaps (#1059) - About linked the wrong project: "OmniVoice on GitHub" and "Model card" opened k2-fsa/OmniVoice. The GitHub button now derives from a single REPO_URL constant in utils/bugReport.js (issue/search URLs derive from it too), and the Model card button is gone — a multi-engine app has no single model card. A test pins the button to the constant so links can't drift. - Picking Arabic now actually flips the UI to RTL: the languageChanged handler sets document dir + lang from i18n.dir(), covering any future RTL locale as well. - A Settings search that matches nothing now shows "No settings match" with a Clear action instead of a silently blank sidebar (and an option-less nav <select> in the narrow layout). - Hotkey recording no longer swallows modifier-less presses in silence — it shows inline "add a modifier" feedback, the Record button becomes a Cancel toggle while listening, window blur cancels the global key listener, and the row copy states the modifier requirement. - About no longer dead-ends on fixable problems: "HF token set: no" and failing self-checks deep-link into the owning Settings category via openSettingsTab. - Sidebar search now also matches translated setting-row titles (keywordKeys), so localized users can find categories by localized names; English keywords keep working everywhere. - Network gets its missing restart flag (the FFmpeg-path row is a restart-bound env write); a lockstep test keeps RestartBadge usage and category flags in sync. - The theme-dot and font-tile radiogroups implement the real WAI-ARIA radio pattern: roving tabindex plus arrow-key movement with focus following selection. - Copy cleanup: sentence-case "UI scale" / "Commercial license"; the review segmented control moves off the orphaned engines.review_* keys to settings.review_mode_on/off (renamed across all 21 locales) with plainer English labels. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> feat(settings): Models & Engines pages — engine identity marks, capability badges, upgrade hints, filter, residency (#1058) The engine list gains a scannable identity mark per engine (EngineMark), capability badges (cloning, device routing with reasons, sidecar isolation), and surfaces available-but-has-advice hints that list_backends previously dropped (new additive hint field; the ready-with-advice convention). The model store gains a filter, disk context near downloads, in-memory residency indicators with safe unload, copyable setup snippets, and actionable empty/error states. Registry additions are additive only (hint, supports_cloning with the property-descriptor guard). Co-authored-by: mergetest <test@local> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> release: freeze v0.3.16 — version bump, lockfiles, changelog (#1057) Co-authored-by: mergetest <test@local> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> fix(models): self-heal HF-cache snapshots with broken file links, retry load once (#1056) A first-run breaker: all blobs download fine, but the snapshots/<rev>/ entries are dangling symlinks (0 KB) — os.path.isfile() is False on a dangling link, so transformers reports the weights missing even though the bytes are on disk, and the existing resume repair can't fix it. New services/hf_cache_repair.py deletes exactly the broken snapshot entries (dangling symlinks + zero-byte weight/config stand-ins; never blobs, never resolving entries) and restores them via snapshot_download, verifying afterwards — if the restore recreates broken links (hub's memoized symlink probe passing while real links come out broken), it forces hub into copy-mode and repairs once more with real files. model_manager retries the load exactly once per repo per process (rung 0 of the cache-recovery ladder); dead-end errors now name the exact models--<org>--<name> folder to delete. failure.py classifies the class as MODEL_CACHE_CORRUPT so the user-facing error and auto bug report explain the automatic repair. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> fix(tts): voxcpm2 — version floor, reference-clip prep, trailing-silence guard (#1055) Three hardenings of the voxcpm2 engine path, all backward-compatible and platform-identical: - Version floor: every install hint now says pip install "voxcpm>=2.0.3" (2.0.3 fixed an Apple-Silicon/MPS audio-quality bug). Floor only — an already-installed older version stays available and working; it just surfaces an actionable upgrade hint in the is_available reason and a load-time warning. - Reference-clip prep: the voxcpm package no longer trims reference audio itself, so raw user clips reached the model unconditioned. The clone path now trims leading/trailing near-silence (-50 dBFS floor, 50 ms edge pad) and caps the reference at 30 s. Fail-open (any prep problem falls back to the raw clip) and a strict no-op for short clean clips. - Trailing-silence guard: generated output is trimmed to the last voiced sample + ~0.3 s natural tail via the new audio_dsp.trim_trailing_silence. Silence-trim only, no content analysis; a no-op on outputs without a silent tail and on all-silent (dead) renders. 22 new fake-module tests in tests/test_voxcpm2_guardrails.py; existing engine/hint tests strengthened to guard the floor. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> feat(tts): normalization covers the OpenAI-compat API, streaming, and batch paths (#1054) The engine-agnostic text-normalization pre-pass now runs at the three remaining text→engine choke points, applied exactly once per request: /v1/audio/speech (req.language), /ws/tts (whole text, before the sentence chunker fans it out), and the batch queue's per-segment _gen (target language) — matching the /generate, dub, and audiobook wiring. Route-level tests pin exactly-once (spy) + toggle-off-raw for each path. Also fixes a pre-existing /ws/tts bug the new test exposed: any request omitting emo_alpha hit a KeyError and got an error frame instead of audio. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> docs(changelog): unreleased entries for #1048-#1052 (#1053) Co-authored-by: mergetest <test@local> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> feat(studio): generation takes — star, replay, and restore past takes; capped history retention (#1052) Every generate already recorded a generation_history row; now that history is usable: a takes rail in the workspace history lists recent takes with star/ unstar, replay, and one-click restore as the active output. Alembic migration 0009 adds the starred column (the startup schema self-heal covers pre- migration DBs), a retention cap (setting, default 200) prunes the oldest UNstarred rows — starred takes are never pruned — and history WAVs are only deleted when no other row references them. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> feat(dub): predict segment fit before synthesis — tight/impossible badges + opt-in shorter rewrites (#1051) New pure planning layer (services/duration_planner.py) runs after translation, before TTS: estimates each translated line's natural speech duration (self- calibrating from the job's already-synthesized segments, static per-language rates as cold-start fallback) and classifies it fits/tight/impossible against slot + capped gap borrow, with thresholds derived from fit_planner's own caps so "impossible" means "would be trimmed". Verdicts ride the /dub/translate response and badge the segment table; an opt-in (default OFF) LLM pass attaches one-click shorter-rewrite suggestions for impossible lines. Never blocks generation — informs before GPU time is burned. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> feat(dub): LLM translations keep terms consistent and sound spoken — auto-glossary brief + reflect pass (#1050) One up-front LLM pass over the full transcript extracts a theme summary + terminology map, merges it under the user's manual glossary (user entries always win), caches it on the dub job per target language (job_data blob, no schema change), and injects the brief into every per-segment prompt. A new reflect pass then critiques each segment's direct translation for wordiness / stiff register and rewrites it as natural spoken dialogue — any failure or divergence silently keeps the direct translation. Both stages have Dub-tab toggles (default ON for the LLM engine, persisted; MT engines unaffected), with i18n strings across all 21 locales and docs updated. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> feat(tts): numbers, times, and abbreviations are spoken correctly in every engine (#1049) New conservative, idempotent pre-TTS normalization pass (services/text_normalization.py): strips zero-width/control junk, caps pathological repeat runs, expands digits/times/ordinals/currency via num2words (29 locales) and per-language abbreviation maps (EN/DE/ES/FR). Wired once at each text-to-engine choke point — /generate, dub segments (+ preview), and longform chapters — BEFORE the pronunciation dictionary so user respellings stay the final say. Pref-gated (text_normalization_enabled, default ON) with OMNIVOICE_TEXT_NORMALIZATION env override; num2words promoted to a direct dependency. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> feat(longform): segment-level render cache — edit one sentence, re-render one segment (#1048) Adds a content-addressed segment cache (segment_cache_key + SegmentCache, cache_dir/segments/) under the existing chapter cache: a changed chapter now reuses every untouched span's WAV and synthesizes only the edited/missing ones, and an interrupted chapter render resumes from the segments that already finished (each persists the moment it renders). The chapter key derivation is unchanged so on-disk caches from released versions keep hitting, a fully- unchanged chapter never touches segment files, and prune_cache_dir now walks both layers so one byte cap bounds the whole cache. Chapter SSE events gain additive segments/cached_segments counts. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> docs(research): adjacent-projects notes — voicebox, RTVC, VoxCPM, ebook2audiobook, VideoLingo (#1047) * docs(research): adjacent-projects read — RTVC, VoxCPM upstream, ebook2audiobook, VideoLingo Owner-requested comparative research tied to the current maturity map: voxcpm2 upstream sync items (>=2.0.3 MPS fix, ref-trim removal in 2.0.1, trailing-audio guard), audiobook per-sentence cache playbook, dub translation reflect-loop + glossary, RTVC migration positioning. * docs(research): add voicebox (jamiepine) — the direct competitor read --------- Co-authored-by: mergetest <test@local> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> fix(watermark): bound AudioSeal memory — long audio embeds/detects in 30s chunks (#1045) (#1046) A multi-minute generation pushed the whole waveform through the AudioSeal generator in one call; its activation memory grows linearly with length, and a reporter's 16 GB Windows box failed a single ~2.2 GB CPU allocation mid- generate (DefaultCPUAllocator: not enough memory). Embedding and detection now slice audio into ~30 s chunks (sub-second tails fold into the previous chunk), so peak memory is flat regardless of audio length. Detection keeps the best-confidence chunk, which also stops whole-file averaging from diluting spliced audio. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> fix(clone): ⊕ Insert popover opens below the script input instead of climbing out of the viewport (#1043) The popover was hard-anchored bottom-[60px] — always growing upward from the textarea. ScriptPanel's only mount (CloneDesignTab) puts that input at the very top of the panel, so the tag list (max-h 280px, including the CMU phoneme chips visible in the owner's screenshot) extended past the viewport top, unreachable and unscrollable. Anchored top-[calc(100%+6px)] instead: below the input, where the panel's topmost placement guarantees room in its one mount. Regression test locks the placement (top-anchored, bottom-[60px] banned). Co-authored-by: mergetest <test@local> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> feat(ui): global audio mini-player — waveform, seek, and time for every playback that had none (#1042) playBlobAudio-path audio (generate auto-play, profile/segment previews, story lines, gallery voices, Projects renders) played through a bare Audio()/AudioContext with no on-screen player; the #1032 stop pill was a stop-only band-aid with a fixed-overlay overlap quirk at 1440x900. - playback.js: claimTrackedPlayback extends the single-playback manager with label + seek/pause/resume transport and a timeupdate-driven track snapshot (currentTime/duration/paused/peaks); claimPlayback stays as the thin wrapper, single-playback invariant unchanged. - media.js: every playBlobAudio path registers tracked - element paths get real seek/timeupdate, the Tauri Web Audio path gets offset-based seek + suspend/resume pause, and peaks are computed once from the blob/decoded buffer already in hand (never refetched). onDone(reason) lets callers chain (stories) or reset card state (gallery). - GlobalAudioPlayer.jsx: persistent bottom bar (only for source 'output' — exact pill exclusion semantics) with peaks canvas, click/drag/keyboard seek, play/pause, elapsed/total, label, stop. - Layout: the bar is a real grid row (row 3) above the LogsFooter, mirroring the footer's in-flow fix — content physically ends at its top edge, so the pill's overlay-overlap class cannot recur; fixed overlays anchored above the footer also clear --audio-dock-height. Verified headless (Chromium 1440x900 + 1000x700, isolated vite, all :3900 traffic intercepted): bar meets footer edge-to-edge, clears the nav rail, seek/pause/stop drive the owner callbacks. - Callers pass labels: "Generated audio" (useTTS/first-sound), profile name / segment text (useProfiles), story line (StoriesEditor), voice name (VoiceGallery/CommunityZone/ImportsZone), render title (Projects). VoiceGallery drops its bespoke copy of the Tauri playback detour; StoriesEditor line previews now actually play under WebKit (blob: media URLs never worked there) and are stoppable mid-chain. - PlaybackStopPill.jsx + its test deleted; intent migrated into GlobalAudioPlayer.test.jsx (appears on output/hidden when idle/stop works/excluded sources) plus transport coverage; playback.test.js covers the tracked API; playBlobAudioTracked.test.js covers the media wiring incl. onDone reasons; logsFooterInFlow.test.js now guards both bars' grid rows and the overlay anchor calc. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> release: freeze v0.3.15 — version bump, lockfiles, changelog (#1041) Co-authored-by: mergetest <test@local> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> fix(tts): model-load time stops eating the generate timeout budget (#1039) * fix(tts): model-load time stops eating the generate timeout budget (#1033, #1037) The generate guard (OMNIVOICE_GENERATE_TIMEOUT_S, 300s) wrapped the adapter's lazy _ensure_loaded() — weight download included — together with the synthesis. A cold first request burned the whole window on the download and died with the VRAM-guidance 503; #1014's T4 verification measured it (0% GPU util for the full 300s), and #1033 + #1037 match the signature. New public TTSBackend.ensure_ready() (dispatches to the adapter's _ensure_loaded when present) runs FIRST under the model-load budget (OMNIVOICE_MODEL_LOAD_TIMEOUT, 1200s) in both /generate's adapter path and /v1/audio/speech — the same load/generate split get_model() already gave the native engine. Warm engines no-op. A load exceeding its own budget 503s with load-specific text pointing at Settings → Models, never the misleading 'too heavy for compute' guidance. Tests: end-to-end class test (load slower than a tiny generate budget but inside the load budget → succeeds; fail-before verified), the stalled-load error path, and the base-hook dispatch. * changelog entry for the load-budget split (#1033, #1037) * catch the builtin TimeoutError base — reload-proof class identity (CI-only miss) --------- Co-authored-by: mergetest <test@local> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> docs: link the community Colab notebook (#1038) (#1040) @shakib30 built and tested a working Colab notebook for the project and offered it upstream. Linking it from the README (community- maintained, credited) makes the no-local-GPU path discoverable without taking on notebook maintenance in-repo. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> fix(studio): restore Clear History, stoppable auto-play preview, + cached ref transcripts (#1032) (#1036) Three-part fix for the v0.3.5-comparison report: 1. Perf: since v0.3.6 (#308), a clone reference without a stored transcript triggered a FULL ASR model load + transcribe on every /generate — get_active_asr_backend() builds a fresh whisper backend per call. Measured live: 92.7s wall vs 14.9s of actual TTS. Now the first auto-transcript is persisted onto the (unlocked, clone-kind) profile row, and transcribe_reference caches results by audio content hash (bounded LRU, no model/VRAM held), so the cost is paid once per clip, not per request. User-typed transcripts are never overwritten; locked/design profiles are excluded from the persist. 2. Clear History: the workspace UX overhaul (#374) moved history into the right-side WorkspaceHistory panels and dropped the old Sidebar's clear-all control (the Sidebar is now hidden in every mode). Both the Voice and Dub panels get a scoped Clear History button wired to the existing DELETE /history and /dub/history endpoints, with the same confirm dialog the Sidebar used. 3. Auto-play: the finished-render playback (playBlobAudio) has no on-screen player and the only stop lived in the Voice ActionBar's CTA morph — unstoppable from the Dub workspace, profile pages, or after navigating away. A global PlaybackStopPill now appears for any 'output' playback on every page. The existing Settings → Appearance "Auto-play preview" pref (#667) now also gates the generate path, as its label always promised (default ON — no behavior change). Co-authored-by: mergetest <test@local> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> fix(api): /v1/audio/speech honors num_step + guidance_scale instead of silently dropping them (#1014) (#1035) A contributor's measured Tesla T4 verification (PR #1014) caught that POST /v1/audio/speech accepted num_step/guidance_scale in the JSON body with a 200 OK and discarded both (pydantic's default extra=ignore) — API callers could never reach the model's documented quality preset (num_step=32) through the OpenAI-compatible surface, while the native /generate exposes both as form fields. Both are now declared as validated optional extensions (num_step 1-128, guidance_scale 0-20) and passed through to the engine's generate() kwargs — omitted means absent (engines that don't accept the kwargs never see a stray None), exactly like the existing duration/seed extensions. Tests: passthrough reaches the engine kwargs; omitted stays absent. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> docs(agents): add verified Tesla T4 (16GB) inference notes (#1014) * docs: add AGENTS.md with verified Tesla T4 (16GB) inference notes Documents two things found while verifying inference on a real T4: 1. Cold-cache first /v1/audio/speech call can hit the 300s OMNIVOICE_GENERATE_TIMEOUT_S because the checkpoint download happens inside that budget — workaround via existing POST /models/install or raising the timeout, no code change needed. 2. The OpenAI-compatible endpoint silently ignores num_step/guidance_scale (schema doesn't declare them) — use native /generate for those. Also documents the T4 acceleration checklist (dtype/attention/int8/CUDA graphs) and measured VRAM (peak 2.05GB). No code changes. * fix(docs): make /models/install workaround command actually executable Addresses Greptile review: the instruction omitted the required repo_id body field (InstallModelRequest rejects an empty body). * fix(docs): correct port in /models/install example (3900, not 8000) The app serves on port 3900 (confirmed: /health returns 200 there, connection refused on 8000). Verified the exact corrected curl command returns 200 {"status":"install_started",...}. * move T4 notes to docs/hardware-notes-tesla-t4.md — AGENTS.md is the auto-loaded agent-instructions filename --------- Co-authored-by: mergetest <test@local> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> fix(clone): bound the ref-text re-transcribe like every other ASR dispatch (#730) (#1031) The re-transcribe added for the (ref_audio, ref_text) mismatch fix is dispatched with a bare run_in_executor(_gpu_pool, ...), and refine_ref_text calls asr_backend.transcribe() directly. Its try/except catches a raised error but not a *hang* — a wedged whisperx/CTranslate2 transcribe (#730) holds the GPU-pool worker forever. On a <=10 GB card the pool is 1 worker, so that starves every later GPU job into the misleading "can't reach the local backend", and there's no ping on the await so the EventSource drops. Route both refine dispatches (per-speaker and per-segment) through the same run_transcribe_guarded the rest of dub_core.py already uses (the chunk loop and the whole-file "Dub" transcribe). On timeout it resets the pool and raises ASRTimeoutError; keep the original clones, matching refine_ref_text's own "failure is a strict no-op" fallback. Adds a repro test: refine_ref_texts dispatched raw is unbounded on a hang; through the guard it times out and falls back to the original ref_text. Co-authored-by: stronghamjji <289942360+stronghamjji@users.noreply.github.com> feat(skills): installable Agent Skills — npx skills add debpalash/omnivoice-studio (#1034) Two skills in the standard skills/<name>/SKILL.md layout (vercel-labs/ skills CLI; listed on skills.sh via install telemetry): - omnivoice — teaches any agent (Claude Code, Cursor, Codex, …) to speak and transcribe through the user's LOCAL install via the OpenAI-compatible API at localhost:3900: health preflight, TTS with cloned-voice-profile discovery via /v1/audio/voices, STT with srt/vtt subtitle formats, and the local-first rule (never silently fall back to a cloud API). - oss-maintainer — the maintainer methodology this repo is actually run with, distilled from real sessions: absorbed-or-declined queue discipline, check-the-PR-queue-before-implementing, root-cause → fix-the-class → regression-test, structural merge gates with flaky-vs-real judgment, the release protocol, and thank-contributors-specifically. Every endpoint/flag in the omnivoice skill verified against backend/api/routers/openai_compat.py and the README's API section. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> fix(update): app updates stop uninstalling user-added engines — drift sync goes --inexact (#1029) (#1030) Every app update whose uv.lock changed ran `uv sync --frozen` to reconcile the venv (#307 drift path) — and uv sync's exact mode UNINSTALLS every package not in the lockfile. That silently deleted user-pip-installed optional engines (voxcpm, kittentts — packages the app's own Settings → Engines hints tell users to install into this venv) on every single update. Reported as "VoxCPM2 is automatically uninstalled after updating Studio." Fix: the routine drift sync now carries --inexact — locked deps are still installed/upgraded exactly per the lockfile, but extras the user added on purpose are left alone. Deliberate asymmetry: the venv-REPAIR sync stays exact, because repair runs when the venv is broken and a user-installed extra is a plausible cause — healing must restore the known-good locked state. First-run syncs are untouched (a fresh venv has no extras; exact == inexact there). Both sync arg sets are now named constants with contract tests pinning the asymmetry, so neither side can silently regress. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> docs(faq): honest ElevenLabs comparison — where each wins, and why dub quality varies (community question) (#1028) Asked directly on Discord ('how it compares to something like 11 labs in quality?'). The old answer ('yes, comparable for most use cases') oversold — the honest version names where ElevenLabs still wins (out-of-the-box English polish/consistency) and where OmniVoice is genuinely competitive (cloning from clean references, 646 languages, structural advantages), plus the dubbing-specific truth another same-day report surfaced: a dub is a chain, and incoherent output usually traces to transcription quality on the user's source audio — with the check-the-original-text-first debugging step that actually helps. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> release: freeze v0.3.14 — version bump, lockfiles, changelog (#1027) Co-authored-by: mergetest <test@local> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> feat(asr): ASR engines get the same Settings picker TTS has (env var still wins) (#1026) Settings → Engines now stacks one pinned Engine Compatibility Matrix per family (TTS, ASR, LLM) instead of a single TTS-titled table with the other families tucked behind a low-discoverability tab. The backend select/prefs path (family="asr" → prefs.asr_backend, env > prefs > auto-detect) already worked but was unexercised and undocumented — it's now locked by API and resolution-order tests, and README + the openai-compat-asr doc stop promising a picker that didn't exist / denying one that now does. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> docs: CLAUDE.md refresh — de-rot versions, compress shipped stack research, replace the dead GSD gate (#1025) Three classes of staleness that actively misled agent sessions: - The Project section hardcoded "latest stable v0.3.5 / main at v0.3.6" — eight releases behind. Now points at the sources of truth (frontend/package.json, the Releases page) and documents the current AUTO_VERSION_BUMP-off holding behavior instead of a version literal that rots every release. - ~165 lines of May-2026 stack research for five capabilities that have ALL since shipped (HF-token panel, prefilled-URL bug reporting, uv mirror fallback, Supertonic-3, in-repo docs). Compressed to the durable don'ts it established (no telemetry endpoints, no app-side GitHub tokens, no setx, no MkDocs, no hf_transfer) plus a pointer to prefer what's already pinned. - The GSD Workflow Enforcement gate referenced /gsd-quick//gsd-debug/ /gsd-execute-phase skills that exist nowhere in this environment; the owner explicitly chose direct edits over restoring them (2026-07-08). It cost a real mid-task detour when a subagent correctly refused to work under an unsatisfiable rule. Replaced with the owner decision and the working conventions that actually bind (merge gating, check-the-PR-queue-first). 244 → 83 lines. GSD section markers preserved so the generating tool can still find its blocks. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> fix(linux): AppRun workaround detection reads the BUNDLED WebKitGTK version, not the host's (#961 follow-up) (#1024) The launcher decided whether to export WEBKIT_DISABLE_COMPOSITING_MODE by asking the host's pkg-config — but LD_LIBRARY_PATH makes the BUNDLED libwebkit2gtk the one that actually runs, so on any machine where the two diverge the detection read the wrong number. This was the second bug identified during #961's investigation (the reporter built from source, so their dev packages answered pkg-config with a healthy version while the shipped bundle ran an older lib) and was explicitly deferred in #1007 as not-safely-fixable at runtime. The fix makes it knowable by construction instead: inject-apprun.sh runs at bundle time ON the build host whose libwebkit2gtk gets bundled, so it stamps that version into .bundled-webkitgtk-version inside the AppDir. AppRun reads the stamp first and only falls back to host pkg-config for bundles predating it. Empty/unreadable stamp fails safe (workaround on), same philosophy as the missing-pkg-config path. Tests: 3 new cases in AppRun.test.sh — marker-beats-host in both directions (broken-marker/healthy-host and the #961 inversion, healthy-marker/broken-host) plus empty-marker fail-safe. Also wires AppRun.test.sh into pytest (tests/test_apprun_launcher.py) — it was previously run by NO CI job, so the launcher could regress silently. Also documents Windows install-to-another-drive behavior in docs/install/windows.md (#938): local drives work via the wizard's directory picker, mapped network drives are a Windows Installer limitation, and the data directory moves independently of the app. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> release: freeze v0.3.13 — version bump, lockfiles, changelog (#1023) Co-authored-by: mergetest <test@local> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> docs: correct troubleshooting §16 — mic bug was a missing entitlement, not an upstream limitation; changelog for #1016/#1020/#1021 (#1022) troubleshooting.md §16 claimed the macOS microphone-permission bug was an unresolved upstream Tauri/wry limitation with no available fix. That was wrong: @MahdiHedhli read the wry/tauri sources more carefully and found the real cause — Tauri's Hardened Runtime default blocks mic hardware access without com.apple.security.device.audio-input in the bundle's entitlements, which also explains why TCC never listed the app. Their fix (#1016) is merged; §16 now documents the real mechanism, credits the correction, and keeps the record-elsewhere workaround for users on ≤0.3.12 builds. Also brings CHANGELOG [Unreleased] current for the three merges that lacked entries: #1016 (mic fix), #1020 (shutdown wait 3s→20s), #1021 (CI flaky-trio root cause + guard). Co-authored-by: mergetest <test@local> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> test(ci): root-cause and neutralize the flaky trio — a leaked torch fp16 default dtype (#1021) test_effects_chain / test_generation_audio_guard / test_persona_bundle failed intermittently on CI (never locally) with identical signatures across three unrelated PRs today (#1002, #1019, #1016) — costing a full CI cycle per occurrence and repeatedly muddying merge decisions. Root cause, confirmed by local reproduction: a leaked torch.set_default_dtype(torch.float16) from some earlier test in the CI-Linux ordering. The smoking gun was test_generation_audio_guard's observed 0.0999755859375 — exactly float16(0.1), i.e. torch.tensor([0.1, …]) built under a leaked fp16 default. Reproducing with a simulated polluter locally produced the trio's exact failures: Pedalboard refuses fp16 audio outright ("only supports 32-bit and 64-bit floating point") and silently returns unmodified audio for every preset, so test_effects_chain's preset outputs compare identical; and the fp16 tensor value breaks the sanitize approx-check. Fix: an autouse conftest guard (same philosophy as the existing LLM-state isolation guard, #878) that checks torch's default dtype after every test, resets any leak to float32, and emits a UserWarning naming the offending test's nodeid — so the actual CI-only polluter identifies itself in the next CI log instead of being chased blind. Regression test: a deliberate-leak pair proving reset-between-tests. Fail-before/pass-after verified: with the guard stashed, a simulated polluter + the trio reproduced 2/3 failures locally with the exact CI signatures; with the guard active, 73/73 pass and the warning names the polluter. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> fix(macos): add microphone/camera entitlements so TCC ever sees a request (#1016) Root cause of #1013 (macOS: "Microphone access denied" but OmniVoice never appears in Privacy & Security → Microphone to enable it): Tauri's macOS bundle config defaults `hardenedRuntime` to true, and Hardened Runtime blocks camera/microphone hardware access unless the matching entitlement is present — regardless of Info.plist's NSMicrophoneUsageDescription (that only supplies the *prompt text*, it isn't itself the grant) and regardless of wry's own WKUIDelegate already granting the request at the WebKit/JS layer (WryWebViewUIDelegate::request_media_capture_permission unconditionally calls WKPermissionDecision::Grant — confirmed by reading wry 0.55.1's source; that part was never the problem). With Hardened Runtime on and zero entitlements, TCC never registers a request at all, which is exactly the reported symptom: nothing to enable because the OS never saw a legitimately-entitled process ask. This also explains the workaround in #1013 and its comments (launching the raw binary from Terminal works, but as Terminal's identity, not the app's) — Terminal is a properly entitled, hardened-runtime process; the ad-hoc/unentitled app binary isn't. Adds src-tauri/entitlements.plist (com.apple.security.device.audio-input, plus com.apple.security.device.camera matching the forward-looking NSCameraUsageDescription already in Info.plist) and wires it in via tauri.conf.json's bundle.macOS.entitlements. Also corrects the stale "nothing to do here" module comment in lib.rs that documented the incomplete assumption this bug falsified. Verified: built a debug .app (`tauri build --debug --bundles app`) and diffed `codesign -dv --entitlements -` before/after this change — the entitlements dictionary goes from absent to containing exactly the two keys added here, alongside the runtime (Hardened Runtime) flag that was already on. `cargo test` — 60 passed, 0 failed. fix(backend): shutdown wait bound 3s→20s — post-merge review finding on #1002; absorb #1015's design-path test (#1020) Greptile's review of the merged #1002 flagged a real residual gap: a cold transformers import alone can exceed the 3s shutdown wait, and cancelling the asyncio task doesn't stop the underlying OS thread — so quitting during an unusually slow preload could still let shutdown report "done" while that thread was alive, the exact #1000 class with lower odds. Python cannot forcibly kill a running thread, so no finite bound eliminates this outright; 20s shrinks the window from "any preload" to "an unusually slow cold-import," the practical ceiling before a long shutdown becomes its own complaint. New source-level contract test pins the production bound at ≥15s so a future edit can't quietly shrink it back without deliberate consideration. Also absorbs the one test case from community PR #1015 (superseded by the earlier-merged #1017, which duplicated it — my fault for not checking the PR queue) that the merged version lacked: the design/instruct path with no ref kwargs at all stays untouched by the ref_text forwarding fix. Co-authored-by: mergetest <test@local> Co-authored-by: MahdiHedhli <noreply@github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> fix(ui): dub editor play button no longer sticks disabled; remove donate heart from nav rail (#1019) WaveformTimeline's play button (disabled={!ready}) stayed permanently disabled whenever the initial WaveSurfer decode failed and the component fell back to loading pre-computed peaks. The waveform still rendered fine from those peaks (nothing looked visibly broken), but `ready` was only ever flipped by the 'ready' event re-firing on that recovery load — which this component's own error-handling never actually confirmed, just assumed. Each of the three fallback ws.load() calls now explicitly confirms readiness once it settles (via .then()/ .catch(), or the existing synchronous-throw catch), instead of hoping the event fires again. Regression test: WaveformTimeline.readyFallback.test.js — a source-level contract guard (driving a real decode-failure/recovery sequence through jsdom is brittle, same house pattern as the sibling WaveformTimeline.unlock.test.js) asserting every fallback load in the error handler is followed by an explicit setReady(true). Also removes the "Support OmniVoice" heart button from NavRail — the donate page stays reachable from Settings' footer and the Contact page. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> fix(voice): free-text instruct now filtered before every generate/save call (#1010) (#1018) buildDesignInstruct() already keeps Studio's design/clone generate calls (useTTS.js) from round-tripping a 400 "Unsupported instruct items" — it filters free-text against the active engine's supported vocabulary client-side, with a toast instead of a failed request. Three other call sites built their own instruct string directly and skipped it entirely: - handleSegmentPreview (Dub tab's per-segment preview) — instruct comes straight from segment/preset data; a preset's raw attrs merged with a free-text style field can carry phrases outside the vocabulary. - handleSaveProfile / handleSaveHistoryAsProfile — both always create a kind='clone' profile; the backend only sanitizes instruct on save for kind='design' (see profiles.py's heal_design_instruct branch), so a clone profile could silently persist an unusable instruct and then 400 every single time it's later used to generate. All three now filter through the same buildDesignInstruct({}, instruct) call useTTS.js's own clone path already uses, with the same unsupported/duplicate-item toasts. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> fix(tts): mlx-audio CSM cloning drops ref_text, breaking every clone attempt (#1012, #1013) (#1017) MLXAudioBackend.generate() reads voice/ref_audio/language/speed from its kwargs but never extracted ref_text — it was built, then silently never passed through to self._model.generate(). CSM (sesame.py) only builds its cloning context when BOTH ref_audio AND ref_text are present; with ref_text missing, the context list stays empty and indexing into it raises "IndexError: list index out of range" deep inside mlx-audio, instead of the clone ever being attempted. Voice cloning on the CSM engine could never have worked as shipped. generation.py already threads ref_text all the way through — even auto-transcribing it via the GPU pool when the caller supplies ref_audio without one (~line 780) — so the value was always available in kwargs; it just never survived the crossing into this specific backend. Reported with the precise root cause and a working fix (community member independently diagnosed and patched it locally, confirmed working on MPS/0.3.12). Two-line fix: extract ref_text and pass it through when both ref_audio and ref_text are present (guards against passing an orphaned ref_text with no accompanying audio to engines that don't expect it). Tests: tests/test_engines.py — ref_text is passed through when paired with ref_audio, omitted when ref_audio is absent. Also documents the second bug from the same report (#1013): macOS microphone permission never prompts, so OmniVoice never appears in System Settings to grant access. Root-caused to an unresolved upstream Tauri/WebKit limitation (WKWebView's requestMediaCapturePermissionFor delegate — wry#1195, tauri#11951, fix wry#1196 still open/unmerged, no released version to bump to) — not something fixable here without an unverified native Rust/WKWebView hack this session has no way to test. Documented in docs/install/troubleshooting.md with the confirmed workaround (record elsewhere, upload the file). Co-authored-by: mergetest <test@local> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> fix(gallery): surface the real error instead of a generic guess (#1009) Found while triaging Discord: a community member (lehoangan227) hit "Could not create that voice — the engine may be loading" trying to use an archetype from the Gallery. That message is hardcoded and shown for ANY failure — the actual cause (a 500, a validation error, anything) is caught and discarded. api/client.js's ApiError already builds a clean, user-facing message for every failure mode (HTTP status + backend detail, a network failure, or a detected backend crash) — this codebase's own established convention elsewhere is to interpolate that message via `{{message}}` (see BatchQueue.jsx, Settings.jsx, ToolsPage.jsx). The Gallery's own catch blocks just weren't following it. Fixed the whole class across VoiceGallery.jsx (use/preview), CommunityZone.jsx (add-to-voices, whose catch clause didn't even bind the error), and ImportsZone.jsx (search/upload/save/delete/trim — handleDelete previously failed completely silently, no message at all). All now interpolate the real error message, matching the gallery.download_failed key that already did this correctly a few lines away. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> fix(dub): cross-language dub no longer speaks source-language reference text verbatim (#1004) (#1008) extract_speaker_clones/extract_segment_refs pair each audio slice (cut at ASR segment timestamps) with that segment's own `text` field, on the assumption the two agree. They routinely don't — Whisper (and friends) frequently drift on segment boundaries: a trailing word audible in [start, end] but missing from text, or vice versa. When the (ref_audio, ref_text) pair disagrees, zero-shot TTS prompt-priming breaks down and the clone can emit the mismatched reference text itself instead of the target-language line it was asked to speak — reported with an exceptionally clear root-cause diagnosis and a working A/B repro (matched pair: clean on the first try; mismatched pair: wrong language 6/6 times). Fix (as proposed in the report): re-transcribe each written reference clip via the already-loaded, already-warm active ASR backend and use that transcript as ref_text — this guarantees the pair matches by construction, independent of whether the original segment text was ever right. Falls back to the original text on any re-transcribe failure or empty result — never a regression from current behavior, only ever a fix. New services.speaker_clone.refine_ref_text (single clip, unit-testable against a duck-typed fake ASR backend) and refine_ref_texts (batch — one executor round-trip per whole clones/seg_clones dict rather than one per reference). Wired into dub_core.py's two clone-extraction call sites, routed through _gpu_pool to match the established convention for ASR-backend calls (the model is mid-lifecycle: TTS is offloaded, ASR is loaded and exclusive, right where the existing per-chunk transcribe calls already run on this same pool). Tests: tests/test_speaker_clone_purity.py — 6 new cases covering the mismatch-correction path, ASR-failure fallback, empty-transcript fallback, no-backend no-op, and batch behavior (one failing entry doesn't affect the others). Full backend suite: 2412 passed, 0 failed. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> fix(ci): bump Linux release runner to ubuntu-24.04, fixing stale bundled WebKitGTK (#961) (#1007) The AppImage bundles whatever libwebkit2gtk-4.1-dev the build runner's apt repos resolve at build time (see the "Linux system deps" step) — AppRun's LD_LIBRARY_PATH then makes that bundled copy take priority over the host's system WebKitGTK at runtime. ubuntu-22.04's version was stale relative to what current distros (Ubuntu 24.04+, Fedora 44) ship, which is why a from-source build (linking straight against the host's healthy system library) worked fine on the exact machine where the shipped AppImage white-screened — the released binary was running an older, buggier WebKitGTK under the hood regardless of the host. Bumped the Linux release matrix entry to ubuntu-24.04, and ci.yml's Tauri shell-check job to match (its own comment already says "Mirror release.yml" — now it actually does, so a green PR check accurately predicts the release build will also succeed). Raises the AppImage's glibc floor from 2.35 to 2.39 (Ubuntu 24.04+) — README's system-requirements table corrected from the now-false "Ubuntu 20.04+" claim. No reports of anyone on a pre-2022 distro. This does not fix the AppRun launcher's separate, related bug (its WebKitGTK-version auto-detection reads the *system's* pkg-config version, not the version actually bundled and running) — that would need a reliable way to read the bundled .so's version from within the AppImage, which isn't straightforward (WebKitGTK's soname doesn't map 1:1 to its release version) and isn't verifiable without a real Linux build environment to test against. Left as a known, separate gap. Cannot be verified from here on a real Ubuntu 26.04 machine — shipped on the strength of the root-cause diagnosis, pending the reporter's confirmation. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> fix(backend): quitting mid-preload no longer reports a clean shutdown while a GPU-pool thread is still importing (#1000) (#1002) A user-pasted backend log revealed the real cause behind a class of 'can't reach backend' reports: three rapid restart cycles, each ending with 'Shutdown: done.' immediately followed by a 'Model loading failed: Could not import module AutoFeatureExtractor' error. That error text is transformers' own generic lazy-import wrapper (import_utils.py's _LazyModule.__getattr__), not a real dependency problem — pyproject.toml already pins transformers/torch/torchaudio/soundfile/librosa as core, non-optional deps, and the same venv loaded the model successfully 90 seconds later in the same log. Root cause: preload_task and capture_preload_task were created at startup but never referenced in the lifespan shutdown block — idle_task and worker_task got cancelled-and-awaited, the preload tasks were simply abandoned. Cancelling an asyncio task awaiting run_in_executor() can't stop the underlying OS thread once it's inside blocking import/load work, so 'Shutdown: done.' logged while a GPU-pool thread was still mid-Version: ImageMagick 7.1.2-25 Q16-HDRI aarch64 037e46295:20260604 https://imagemagick.org Copyright: (C) 1999 ImageMagick Studio LLC License: https://imagemagick.org/license/ Features: Cipher DPC HDRI Modules Delegates (built-in): bzlib freetype heic jng jpeg lcms ltdl lzma png tiff webp xml zlib zstd Compiler: clang (21.0.0) Usage: import [options ...] [ file ] Image Settings: -adjoin join images into a single multi-image file -border include window border in the output image -channel type apply option to select image channels -colorspace type alternate image colorspace -comment string annotate image with comment -compress type type of pixel compression when writing the image -define format:option define one or more image format options -density geometry horizontal and vertical density of the image -depth value image depth -descend obtain image by descending window hierarchy -display server X server to contact -dispose method layer disposal method -dither method apply error diffusion to image -delay value display the next image after pausing -encipher filename convert plain pixels to cipher pixels -endian type endianness (MSB or LSB) of the image -encoding type text encoding type -filter type use this filter when resizing an image -format "string" output formatted image characteristics -frame include window manager frame -gravity direction which direction to gravitate towards -identify identify the format and characteristics of the image -interlace type None, Line, Plane, or Partition -interpolate method pixel color interpolation method -label string assign a label to an image -limit type value Area, Disk, Map, or Memory resource limit -monitor monitor progress -page geometry size and location of an image canvas -pause seconds seconds delay between snapshots -pointsize value font point size -quality value JPEG/MIFF/PNG compression level -quiet suppress all warning messages -regard-warnings pay attention to warning messages -repage geometry size and location of an image canvas -respect-parentheses settings remain in effect until parenthesis boundary -sampling-factor geometry horizontal and vertical sampling factor -scene value image scene number -screen select image from root window -seed value seed a new sequence of pseudo-random numbers -set property value set an image property -silent operate silently, i.e. don't ring any bells -snaps value number of screen snapshots -support factor resize support: > 1.0 is blurry, < 1.0 is sharp -synchronize synchronize image to storage device -taint declare the image as modified -transparent-color color transparent color -treedepth value color tree depth -verbose print detailed information about the image -virtual-pixel method Constant, Edge, Mirror, or Tile -window id select window with this id or name root selects whole screen Image Operators: -annotate geometry text annotate the image with text -colors value preferred number of colors in the image -crop geometry preferred size and location of the cropped image -encipher filename convert plain pixels to cipher pixels -extent geometry set the image size -geometry geometry preferred size or location of the image -help print program options -monochrome transform image to black and white -negate replace every pixel with its complementary color -quantize colorspace reduce colors in this colorspace -resize geometry resize the image -rotate degrees apply Paeth rotation to the image -strip strip image of all profiles and comments -thumbnail geometry create a thumbnail of the image -transparent color make this color transparent within the image -trim trim image edges -type type image type Miscellaneous Options: -debug events display copious debugging information -help print program options -list type print a list of supported option arguments -log format format of debugging information -version print version information By default, 'file' is written in the MIFF image format. To specify a particular image format, precede the filename with an image format name and a colon (i.e. ps:image) or specify the image type as the filename suffix (i.e. image.ps). Specify 'file' as '-' for standard input or output., and interpreter finalization tore down module state under it — producing exactly this misleading error. Fix: extract the existing cancel+bounded-await pattern into _cancel_and_await_tasks() and apply it to all four background tasks, not just two. An early-stage load (still importing, not yet mid weight- download) now gets a real chance to finish before shutdown proceeds; a load genuinely deep in blocking work still times out at the same 3s bound, and _reset_gpu_pool() abandons it same as before. Also: both error handlers around this path logged only str(exc), discarding __cause__ — added exc_info so a future incident (even one this fix doesn't fully prevent) surfaces the real underlying error instead of the misleading generic wrapper text. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> feat(asr): generic OpenAI-compatible transcription backend (#877) (#1003) First slice of the community's two-track proposal for #877: a generic OpenAI-compatible ASR backend that works TODAY, without waiting on transformers to ship a direct Qwen3-ASR integration (tracked separately, still blocked upstream). Points OmniVoice's transcription at any server exposing POST /v1/audio/transcriptions — a self-hosted Qwen3-ASR/ FunASR/SenseVoice server, or OpenAI's own API. - New OpenAICompatASRBackend (backend/services/asr_backend.py): a pure network client, no local model, no install. Prefers response_format=verbose_json for real per-segment timestamps, degrades to plain text (matching MoonshineASRBackend's shape) when a minimal server rejects that format. Never leaks a raw SDK/httpx exception to the caller (#977 convention) — wraps network/auth failures in a clean, actionable RuntimeError naming the server. - Settings persist via the same encrypted-secret convention as services/llm_providers.py (settings_store.set_secret for the API key — Fernet-encrypted, never a .env row, never echoed back; get_text/ set_text for base_url/model). New GET/PUT /api/settings/ asr-openai-compat, loopback-gated like every other settings route. - Frontend: a small settings panel (Settings → Models) mirroring HFMirrorPanel's exact structure. No ASR engine picker exists yet for ANY ASR backend (only TTS has one) — activating this engine still needs OMNIVOICE_ASR_BACKEND=openai-compat-asr; documented plainly rather than pretending otherwise. - README's ASR Engines table (9 → 10 engines) and docs/features.yaml's drift-checker inventory updated; the '9 engines, all fully local' claim corrected since this one genuinely isn't. - docs/engines/openai-compatible-asr.md: setup steps + an explicit privacy note (unlike every other ASR engine, audio leaves the machine to whatever server is configured). Regression tests: tests/test_asr_openai_compat_877.py (12 tests) — is_available() gating, verbose_json + plain-text response adaptation, network-failure error hygiene, SDK retry disabling, and the settings endpoints' persist/mask/clear-vs-unchanged semantics. Fixed two real full-suite-only failures found during verification (not brushed aside): the API route inventory snapshot needed regenerating for the two new routes, and this file's own tests had a module- staleness bug — a collection-time settings_store import went stale relative to a test-time-fresh fixture when another test elsewhere in the ~2400-test suite reimports the module — fixed by making settings_store itself a fixture resolved at test-run time, same lesson already applied to tests/test_mm2_lifecycle.py earlier this session. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> release: freeze v0.3.12 — version bump, lockfiles, changelog (#1001) 19-issue GitHub sweep: 16 PRs merged since v0.3.11, most fixing reports filed same-day or in the days prior. Highlights: MLX-Audio's 6 other curated models are finally selectable (was silently stuck on Kokoro regardless of what was downloaded), first-run no longer dead-ends behind restricted networks or corporate TLS-inspecting proxies, dubbing/batch TTS honor your active engine selection, and a run of sharp community diagnoses (ROCm wheel index, Windows dictation focus-steal, a genuine frontend crash regression) got fixed largely because reporters did the hard diagnostic work themselves. Full backend suite: 2390 passed, 0 failed. Full frontend suite: 918 passed, 0 failed. Version lockstep (tests/test_app_version.py): 6/6 passed. Docker frozen-lockfile parity (bun install --frozen-lockfile): clean, no drift. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> fix(diagnostics): TTS generate timeout message tells you to Flush/Unload (#939) (#999) The guard itself (#851/#896) is working as designed — this is a message gap, not a logic bug. The GPU-branch timeout guidance explained VRAM contention but never mentioned the Flush/Unload action that actually resolves it, even though: (a) that action already exists (POST /system/flush-memory, wired to the header's Flush button), and (b) the sibling ASR-timeout guard's message already recommends it verbatim (asr_backend.py's _CUDA_VRAM_BUDGET_GB guidance). The maintainer ended up manually explaining 'Settings → Models → Flush caches / Unload' in an issue thread reply — information the error message should have carried itself. String-only change, no control-flow touched, mirrors the exact precedent of #896 (a guidance-only change to this same function). Co-authored-by: mergetest <test@local> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> fix(diagnostics): filter Tauri's benign IPC-fallback warning from frontend log capture (#975) (#998) On some Windows configurations, Tauri's custom-protocol IPC probe fails once at startup and Tauri logs a console.warn before silently — and successfully — falling back to postMessage + WebSocket. Fully functional, happens at most once per launch, and not a bug in our code (confirmed: this is Tauri's own internal fallback mechanism, structurally intentional across its recent 2.11.x releases, not something being actively patched upstream — so not bumping the framework speculatively for this). It IS real noise though: as a captured console.warn it spuriously flips the Settings > Logs footer's Frontend pill to "1 warning" on every affected Windows launch. Filtered at the capture source (consoleBuffer.js) rather than the display layer, so it never enters the ring buffer or a copied diagnostic dump either — narrowly scoped to this one known message prefix, not a general warning-suppression mechanism. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> docs(linux): mention yt-dlp as an optional prerequisite (#973) (#997) The preflight system check already warns in-app when yt-dlp is missing (Voice Gallery/Dub YouTube downloads fail without it), but the install docs never mentioned it — a user has to hit the in-app warning first instead of seeing it up front alongside the other optional prereqs. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> fix(dictation): Windows pill no longer steals foreground focus (#982) (#996) Root-caused and verified against the actual code (community diagnosis in #982 was accurate): showing the dictation pill — an always-on-top top-level WebviewWindow — grants it Win32 foreground activation by default on Windows, which macOS doesn't do for a shown window. Nothing marked the pill non-activating, so on Windows the synthesized Ctrl+V from `simulate_paste` landed back in the pill instead of the app being dictated into. Code review also found a second, more direct culprit at the same call sites: the global-shortcut Pressed handler's `win.set_focus()` was only skipped on macOS (`#[cfg(not(target_os = "macos"))]`), so Windows explicitly focused the pill on top of the implicit Win32 activation; the tray "dictate" menu handler called `win.show(); win.set_focus();` unconditionally on every platform, including Windows. This is the Windows counterpart of #287 (macOS auto-paste — don't steal focus): same class of bug, same "pill must stay unfocused so the paste lands in the target app" intent (already stated in the `grant_webview_ media_permissions` module comment), different OS mechanism. Fix, mirroring #287's platform-cfg pattern: 1. WS_EX_NOACTIVATE applied to the pill's HWND once, right after creation (`mark_pill_noactivate`), via the `windows` crate pinned to the exact 0.61.x tauri itself already resolves to — Cargo.lock confirms this unifies to the same `windows 0.61.3` already in the graph, so `WebviewWindow::hwnd()`'s HWND and our Win32 calls share one type and no new crate version was added. 2. `ShowWindow(SW_SHOWNOACTIVATE)` (`show_pill_noactivate`) in place of `.show()` at the two dictation-trigger call sites (global shortcut + tray "Start Dictation"), since `.show()` alone still raced the style bit on some paths. 3. The explicit `set_focus()` calls at those same two sites are now skipped on Windows too, the same way they already were on macOS. macOS and Linux are untouched: the macOS cfg branch is unchanged, and the Linux branch of the `set_focus()` guard still runs exactly as before. The pill's auto-dismiss (`scheduleDismiss`/`dismiss` in CaptureWidget.jsx) was checked and is a plain unconditional setTimeout chain — it is not gated on any native focus-loss/paste-completion signal, so there's no independent bug to fix there. The "never dismisses" symptom is a consequence of the focus-steal, not a separate stall: once the pill wrongly held foreground for the whole session, hiding it later left Windows' foreground state inconsistent. With the pill never taking focus, the target app stays foreground throughout and there's nothing to reconcile. Win32 window-activation syscalls (`#[cfg(target_os = "windows")]`) can't run under `cargo test`/`cargo build` on this non-Windows CI runner, so the new `pill_noactivate_tests` module tests the pure flag math (`with_noactivate_style`) instead — platform-agnostic, runs everywhere, verified passing here. The actual HWND-touching code is logic-reviewed but UNVERIFIED on real Windows; the reporter offered to test a patched build, which is the recommended next step before this ships in a release. `cargo build` and `cargo test --lib` both pass (60/60 tests, including the 3 new ones); the pre-existing `setup.rs` unreachable_code warning (#286) is unrelated. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> fix(clone): voice-design panel no longer crashes on a partial vd_states shape (#983) (#995) Crash: DesignMethodPanel's optLabel() called val.replace(...) on an undefined category value — a regression fromf678e33, which swapped a safe plain template literal for an i18n lookup that assumes vdStates[key] is always a string. Both occurrences (the label kicker and the chip list) are now guarded, falling back to 'Auto' the same way the rest of the component treats an unset category. Root cause: vdStates could actually go partial in real usage. Selecting a design profile (useProfiles.js) or restoring legacy localStorage state (useAppData.js) applied the backend/stored vd_states object as-is, with no check that all 6 CATEGORIES keys were present — so an older client, hand-edited payload, or partial API write reproduced the crash on selection. Both call sites now run the restored object through mergeDescribedAttrs() (voiceInstruct.js), the existing completion helper already used for the "describe your voice" path, which fills any missing/unknown category with 'Auto'. useAppData.js also gained the typeof === 'object' guard useProfiles.js already had. Closes the class at the source: POST /profiles now completes vd_states against CATEGORY_ORDER (core/describe_voice.py, the same list the frontend's CATEGORIES mirrors) before persisting, so a design profile can never be *saved* with an incomplete shape regardless of which client wrote it — updated two existing tests whose fixtures asserted the old (partial) persisted shape. Regression tests: DesignMethodPanel render test with a partial vdStates input, a mergeDescribedAttrs unit test for the exact partial shape from the issue, and a backend test asserting POST /profiles fills all 6 keys. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> feat(engines): expose MLX-Audio's curated model picker (#981) (#994) mlx-audio multiplexes 7+ curated models (Kokoro, CSM, Qwen3-TTS, Dia, Chatterbox, MeloTTS, OuteTTS) behind a single "mlx-audio" backend id, but MLXAudioBackend resolved its active model ONLY from the OMNIVOICE_MLX_AUDIO_MODEL env var — invisible to Settings and unreachable without restarting the packaged app with that var set. A user who downloaded e.g. Llama-OuteTTS via Settings → Models had no way anywhere in the UI or API to actually load it; the backend silently kept using Kokoro. Fix: - MLXAudioBackend.__init__ now resolves its model via prefs.resolve("mlx_audio_model_id", env=..., default=...), mirroring active_backend_id()'s env > prefs > default order exactly. - get_active_tts_backend()'s switch-detection now also tracks the resolved mlx-audio model key, so a model-only change (same backend id) invalidates the cached instance and reconstructs it — no app restart needed to pick up a different curated model. - POST /engines/select gained an optional model_id field; for family=tts/backend_id=mlx-audio it validates against MLXAudioBackend.CURATED_MODELS (or a raw HF repo id, matching the class's existing tolerance) and persists it via prefs. - GET /engines now includes a curated_models roster + active_model_id on the mlx-audio entry only. - Settings → Engines renders a small model dropdown on the mlx-audio row, pre-selected to the active model, wired through selectEngine's new optional modelId argument. Regression coverage: prefs resolution + env override, cache invalidation on model-only switch, /engines/select 400s on an unknown model id and persists a valid one, curated_models present only on mlx-audio, and a new EngineCompatibilityMatrix vitest suite for the dropdown. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> fix(install): classify SSL handshake failures + trust the OS cert store (#976) (#992) Windows users behind a corporate/antivirus TLS-inspecting proxy got a raw `[SSL: SSLV3_ALERT_HANDSHAKE_FAILURE]` on every model install — the TCP connection reaches the server fine, but the handshake fails because the OS trusts the proxy's re-signed root CA and Python's bundled certifi CA list doesn't. A genuinely different failure mode from #984 (that was TCP-level unreachability to a blocked host, before any TLS negotiation). - backend/core/failure.py: new SSL_HANDSHAKE_FAILURE classification (handshake/cert-verify-failed/sslv3_alert/sslcertverificationerror substring markers) with an actionable hint, added to _CONTEXT_FREE_HINT_CLASSES so append_hint() (already called by setup/download.py's install worker) surfaces it without further wiring. - backend/main.py: truststore.inject_into_ssl() at module level, before any huggingface_hub/requests/httpx network I/O — patches ssl.SSLContext to verify against the OS trust store instead of only certifi's bundled CA list. Not platform-gated (correctness improvement everywhere); wrapped in try/except so it never blocks startup. - pyproject.toml/uv.lock: truststore>=0.9 — pure Python, MIT, PyPA- maintained, zero transitive deps, same class of fix as socksio. Verified: uv lock --check + uv sync --frozen clean (lockfile diff is just the one new package); main.py imports cleanly; full backend suite passes; no hiddenimports entry needed (main.py is PyInstaller's direct entry script per backend.spec, so a top-level import traces normally — unlike socksio's case, which was httpx's internal lazy import). Co-authored-by: mergetest <test@local> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> fix(engines): mlx-audio no longer crashes on unsupported languages, error messages never leak raw exception internals (#977) (#993) Root cause: MLXAudioBackend.generate() blindly truncated the full language display name to two characters (language[:2].lower()), assuming an ISO code — 'Dutch' -> 'du', which crashed Kokoro's vendored pipeline's internal assertion (assert lang_code in LANG_CODES, (lang_code, LANG_CODES)) for any language whose first two letters didn't coincidentally match one of Kokoro's single-letter codes. The raw AssertionError's tuple-containing-a-dict args then leaked straight into the user-facing 500 message via two stacked f"...{e}" formatters in generation.py. - resolve_kokoro_lang_code() resolves against the AUTHORITATIVE ALIASES/LANG_CODES table read from the installed mlx_audio package (never a hardcoded guess), and only applies when Kokoro is the actual active curated model — other curated models (CSM, Dia, Qwen3-TTS, OuteTTS, ...) either ignore the kwarg or expect a different format, so Kokoro's strict validation doesn't wrongly reject them. Unsupported languages now raise a clear ValueError naming what Kokoro supports, which generation.py already converts to a clean 400. - _safe_exc_text() hardens both generic exception formatters in generation.py: if any element of an exception's .args is a container (dict/list/tuple/set), never interpolate str(e) raw — name the exception type and point at the log instead. Protects every current and future engine's generate() from leaking a raw container repr, not just this one Kokoro assertion. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> fix(engines): nemo-parakeet install hint stops recommending a shared-venv-breaking pip install (#974) (#991) The Engines page told users to run `pip install nemo_toolkit[asr]` for the NeMo Parakeet ASR engine. nemo_toolkit[asr]==2.7.3 hard-pins transformers>=4.57,<4.58, which is unsatisfiable alongside OmniVoice's own transformers>=5.3 requirement (needed by omnivoice/models/omnivoice.py for HiggsAudioV2TokenizerModel). A user who followed the hint ended up with a backend that wouldn't start (ImportError: cannot import name 'HiggsAudioV2TokenizerModel'). _INSTALL_HINTS["nemo-parakeet"] in backend/services/asr_backend.py now states plainly that installing into the shared venv will break the backend, names the transformers conflict, and tells users to use a separate/dedicated Python environment instead — without implying a safe one-line fix or an isolated-venv env var exists (unlike dots-tts/moss-tts-v15/confucius4-tts, nemo-parakeet has no isolated venv option yet; that's a separate, larger follow-up). Also adds one sentence to docs/install/troubleshooting.md's existing "engine venv clash" section (#11) pointing at the same class of issue on the ASR side, and a regression test asserting the hint never again contains the literal bare `pip install nemo_toolkit[asr]` string. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> docs(linux): stop advertising a .deb package that isn't published (#990) README's Quickstart badges linked a 'Download Debian .deb' button straight to the releases page — but .deb bundling was deliberately dropped from release.yml (tauri-cli bug, 'Failed to create control scripts') and no release has ever shipped one. A community member investigating #961 confirmed this by checking the actual release assets. Users clicking that badge got a broken promise, not a package. Removed the badge; docs/install/linux.md's '## Install (.deb)' section now honestly states it's unavailable pending a tauri-cli fix, points to the AppImage as the supported path, and keeps the historical pre-v0.3 .deb upgrade note (ffprobe conflict) since that's still relevant to existing installs. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> test(voice-design): lock in RTL/non-Latin script handling for the instruct field (#980) (#989) Issue #980 reported a raw 400 for Hebrew text ('שמואל') typed into the Clone tab's Style field. Investigated: this is the same failure class as #612 (Vietnamese free-text) and was already fixed when #612 landed in commit10b9d69(first released v0.3.8) — buildDesignInstruct() drops any unsupported free-text client-side before it ever reaches the backend's validator, regardless of script. The reporter was on v0.3.7, which predates that fix. No behavior change needed — only a regression test, since the existing Vietnamese test case covered Latin-script-with-diacritics but nothing exercised a right-to-left / non-Latin script specifically. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> fix(install): AMD ROCm torch reinstall targets rocm6.4, not rocm6.2 (#988) Community-diagnosed (issue #972, Kaihui-AMD): pyproject.toml pins torch==2.8.0, but the rocm6.2 wheel index only ever published up to 2.5.1 — the reinstall silently failed to resolve and fell back to the default CUDA build, which runs on CPU on an AMD GPU. The failure was correctly logged (bootstrap.rs's emit_log warning), just never actioned because the index itself couldn't succeed. rocm6.4 carries a matching torch==2.8.0 build. Docs updated with the corrected index plus a repo.radeon.com find-links path for users who want a driver-matched ROCm 7.2.x build the PyTorch index doesn't carry (OMNIVOICE_TORCH_INDEX only accepts a PEP 503 index, not find-links, so that's documented as a manual step). Co-authored-by: mergetest <test@local> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> fix(engines): dub and batch TTS honor the active-engine selection (#987) * fix(engines): dub and batch TTS honor the active-engine selection — with a real capability gate, not a silent OmniVoice fallback Dub generation and batch TTS hardcoded services.model_manager.get_model() (OmniVoice) regardless of the engine picked in Settings → Engines. A user selecting VoxCPM2 (or any other engine) still got OmniVoice output with no error — the silent fallback IS the bug class, not just the one report. Root-caused and fixed for the whole class: - New `TTSBackend.supports_cloning` capability flag (default True) marks engines that can only offer fixed preset voices — kittentts, supertonic3, sherpa-onnx set it False. MLXAudioBackend exposes it as an instance property (Kokoro doesn't clone, CSM does) since the adapter multiplexes multiple models with different capabilities. - `cloning_capable_engine_ids()` and a shared `resolve_generation_backend()` helper in services/tts_backend.py centralize engine resolution (id → is_available() → routing gate → optional cloning gate), mirroring generation.py's /generate resolution instead of inventing a third parallel mechanism. Both routers now standardize on the existing get_active_tts_backend() cache (unload-on-switch already handled). - dub_generate.py's two TTS-generate call sites (main run + OOM retry) and the /dub/preview-segment route resolve once, up front, with require_cloning=True — dub's ref_audio is populated for essentially every real job, so an engine that can't clone fails the whole job with one actionable message instead of mis-cloning per segment. - batch.py resolves once per job, require_cloning only when voice_id is pinned — an unpinned batch job runs fine on any engine. - Applied the three pre-existing TODO(#312) comments: mastering now skips via `applies_own_mastering` for both pipelines, matching generation.py. Regression tests cover the capability-id list, the fail-fast gate (proving no OmniVoice fallback), the success path on a selected non-OmniVoice engine, batch's pinned-vs-unpinned voice_id behavior, and the mastering skip for both pipelines. Three existing dub tests that mocked get_model() directly were updated to mock the new resolver instead. * fix(engines): exclude model-dependent adapters from cloning_capable_engine_ids() getattr(cls, "supports_cloning", True) at the CLASS level returns a property descriptor object (always truthy) when the flag is an instance @property, not a plain attribute — MLXAudioBackend uses exactly this pattern because its cloning capability depends on which of its 7+ curated models is loaded (only CSM clones; Kokoro etc. don't). Without this fix, the dub/batch capability-gate error message would always recommend 'switch to mlx-audio' even when the user's configured MLX model can't clone, sending them in a circle back to the same error. isinstance(value, bool) distinguishes a resolved boolean from a descriptor object, so mlx-audio is excluded from the suggestion list until its actual per-instance capability can be checked (already handled correctly by resolve_generation_backend()'s per-call instance check). * docs(changelog): engine-aware dub/batch entry (#987) --------- Co-authored-by: mergetest <test@local> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> fix(ui): loaded-models panel says when a resident model is not the active engine (#985) * fix(ui): loaded-models panel says when a resident model is not the active engine (#TBD) A switched-away TTS model stays resident in VRAM until Unload/Flush or the idle evictor, so users who picked another engine (e.g. voxcpm2) saw "OmniVoice TTS - cuda:0 - 1937 MB" in the LOADED MODELS flyout and concluded synthesis was still routing to OmniVoice. It wasn't - the panel just gave no hint that resident != active. /model/loaded entries for TTS-family models (in-process OmniVoice + subprocess sidecars) now carry engine_id + is_active_engine, computed against active_backend_id(); attribution failure degrades to the old shape (is_active_engine: null) and non-TTS entries (ASR, diarization) are left unannotated. The flyout renders a muted "not active - safe to unload" tag (i18n: header.model_not_active, en + zh-CN) on inactive entries; Unload/Flush behavior is unchanged. Regression tests cover both attribution states, the ASR non-label, and the degradation path. * docs(changelog): loaded-models active-engine hint entry (#985) * test(mm2): string-target monkeypatch for active_backend_id — immune to sys.modules reimports The two attribution tests patched the collection-time module alias; other suites pop+reimport services.* modules mid-run, so in full-suite order the patch landed on a stale module object while _active_tts_id late-imported the fresh one (CI-only failure). String targets resolve at patch time. --------- Co-authored-by: mergetest <test@local> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> fix(audio): remove hidden reverb from the mastering pre-stage — reverb is preset-declared only (#986) * 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. * docs(changelog): hidden mastering reverb entry (#986) --------- 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 (#984) * 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. * docs(changelog): open [Unreleased] with the preflight mirror fix (#984) --------- Co-authored-by: mergetest <test@local> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> release: freeze v0.3.11 — version bump, lockfiles, changelog (#970) 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 (#969) * 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. * docs(changelog): add backend crash forensics under [Unreleased] (#969) --------- 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 (#963) (#968) * 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. * docs(changelog): add WebView2 box-color fix under [Unreleased] (#968) --------- 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 (#963) (#967) * 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. * docs(changelog): add onset-snap robustness under [Unreleased] (#967) --------- 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 (#959) (#966) * 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. * docs(changelog): add SOCKS-proxy resilience under [Unreleased] (#966) --------- 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) (#965) * 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. * docs(changelog): add LLM-provider persistence fix under [Unreleased] (#965) --------- Co-authored-by: mergetest <test@local> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> docs+scripts: install fixes — desktop-prod tauri resolution, Ubuntu white-screen guidance, honest GPU/prereq docs (#960 #961 #962) (#964) * docs+scripts: install fixes — desktop-prod tauri resolution, Ubuntu white-screen guidance, honest GPU/prereq docs (#960 #961 #962) * docs(changelog): add the install-fixes batch under [Unreleased] (#964) --------- 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) (#958) * 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. * docs(changelog): add per-language storage + per-track caches under [Unreleased] (#958) --------- 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 (P1) (#957) * 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) * docs(changelog): add multi-lang auto-translate under [Unreleased] (#957) --------- Co-authored-by: mergetest <test@local> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> fix(ci): oxfmt the two #956 test files (unbreak main format check) #956 merged with a red Tests gate — my merge script ran unconditionally instead of aborting on the gate value; the failure was oxfmt-only on the two new test files. Whitespace-only fix, tests re-verified green. fix(dub): completed tracks always show their tabs + history keeps its language (P0) (#956) * 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). * docs(changelog): open [Unreleased] with the dub track-tabs fix (#956) --------- Co-authored-by: mergetest <test@local> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> feat(dub): project title first in the editor header, pipeline stages below (#955) Header reordered per owner: row 1 = title (+ duration/segments) with the action buttons, row 2 = the Upload→Export pipeline spine directly beneath with a tight 2px gap (was: stepper and title side-by-side on one row). Co-authored-by: mergetest <test@local> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> release: freeze v0.3.10 — version bump, lockfiles, changelog (#954) 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) (#953) * 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. * docs(changelog): add the audiobook pause-span concat fix under [Unreleased] (#953) --------- 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 (#952) * 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. * docs(changelog): add the speaker-hint + clone-purity fix under [Unreleased] (#952) --------- 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) (#951) * 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. * docs(changelog): add the timeline-box compositor fix under [Unreleased] (#951) --------- Co-authored-by: mergetest <test@local> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> fix(translate): Cinematic/Autofit can no longer invent dialogue — divergence guard + pinned temperature (#950) 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> docs(readme): community table lists the Discord server's real channels (#949) The table described aspirational channels (#showcase/#help/#feature-requests/#dev) that don't exist on the server; it now matches reality (#announcements, #releases+#changelog, #issues and #ideas forums, #discuss-ideas, #general). 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 (#947) * 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. * docs(changelog): add the stale-backend port-reclaim fix under [Unreleased] (#947) --------- Co-authored-by: mergetest <test@local> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> docs(readme): charm + organization overhaul (Opal-style) — collapsibles + OpenAI-compatible API section (#945) * docs(readme): charm + organization overhaul (Opal-style) * docs(readme): restore inventory-exact feature names (docs-drift guard) The charm pass sentence-cased five bold leads in the collapsed feature list; scripts/check-docs-drift.py greps for the inventory's exact title-case names. Restored: Vocal Isolation, Speaker Diarization, Batch Queue, AI Watermark, GPU Auto-Detect. * docs(readme): dubbing screenshot shows a real completed dub (37 segs, EN→BN) Replaces the empty drop-zone shot with the populated editor — video + waveform + cast, 37 Bengali segment rows, DUB COMPLETE banner — captured live from the v0.3.9 app; caption updated to match. --------- Co-authored-by: mergetest <test@local> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> fix(ui): relative timestamps no longer render as "20617d ago" (seconds-vs-ms class) (#946) 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): Dub LLM engine runs on the configured LLM provider (new dub_translation skill) (#944) * 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. * docs(changelog): add the dub-translation provider wiring under [Unreleased] (#944) --------- Co-authored-by: mergetest <test@local> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> docs(readme): cross-promote the maker's other local-first projects (Opal, memxt) (#943) Co-authored-by: mergetest <test@local> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> docs(readme): use the Launchpad screenshot as the hero image (#942) Swap the static social-preview banner for the live v0.3.9 Launchpad shot and drop the now-duplicate Launchpad row from the gallery (shown once). Co-authored-by: mergetest <test@local> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> docs(readme): Opal-style restyle + fresh v0.3.9 screenshots (#937) - Emoji section headers with explicit <a id> anchors. Emoji breaks GitHub's auto-generated heading slugs, so every in-page nav target keeps a stable explicit anchor (verified all href="#..." resolve). - Refresh the screenshot gallery. The prior set was from April, predating the launchpad / settings / dictation UI overhaul, so it misrepresented the app. Captured fresh at retina from the live v0.3.9 UI and led the gallery with the new Launchpad home: launchpad, studio, voice design, voice gallery, dubbing, engine-compatibility matrix, model store, embedded API reference (Scalar), and the in-app changelog reader. - Fix the stale engine count (11 -> 14 TTS engines) in the comparison table, FAQ, and roadmap to match the engine table + backend registry. - Use <kbd> keycaps for the dictation shortcut (Opal detail). Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> fix(engines): snapshot lazy registry keys so /engines can't 500 under concurrency (#940) * 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. * docs(changelog): add the /engines concurrency fix under [Unreleased] (#940) --------- Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> fix(dub): classify EINVAL transcribe failures so they stop dead-ending (#763) (#936) 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> docs(changelog): add sherpa config-error fix (#919) to [0.3.9] (#935) Co-authored-by: mergetest <test@local> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> fix(engines): classify sherpa "model not set" as a config error, gate the engine on its model dir (#919) (#934) A user selected the sherpa-onnx TTS engine and got a 500 that read "TTS engine stopped mid-generation. This usually means it ran out of memory. Try the Flush button…" — when the real cause was a pure setup problem: "OMNIVOICE_SHERPA_MODEL not set. Point it to a sherpa-onnx TTS model directory (containing model.onnx + tokens.txt)." Same misclassification class as #880/#893, which tightened the OOM catch-all on the generation path — but the engine-not-configured case still fell through to memory. Two layers, fixing the whole class: 1. Error classification (backend/api/routers/generation.py): a new `_is_config_failure()` recognizes "required engine model path / env var not set" over the whole exception chain (OMNIVOICE_* named with "not set"/"point it to"/"set omnivoice_…", sherpa's "no model.onnx found in", "not configured", "venv not found. set" for the dedicated- venv opt-ins). `_oom_friendly_reraise` checks it BEFORE the OOM branch and re-raises actionable setup guidance that names the variable, points at Settings → Engines, and never mentions memory or Flush. Generalizes to sherpa/Confucius4/dots/MOSS and any future env-gated engine. 2. Engine gating (backend/services/tts_backend.py): SherpaOnnxBackend ships no bundled model, so is_available() now gates on OMNIVOICE_SHERPA_MODEL (set + contains model.onnx) — like the other path-configured opt-in engines — returning False with an actionable reason instead of "ready", so the picker marks it unavailable-with-a- reason rather than selectable-but-broken. Added the copy-paste setup snippet for the Compat Matrix. Backward-compatible: a correctly configured OMNIVOICE_SHERPA_MODEL keeps the engine available. Tests (fail-before/pass-after): config-classification of the sherpa "model not set" error and the wider not-configured class (no "out of memory"/"Flush"); is_available gating on the env var + model.onnx and the setup-snippet registration. Fixes #919 Co-authored-by: mergetest <test@local> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> fix(test): close the reload-induced test-isolation leak at its source (#932 follow-up) (#933) Two sides of the same class the router-smoke leak (#932) traced to test_pronunciation_api's importlib.reload teardown: - test_pronunciation_api now re-runs init_db() on the restored data dir so the reloaded core.db/main.app is never left on a schema-less DB. - test_db_migration_safety catches db_module.MigrationError dynamically instead of the collection-bound name, so a reload that rebinds the class can't make pytest.raises miss it. Both orderings (real + reversed) now pass; no product change. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> fix(test): router-smoke tests leak-proof against full-suite order (#932) tests/test_router_smoke.py showed ~10 `sqlite3.OperationalError: no such table: jobs` failures in the full suite (and in isolation on a clean data dir), but passed when a schema-creating module ran first. Root cause: the `client` fixture builds a bare `TestClient(app)` with no `with` block, so the FastAPI lifespan never runs — and `init_db()` (the only place the schema is created) lives in that lifespan (main.py). The smoke tests therefore free-rode on whatever schema an earlier module left on the active DB. A module that reloads `core.config`/`core.db` and leaves `core.db.DB_PATH` pointed at a fresh, schema-less DB (test_pronunciation_api's `importlib.reload` teardown restores the env var but never re-runs init_db on the restored data dir) strands router-smoke on a DB with no tables -> every DB-backed route 500s. Same class as #878 / #917. Fix (test-only, zero blast radius): the `client` fixture now calls `core.db.init_db()` against whatever DB is active at run time before serving requests — the same `init_db()` pattern test_api.py / test_personas_api.py use. Because it targets the live `core.db.DB_PATH`, it re-creates the schema regardless of which path any prior module left active, making the suite self-sufficient and order-independent. Verify: - `pytest tests/test_router_smoke.py` alone: 10 failed -> 24 passed - `pytest tests/test_pronunciation_api.py tests/test_router_smoke.py` (deterministic reproducer): 10 failed -> 38 passed - `pytest tests/` full suite: 2215 passed, 20 skipped, 10 xfailed, 4 xpassed, 0 failed / 0 errors Co-authored-by: mergetest <test@local> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> docs: backfill [0.3.9] batch bullets + add OSS sponsorship playbook (#931) 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> feat(settings): OpenAPI reference page — embedded Scalar (bundled, CDN-free) + footer button (#928) Add a Settings → OpenAPI page that renders an interactive Scalar API reference for OmniVoice's own local backend, plus a compact footer button that opens it. - New OpenApiPanel fetches the live spec from the resolved backend base (getApiBase()+"/openapi.json", via apiFetch so it follows remote-backend / LAN-share overrides), owns loading + unreachable-backend fallback (with Retry), and hands the parsed spec inline to Scalar. - Scalar is bundled via the @scalar/api-reference-react npm package — NO CDN script tag. It is lazy-loaded (ScalarApiReference.jsx) so its ~heavy Vue bundle stays out of the initial load and only downloads when the page opens. - CDN-free hardening: withDefaultFonts:false (drops the fonts.scalar.com @font-face rules), proxyUrl:'' (Test Request client goes direct to the local backend, not proxy.scalar.com), spec passed as inline content (no external spec fetch). The Tauri CSP is the hard backstop. Verified the built dist: external hosts appear only as inert/gated strings inside the on-demand Scalar chunks and are absent from the initial-load chunks. - settingsCategories: new 'openapi' category (Braces icon, api/openapi/scalar/ rest/swagger/docs keywords) in the System group; Settings render case wired. - LogsFooter: compact Braces icon button (openSettingsTab('openapi')) next to the discord/mail cluster, chrome-muted → accent on hover, uniform 14px icon. - i18n: all strings via t() with English defaultValue fallbacks (openapi.*, logs.open_api*, settings.openapi); keys added to en.json. - Test: OpenApiPanel.test.jsx (mocks the spec fetch + stubs Scalar) — renders the reference container on success, shows the unreachable fallback on failure, recovers on Retry. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> feat(engines): real synthesis "Self-test" + copy-paste setup snippet for opt-in engines (#930) * feat(engines): real synthesis "Self-test" + copy-paste setup snippet for opt-in engines Builds on #905's Engines-settings fixes (verified still green: license dialog mounts, matrix reloads on select, cpu_fallback routing toast, cpu-native → cpu_only). Two enhancements, no #905 behavior touched. Real "Self-test" for in-process TTS engines ------------------------------------------- The existing /engines/{id}/health probe only imports the package and reports "deps OK" for in-process engines — it never proves the engine can emit audio. New POST /engines/{id}/selftest runs a *tiny real synthesis* from a fixed short ASCII phrase and reports ok + duration + sample-rate + sample count, proving the engine actually produces audio. Guardrails keep it cross-platform-identical and CPU-cheap: TTS + available + in-process only, bounded wall-clock timeout (OMNIVOICE_SELFTEST_TIMEOUT_S, default 90s) that returns ok=false/timed_out instead of hanging the panel, a process-wide lock so a click-storm can't stack model loads, loopback-gated, and only ever on user click (never on load). The Compat Matrix gains a "Self-test" button (with cooldown) that renders "0.82s @ 24 kHz in 820 ms". HF tokens in a synth error are redacted like the health route. Verified end-to-end: kittentts synthesized 89,200 samples @ 24 kHz. Copy-paste setup snippet for path-gated opt-in engines ------------------------------------------------------ IndexTTS / MOSS-v1.5 / dots.tts / Confucius4 gate on an OMNIVOICE_*_DIR env var. list_backends() now emits a single-sourced `setup_snippet` (the exact `export VAR=/path/...` line) surfaced with a Copy button inside the matrix's "Why unavailable?" disclosure, so users don't reconstruct it from the docs. Also tightened the incomplete SelectEngineResponse TS type to include the routing echo (routing_status/effective_device/routing_reason) the post-select toast already reads at runtime. Tests: backend selftest success/subprocess-reject/unavailable/unknown/loopback/ exception-capture/timeout/HF-redaction + setup_snippet shape; frontend self-test render, timeout marker, subprocess+ASR gating, setup-snippet render. New route added to the API route snapshot. Full vitest (808) + backend engine/routing/asr/ route-inventory/no-CJK green; lint 0 errors; format + typecheck:ci clean. * fix(test): allow setup_snippet key in list_backends shape assertion The engine self-test PR added setup_snippet to each backend entry but only updated the route-shape test; test_list_backends_shape strict-asserts the key set. Add setup_snippet there too. --------- Co-authored-by: mergetest <test@local> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> feat(models): one canonical HF-token path + surface incomplete cache (#927) Two Model-management enhancements building on #908 (no re-do of its fixes). Unify the two HF-token entry points. The Model Store toolbar saved the token via /system/set-env (env var + HF-CLI file) while Settings → Credentials saves to the encrypted app store — two stores with an asymmetric clear path, so a toolbar-set token silently outlived the Credentials "Clear" (a support-ticket generator). The toolbar now POSTs the SAME canonical endpoint Credentials uses (/api/settings/hf-token → encrypted store + huggingface_hub.login()), so there is one store with one clear path. In-process parity is preserved (login() populates the HF canonical file, so downloads pick it up immediately). Surface an incomplete/partial cache. A truncated download (config landed, weight shard didn't) occupies disk but used to read as a plain "not installed". The backend already flags it as `incomplete`; the row now shows an "incomplete · N MB" warn badge, relabels the primary action to "Repair" (re-runs snapshot_download to finish the missing shard), and offers a Delete to clear the partial bytes. Tests: modelStoreTokenPath (toolbar hits /api/settings/hf-token, never /system/set-env) + modelStoreIncomplete (badge, Repair→onInstall, Delete, no false positives on normal not-installed/installed rows). Full vitest green; lint + format clean. i18n keys added to en.json (models.incomplete, incomplete_title, repair_btn, repair_title). Co-authored-by: mergetest <test@local> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> feat(contact): make "Get in touch" a guided, well-typeset help page (#925) * feat(contact): make "Get in touch" a guided, well-typeset help page Replace the flat 4-row link list (Discord / Email / Issues / Website) with five guidance cards, each an icon + heading + a "use this when…" sentence so users pick the right channel instead of guessing: - Report a bug → reuses ReportBugButton (prefilled GitHub issue + scrubbed diagnostics; nothing sent until the user reviews & submits) - Request a feature / ask → GitHub Issues - Get help & community → Discord (setup help, sharing dubs) - Support the project → routes to the existing Support page (no Ko-fi duplication) - Report a security issue → GitHub Security Advisories (private, per SECURITY.md) Bigger, friendlier typography ("We'd love to hear from you" header, roomier measure and spacing) and a container-reflow card grid (CSS grid auto-fit, no viewport @media, so it stays correct under --ui-scale zoom). Email + website kept as quieter direct channels. External CTAs are real <a rel="noreferrer"> links, keyboard-focusable, opened via the shared openExternal helper. All strings go through i18n under contact.* with English defaultValues for locale fallback. Adds a ContactPage render test (sections render, each channel targets the right URL, bug-report affordance present, Support routes to donate). * fix(i18n): prune 4 orphaned contact.* keys from 20 locales (Contact-page rewrite) The Contact page rewrite renamed its i18n keys; the old keys lingered in the 20 non-English locales as orphans, failing the locale_no_orphan_keys probe. Pruned; new keys fall back to English per i18n config. --------- Co-authored-by: mergetest <test@local> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> feat(support): sponsor logo slot + "Become a sponsor" affordance (#923) Adds a way for companies and people to visibly support OmniVoice. - config/sponsors.js: single source of truth — an (empty) SPONSORS array with a documented { name, logoUrl, url, tier } shape + tier order, and a SPONSOR_CONTACT object whose githubIssue is a prefilled, zero-token "become a sponsor" issue (same pattern as the bug reporter) plus the Ko-fi link and a SPONSORS.md docs URL. Logos are added here + in SPONSORS.md. - LogsFooter: a compact "Sponsors" link next to the donate heart (a link, not a logo strip in the 28px bar) that opens the in-app Support/Sponsors view. - SupportPage: a Sponsors section — logo grid grouped by tier when populated, a tasteful outlined "be the first — your logo here" slot while empty, a primary "Become a sponsor" button opening the prefilled issue, and a one-line explainer linking to SPONSORS.md. - SPONSORS.md: what sponsors get + how to become one, kept in lockstep with the config. - All strings via i18n (support.sponsors_* / logs.sponsors) with English defaultValues so non-English locales fall back cleanly. Logo links are lazy, max-height capped, aria-labelled, rel="noreferrer", and open in the system browser via the app's external-open helper. - Test: SupportPageSponsors renders the empty placeholder + asserts the become-a-sponsor CTA targets the contact URL, and (with injected sponsors) that each renders as an external logo link. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> docs(sponsors): add SPONSORS.md, README sponsors section, sponsor issue form + FUNDING link (#924) Add a sponsorship home (SPONSORS.md) with Backer/Bronze/Silver/Gold tiers — described as placements/benefits, with $ amounts left as `<!-- OWNER: set amounts -->` placeholders (no invented prices). Primary "become a sponsor" path is a prefilled GitHub issue form (.github/ISSUE_TEMPLATE/sponsor.yml: name/org, logo URL, tier, contact), with Ko-fi/PayPal as direct paths and an OWNER placeholder for a public contact email. README gains a Sponsors subsection (logo-slot placeholder + SPONSORS.md link), a Sponsors nav entry, and a note about GitHub's native Sponsor button. FUNDING.yml adds the SPONSORS.md link alongside the existing ko_fi/PayPal. Keeps the honest "agent bills" framing; sponsorship is a thank-you, not a paywall — OmniVoice stays fully free and AGPL-3.0. Docs-only; no fabricated sponsors, prices, or testimonials. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> ci(release): version-first release titles so the tag shows in GitHub's truncated release list (#922) GitHub's release-list sidebar clips the title mid-string, hiding the version when it trails 'OmniVoice Studio'. Name stable releases 'vX.Y.Z — OmniVoice Studio' and the preview 'Preview — OmniVoice Studio'. Existing releases were renamed to match. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> docs(changelog): add Launchpad full-width (#915) + migration-logging fix (#917) to [0.3.9] (#918) Co-authored-by: mergetest <test@local> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> 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> fix(launchpad): full-width responsive feature-card grid (retire the fixed ~780px deck) (#915) PR #904's deck-of-cards fan pinned the seven Launchpad feature cards (Voice Clone, Voice Design, Video Dubbing, Stories, Audiobook, Voice Gallery, Transcripts) inside a fixed ~780px box, leaving dead margins on a maximized display. Replace it with one full-width grid that fills the content edge-to-edge and reflows its column count from a maximized ~2560px display down to the 900x600 minimum. - LaunchpadDeck renders a single `.lp-cards` grid at every shell width (no deck-vs-fallback split): `repeat(auto-fit, minmax(--lp-card-min, 1fr))` derives the column count from the grid's OWN width, so columns reflow 7->1 with zero viewport @media (which fire at the wrong width under the shell's `zoom: --ui-scale` model). Every column stretches (1fr) -> no dead margins, no horizontal scroll. - The only responsive knob is `--lp-card-min`, set inline from useShellNarrow (the `.app-container` shell-narrow/shell-mini own-width classes): 200px wide, 240px narrow -> fewer, comfier columns on narrow shells. No viewport media queries. - Cards keep #904's character: animated waveform faces, cursor spotlight + eternal breath ring (phase-offset per card via --lp-i), and a hover/focus-forward raise (`lp-action-card--raised`) driven from React state so pointer and keyboard share one path. Reduced-motion freezes the waveform. All 7 navigation targets and i18n keys preserved. - Removed the old `.lp-deck*` fan CSS, the ActionCard narrow fallback, and the launchpad viewport @media overrides. Rewrote the regression suite to assert full-width grid layout, the narrow-vs-wide floor, and the raise interaction for pointer AND focus. Verified in a real browser (chromium): 7 cards fill the full width in one row at 2472px content, reflow to 3 columns at 920/876px, and the grid width equals the container at every size (no overflow). Co-authored-by: mergetest <test@local> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> 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> docs(changelog): backfill the settings/features wave into [0.3.9] (#913) 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(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> 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> 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> 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> 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> 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> 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> feat(launchpad): deck-of-cards redesign — fanned feature cards with waveform faces (#904) The seven launchpad feature cards now render as an overlapping deck fanned left-to-right: each card sits in a fan slot with a subtle tilt (±4°) and vertical stagger (≤14px), peeking ~33% out from under its right neighbor. Every card face carries its lucide icon + name on the always-visible peek edge, a one-line description, and a decorative CSS-only animated waveform strip in the card's accent color (stagger-delayed scaleY bars, aria-hidden, static under prefers-reduced-motion). Hovering OR keyboard-focusing any card brings it fully forward — it straightens, scales up and takes the top of the stack while every other card slides toward it and tucks underneath (dimmed, scaled down, overlap increased). The raise/tuck classes are React-state-driven so pointer and focus share one code path and tests can assert it. Fixed deck height — zero layout jump. Navigation targets, i18n keys, per-feature accent hues and profile/project counts are unchanged; both renderings share a single feature list so they can't drift. On shell-narrow/shell-mini (the app-container's own width classes — not viewport @media, per the UI-scale rationale in App.jsx) the deck degrades to the pre-existing flat ActionCard grid, tracked live via MutationObserver (new useShellNarrow hook), keeping 900×600 usable. New LaunchpadDeck.test.jsx covers: 7 cards in canonical order, every navigation target (incl. clone/design → studio + defineMethod), raise/tuck partitioning for hover and focus, waveform decorativeness, the narrow fallback under both shell classes, and the runtime class flip. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> release: freeze v0.3.9 — version bump, lockfiles, changelog (#899) * release: freeze v0.3.9 — version bump + lockfiles + changelog * release: unwrap the [0.3.9] section — release bodies hard-break single newlines --------- Co-authored-by: mergetest <test@local> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> 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> fix(bootstrap): port cuDNN 8 (NVIDIA CUDA GPU) + VC++ redist to packaged installs (#869) * 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: mergetest <test@local> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> feat(footer): Logs icon + uniform icon sizes + value-moment donate popover (Clippy-style, strictly throttled) (#898) Co-authored-by: mergetest <test@local> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> 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> 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> fix(splash): IPC-independent watchdog + recovery panel for dead Tauri IPC (#879) (#892) After an unclean shutdown (Windows BSOD), the WebView2 profile cache (%LOCALAPPDATA%\com.debpalash.omnivoice-studio\EBWebView) can corrupt: Tauri's IPC custom protocol fails AND the postMessage fallback breaks, so invoke() hangs forever. useBootstrapStage's poll loop rode entirely on that IPC — a hung bootstrap_status call silently killed the loop and the splash sat at "preparing" forever, even with a fully healthy backend answering over plain HTTP. Class fix, three parts: - splashWatchdog.js: IPC-independent escape hatch. If no IPC signal arrives within 10s, poll GET /health over plain HTTP; healthy → proceed to the app as if 'ready' was received (console.warn breadcrumb so diagnostic bundles carry it). Any successful IPC response disarms it for good. - Recovery panel (stage 'ipc_lost'): if neither IPC nor HTTP succeed within 45s, show an actionable panel instead of the infinite spinner — "Open logs" (with an inline path fallback when IPC is dead) and, Windows-only and only in this error state, "Repair and restart". Health polling continues behind the panel so a slow first-run install with broken IPC still reaches the app. - clear_webview_cache_and_relaunch (Rust): writes a marker and relaunches; the fresh process deletes EBWebView at the top of run() before any webview exists (WebView2 holds locks while running), with a bounded retry while the old instance exits. Runtime cfg! guards keep the whole path compiling on every platform. Tauri 2 exposes no reliable flag for the postMessage-fallback mode (closure-local in its injected ipc.js), so the logged detector is the observable combination: zero IPC signals + working plain HTTP. Fail-before/pass-after regression tests: hung invoke + healthy HTTP → ready; hung invoke + dead backend → recovery panel, then auto-continue; working IPC → normal path untouched, zero HTTP polling. Plus watchdog state-machine unit tests and recovery-panel render/interaction tests (6/7 fail on the pre-fix component). Troubleshooting doc gains the matching section (docs-sync). Fixes #879 Co-authored-by: mergetest <test@local> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> 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> 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> fix(platform): declare Intel-Mac local backend unsupported — honest first-run gate + docs (#889); Windows portable-install docs (#766 follow-up) (#891) torch >=2.3 ships no macOS x86_64 wheels (transformers 5.x needs torch >=2.6), so `uv sync` can never resolve on an Intel Mac — per the platform-parity rule the honest option is declaring the platform unsupported, not letting first launch die in a raw resolver error: - bootstrap.rs: pre-check on macOS x86_64 before any venv create / uv sync (first-run AND repair paths) fails fast with an actionable message (remote-backend escape hatch + docs link); healthy pre-torch-bump venvs are deliberately untouched. Unit test pins the message's load-bearing phrases. - BootstrapSplash: routes the failure to a dedicated localized hint (bootstrap.hint_intel_mac, all 21 locales) and suppresses the useless Retry-oriented hints for it. - README + docs/install/macos.md (+ troubleshooting #9): every Intel-Mac support claim now says UI-installs-but-backend-cannot-run, including the from-source path (also broken); remote backend documented as the only use. - release.yml: #889 note on the macos-15-intel leg — artifact is UI-only; keep-or-drop is an owner call, deliberately not changed here. - docs/install/windows.md: new "Portable install (Windows)" section promised in #766 — custom MSI wizard folder / msiexec INSTALLDIR=..., what lives in OmniVoiceStudio-Data next to the exe, and the Program-Files-greyed-out why. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> 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 * docs(changelog): dictation rebuild entry * fix(lint): Array.from over new Array(n) — oxlint no-array-constructor --------- Co-authored-by: mergetest <test@local> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> 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> 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> fix(shell): LogsFooter becomes a real grid row — bottom buttons can't clip under it at small window sizes (#882) Co-authored-by: mergetest <test@local> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> 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> 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> 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> 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> 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> fix(translate+dub): wire Cinematic/Autofit to the LLM Providers registry; retry a wedged transcribe chunk instead of dropping it (#867) Two bugs from real reports: 1. LLM not wired — translator._llm_client()/_llm_model() read TRANSLATE_*/OPENAI_* directly, bypassing the LLM Providers registry (#854). So a provider set up in Settings → LLM Providers never powered Cinematic/Autofit. Now resolves the ACTIVE provider (base_url/key/model) via llm_providers; the 'custom' provider still maps TRANSLATE_* so legacy env setups keep working. 2. Transcription 'missing the beginning' — the chunked dub transcribe dropped a whole chunk's window on failure/timeout (returned empty segments, no retry). A transient wedge on the FIRST chunk (whisperx cold-loads its model there, the #730 hang) therefore lost the start and left only middle+end. Now retries a failed/timed-out chunk once on a fresh pool (OMNIVOICE_TRANSCRIBE_CHUNK_ATTEMPTS, default 2) so the recovered chunk fills the hole. Imports + dub_transcribe/translator/llm_providers tests green. Co-authored-by: mergetest <test@local> refactor(gallery): cleaner, elegant voice cards — tokens over hardcoded surfaces, borderless state (#866) Redesign ArchetypeCard for a calmer visual hierarchy and design-token surfaces, no behavior change (all props/handlers/loading states identical). - Replace hardcoded surfaces with tokens: chips/wand/preview bg → tokens (bg-white/[0.05] → --color-bg-elev-2, bg-white/[0.03] hover → --chrome-hover-bg), text → --color-fg / --color-fg-muted / --color-fg-subtle, and the literal #1d2021 hover text on Use voice → --color-fg-inverse. - Borderless by direction: drop the hover/state border classes on the action buttons and the card; convey hover via background tint + text color and the playing state via an accent box-shadow ring (no literal/token borders). - Hierarchy: name is the focal point (semibold, --color-fg); metadata line is smaller/muted (--color-fg-muted) so it recedes. - Chip row renders only when there are chips (no empty min-h reserve); the grid stretches rows so mt-auto still bottom-aligns actions. - Accent used tastefully: tinted Use voice → solid accent on hover/focus with inverse text; favorite star stays subtle until hover/active. Focus-visible rings intact. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> fix(dub): compact + responsive header — tighter stepper/title/actions, drop hardcoded borders (#865) - Stepper (inline): smaller step gap/font (0.66rem), 19px icons, 10px connectors → the 6 stages take far less width so title + actions fit before wrapping. - Title: lighter weight (medium/0.78rem), normal-case, min-w-0 truncation; meta 0.68rem; project name truncates too. Tighter header padding + gaps. - Removed the hardcoded header border + border-left divider (borderless) and the rgba bg → token --color-bg-elev-1. Co-authored-by: mergetest <test@local> 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. * 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. * 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> 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> fix(ui): remove the SECOND history aside's border (missed by #860's replace_all — different indentation) + filter-chip borders (#862) Co-authored-by: mergetest <test@local> fix(theme): theme-aware native selects — color-scheme per theme + token-driven caret/options/focus ring (#861) Native <select> chrome (option popups, scrollbars, form UA elements) rendered in the OS light scheme on dark themes because `color-scheme` was never set as a property (only a `prefers-color-scheme: light` media query existed, which is not the same thing). The dropdown caret was also a hardcoded gray SVG that ignored theme + accent. Owner report: gallery/install/language selects looked wrong for accent + dark/light. - Declare `color-scheme: dark` on :root (default Gruvbox Dark) and re-assert it on every [data-theme] block. All six shipped themes are dark (verified by their real --color-bg lightness: midnight #0f172a, nord #2e3440, solarized #002b36, rose-pine #191724, catppuccin #1e1e2e), so all get `dark`. The empty auto/light scaffold is left at dark (no light theme ships yet; a light value there would mismatch the still-dark surface) with a note for when one lands. - Replace the hardcoded %23a1a1aa caret in select.input-base and .ui-select with a single --select-caret token, overridden per theme to that theme's muted foreground (a background-image SVG can't read a CSS var, so the color is baked per theme). Both selects consume the one token (DRY). - Paint <option>/<optgroup> from --color-bg-elev-1 / --chrome-fg so Chromium (Windows/Linux) popups match; macOS WebKit popups follow color-scheme. - Give selects a themed focus-visible ring (--color-ring → --color-brand) to match the buttons/checkboxes tokenized last phase, instead of the non-theme-tracking --chrome-accent. Borderless guardrail and :focus-visible rings intact. Covers every named native select (DubTab/AudiobookTab language, DubLeftColumn engine, gallery, ui/Input.jsx Select, VoicePreview, StoriesEditor, ExportModal, DubSegmentRow) via the shared input-base/ui-select rules — no per-call-site edits needed. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> fix(ui): physically remove the panel-frame border utilities on history + active-voice panels (#860) #857 zeroed the border TOKENS but left token-based border utilities (border-t-[var(--chrome-border-strong,…)], border-b-[var(--chrome-border)]) in the JSX — a fragile indirection that still renders a line if the token doesn't resolve transparent (stale HMR / the pre-zero rgba base value). Per 'no borders whatsoever', remove the utilities outright from the WorkspaceHistory (dub + regular history) and WorkspaceVoices (active-voice) panel frames; the active-voice card keeps its background tint as the selection cue. Co-authored-by: mergetest <test@local> style(controls): unify buttons/inputs/selects/checkboxes/toggles onto design tokens (Phase 2) (#859) * style(controls): Phase 2 — tokenize + unify buttons/inputs/checkboxes/toggles onto design tokens Phase 2 of the borderless styling pass. Converts interactive controls to design tokens with a cohesive, theme-tracking active/checked affordance, building on Phase 1's borderless base. No behavior changes — visual/token only. Shared primitives (highest leverage): - ui/button.tsx: replace literal `hover:bg-white/[0.04]` (subtle/softGhost/ chip/preset/iconBtn) with `hover:bg-[var(--chrome-hover-bg)]`. - ui/toggle.tsx (seg): drop hardcoded `text-[#fff9ef]` active text and hover white literal for `text-fg` + `--chrome-hover-bg`. - ui/Segmented.jsx: recessed track `bg-black/[0.28]` -> `bg-bg-elev-2`. - index.css: native checkbox `accent-color` and range-input thumb/track/active moved off non-themed `--chrome-accent` / legacy `--text-primary`/`--primary`/ raw rgba onto themed `--color-brand` / `--color-fg` / `--color-bg-elev-2` + radius/shadow/duration tokens. Checked state is now brand-tinted and recolors per [data-theme], matching sliders/segmented/primary buttons. - SettingsToggle: on-state -> `--color-brand`, focus ring -> `--color-ring`, knob shadow -> `--shadow-sm`, radius -> `--radius-pill`. Control call sites (exact-token swaps, remove hardcoded hex/rgba): - Unified every checkbox `accent`/`accentColor` override onto `--color-brand` (DubbingDemo, DubRightColumn, DubLeftColumn, IdleSkeleton, DubFooter, DubSegmentRow, AppearancePanel range). - FooterBtn blue/orange tones -> --color-info/--color-warn. - MicButton danger tint/neutral fill -> tokens. - DubLeftColumn install CTAs + engine chip -> brand tokens + --radius-pill. - NetworkToggle: neutral fg/hover tokens; removed stray `#504945` fallback border. Focus rings and the borderless guardrail (tests/test_no_literal_borders.py) intact. build + format:check + lint (0 errors) + guardrail all green. * test(dub): assert the tokenized brand-accent install button (bg-[var(--color-brand)]) after Phase 2 Phase 2 tokenized the highlighted Install CTA from the hardcoded #d3869b to var(--color-brand); update the two assertions to match. --------- Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> chore(version): pin main to 0.3.8 (revert the post-release auto-bump) (#858) * Revert "chore(version): main -> 0.3.9 after v0.3.8 release" This reverts commit7489bef085. * chore(release): gate the post-release version-bump behind AUTO_VERSION_BUMP (owner controls bumps) Owner decision (2026-07-01): keep main pinned to the released version and bump only on explicit request. The version-bump job now runs only when the repo variable AUTO_VERSION_BUMP == 'true' (default off), so releasing no longer auto-rolls main to +1. Documented the override in CLAUDE.md's versioning rule. --------- Co-authored-by: mergetest <test@local> 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> chore(version): main -> 0.3.9 after v0.3.8 release 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. * 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(dub): consolidate pipeline stepper and title/meta into one header row (#855) Merge the two stacked dub-editor header rows into a single line to save vertical space. The pipeline stepper (Upload → … → Export) is now inlined onto the DubHeader row alongside the title, duration · N segs metadata, and the primary action buttons (Generate Dub / QC / Export). All step active/complete styling, data bindings, and button onClick/disabled/loading props carry over unchanged. - DubPipelineStepper gains an `inline` prop → `dub-stepper--inline` variant (drops the standalone border-bottom/padding, tighter connectors). - DubHeader renders the inline stepper as the leftmost element; the row is flex-wrap so it wraps gracefully on narrow windows. - DubTab only renders the standalone spine before the editor exists, so the stepper is never duplicated once the editor (and inline spine) is shown. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> 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. * 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. * 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. * 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). * 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(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. * 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. * 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. * 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. * 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> docs(changelog): cut v0.3.8 — fold Unreleased into the 0.3.8 release section (2026-07-01) (#852) 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) (#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. * 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: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> fix(theme): restore per-theme chrome recoloring — default :root was clobbering [data-theme] overrides (P5 consolidation regression) (#849) The color themes (Midnight, Catppuccin, Nord, Solarized, Rose Pine) stopped recoloring the app chrome — the Settings hub, header, footer and everything else that reads var(--chrome-*) stayed the default dark on the real app. Root cause: in the real app `data-theme` is set on <html>, and <html> IS `:root` (documentElement === :root). The P5 tokens consolidation inlined the default legacy/chrome `:root` block (--chrome-bg:#0f1011, …) AFTER all the [data-theme] blocks. A plain `:root {…}` and a `[data-theme="x"] {…}` both match that same element at EQUAL specificity (0,1,0), so source order is the only tiebreaker — the later default `:root` won and clobbered every theme's --chrome-*/--color-* overrides. The visual-regression suite kept passing because its harness applies `data-theme` to a WRAPPER div (a closer ancestor that wins by proximity, not source order), so it never exercised the <html> path where the bug lives. Fix (source order, not specificity): reorder index.css so every default `:root` block precedes all `[data-theme]` blocks. The [data-theme] blocks (+ the @media prefers-color-scheme:light theme block) now sit LAST, after the default legacy/chrome `:root`. The `[data-theme="x"]` selectors are unchanged (bumping to `:root[data-theme="x"]` would stop matching the wrapper-based harness and break the 48 snapshots). Crucially the Tailwind v4 region is left byte-for-byte intact: the @theme base and the adjacent `@theme inline` shadcn bridge keep their exact positions. Moving a `:root` between/across them changes the GENERATED CSS (`@theme inline` stops inlining, so shadcn utilities lose their brand color) — so instead of lifting the default :root above @theme, the [data-theme] blocks are lowered below it. Verified: the compiled CSS is byte-identical to before (260686 B), and every token value is preserved byte-for-byte (pure reordering). Regression test: src/test/themeCascade.test.js replays the documentElement cascade from index.css source order and asserts each theme's --chrome-bg/-fg wins over the default :root. Fails-before / passes-after. Verified live in Chromium too: getComputedStyle(documentElement)['--chrome-bg'] now resolves to #0f1011 (default) / #1e293b (midnight) / #313244 (catppuccin) / #3b4252 (nord). Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> fix(settings): convert un-migrated panel controls to design-system primitives (theme-consistent inputs/buttons/selects) (#848) Several Settings panels were re-hosted in the redesign without converting their raw <input>/<select>/<button> to the design system, so they rendered as native UA controls (white input fields, light-gray buttons, system fonts) that ignored the theme tokens — jarring on the dark chrome. Convert every native control in the affected panels to the shared primitives (SettingsInput / ui Button / ui Select / ui Badge) so all of Settings themes coherently in every palette. Panels fixed: - LLMEndpointPanel: Ollama/LM Studio/vLLM/OpenAI preset chips -> Button (preset); Base URL / Model / API key -> SettingsInput (mono); Save -> Button (subtle/sm, loading); reachable/not-configured status -> Badge (success/warn, dot). - HFMirrorPanel: mirror preset chips -> Button (preset); Save -> Button (subtle/sm, loading). (HF_ENDPOINT was already SettingsInput.) - PronunciationPanel: add-entry term/replacement/language + test inputs -> SettingsInput; type selector -> ui Select; per-row enable checkbox -> SettingsToggle; type/scope pills -> Badge; Add + per-row delete -> Button (subtle/sm, danger/sm). - RemoteBackendPanel: Test connection + Save & reload -> Button (subtle/sm, loading); probe result -> Badge (success/danger, dot). - MCPBindingsPanel: client-id input -> SettingsInput; voice select -> ui Select; Bind -> Button (subtle/sm); per-binding profile pill -> Badge; delete -> Button (danger/sm). Also dropped the perfpanel__row / perfpanel__badge / perfpanel__checkbox class usages from these panels (replaced by primitives + token flex utilities). The perfpanel CSS block lives in src/index.css (owned by an in-flight theme-cascade change), so it was left in place; the remaining perfpanel__error / perfpanel__help references are token-based themed banners, not native controls. Behavior, handlers, state, endpoints, and all data-testids are preserved. No new user-facing strings (styling-only). Gates: vite build, oxlint (0 on touched files), oxfmt --check clean, vitest 645 pass, 48 visual snapshots unchanged, bun install --frozen-lockfile clean. Live eyeball across all 5 categories in default + catppuccin themes confirms no white fields / no native buttons. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> 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> fix(dub): show transcribing/progress view instead of idle dropzone while the pipeline runs (#846) The Dub stepper could show Upload ✓ → Prepare ✓ → Transcribe (active) while the main content pane still rendered the IDLE upload dropzone ("Drop video or audio here" + paste-URL input + "Pull YouTube captions"). Contradictory: if the pipeline is transcribing, the pane must reflect that stage, not the landing. Root cause (frontend/src/components/dub/IdleSkeleton.jsx): the main-view branch keys off the non-serialisable local File `dubVideoFile`. That File is only set on the drag/drop + file-input path — never on the URL-ingest path (and not on a restored job). The `dubVideoFile ?` branch correctly renders both the prepare (PrepOverlay) and transcribe (TranscribeOverlay) overlays via the WaveformTimeline, but the no-file branch only handled `dubStep === 'uploading'` (PrepOverlay large) and otherwise fell straight through to the idle dropzone. So a URL-ingested job in `dubStep === 'transcribing'` (no File) rendered the dropzone — the exact desync in the screenshot. Not a #818 regression: the no-file branch never handled `transcribing`. It was identical before #818 (verified against9d79bb8) — a pre-existing gap that only bites the URL-ingest / restored-job paths. Fix (whole class, recurrence-proof): - Add a `dubStep === 'transcribing'` case to the no-file path that renders TranscribeOverlay, symmetric to the existing `uploading` → PrepOverlay case. This covers URL-ingest AND restored/resumed jobs that lack a local File. - Gate the idle dropzone on `dubStep === 'idle'` so it can render ONLY when genuinely idle; any other non-idle no-file step (e.g. `stopping`) shows a neutral working indicator instead of falling back to the dropzone. This makes it structurally impossible to show the dropzone during an active pipeline. All existing behavior/handlers preserved (failure banner + retry still show in the idle-after-failure state, since that sets dubStep back to 'idle'). Regression test: frontend/src/test/DubIdleSkeleton.test.jsx — asserts the dropzone renders only when truly idle, is hidden (and the transcribe overlay shown) while transcribing a URL-ingested job, is hidden while preparing, and never falls back to the dropzone for a non-idle no-file step. Fails before / passes after. Verified live (Playwright, real backend): before → transcribe stage shows the dropzone (transcribingHasDrop=1, overlay=0); after → shows the transcribe overlay (transcribingHasDrop=0, overlay=1), idle still shows the dropzone, reset returns to idle. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> fix(settings): right-anchored controls fill leftward on the full-width hub (#845) The full-width Settings hub (#843) left every right-aligned SettingRow control capped at `max-w-[60%]`, so wide fields (text/URL/key inputs, selects, textareas) sat cramped against the right edge with a big empty gap to the label. Read-only mono values ("0.3.8", version strings) also wrapped character-by-character because `[overflow-wrap:anywhere]` collapsed the auto grid cell to a 1-char min-content, and removing the content measure spread rows edge-to-edge on wide/ultrawide screens. SettingRow.jsx: - Widen the control grid track to `minmax(0,1fr) minmax(0,1.9fr)` only when the row contains a real field (`has-[input:not(checkbox/radio/range)]`, `has-[select]`, `has-[textarea]`), gated to `@min-[601px]/settings` so the narrow-container stacking is untouched. Toggles (checkbox), Segmented / Slider (Radix), and buttons don't match, so short controls keep the `auto` track and stay compact, right-pinned. - Lift the `max-w-[60%]` cap to `max-w-[85%]`; make the control cell `w-full` (has-gated) so wide fields fill the widened track leftward to a clean right edge. Existing `w-full` fields fill; short controls unaffected. - Fix mono/read-only wrapping: `[overflow-wrap:anywhere]` -> `break-word` and the percentage `max-w-[75%]` -> length-based `max-w-[42ch]`, so short values render on one line (the percentage cap forced the auto track to min-content) while long paths still wrap on boundaries. Settings.jsx: - Re-introduce a generous, centered content measure (`w-full max-w-[1100px] mx-auto`) on the content column so rows fill from the middle instead of spreading to the screen edges on wide/ultrawide displays; the rail stays fixed. Wider than the old cramped 660px measure, capped for readability. Verified visually with Playwright at 1400px (General, Translation, Network, Credentials, Appearance, Dictation, About) and 700px (stacking intact). All gates pass: vite build, oxlint, oxfmt, vitest (641), visual (48, no baseline change needed), frozen lockfile. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> fix(settings): sidebar items show UA button-gray in dark themes (#844) The sidebar nav items are native <button>s and the non-active state set no background, so with Tailwind preflight disabled they fell back to the browser's default `ButtonFace` (light gray) — washed-out pills in the dark themes, and the active item paradoxically looked darker (it got the subtle --chrome-hover-bg overlay while inactive items showed UA gray). Add explicit `bg-transparent` + `appearance-none` so items are theme-adaptive; active/hover keep the overlay. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> feat(settings): make the Settings hub full-width (#843) Dropped the root max-width cap + mx-auto centering and the content pane's reading-measure cap so Settings spans the full content area (rail + fluid content) instead of sitting in a centered column with side gutters. The `container-name:settings` inline-size container is preserved, so SettingRow's narrow-width stacking still fires on the real content width. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> docs(contributing): reconcile file-size/co-location rules with the one-stylesheet end-state (#842) The CSS consolidation (#837) collapsed all component CSS into src/index.css, so the "hard 500 lines per .css" cap and "co-locate Foo.css" rule no longer apply. index.css is the single intentional styling foundation (exempt from the cap); styling is utilities + shadcn, not per-component files. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> docs(contributing): CSS guidance for the one-stylesheet end-state (#841) The CSS consolidation (#837) folded every per-component stylesheet into src/index.css — the note still implied component-level .css files exist for keyframes/glass/hooks. Now: all styling lives in src/index.css; don't create new component .css files. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> refactor(ui): consolidate component CSS into index.css — collapse to ~one stylesheet (#837) Fold every remaining per-component stylesheet into src/index.css so the frontend ships essentially ONE CSS file. index.css keeps its Tailwind v4 token foundation (@layer order + @theme + [data-theme] + shadcn bridge) and now also carries, in a clearly-commented "CONSOLIDATED COMPONENT STYLES" section, the former residual.css plus all 28 component .css files — verbatim, unlayered, appended AFTER index.css's own rules so the previous cross-file load order (index.css → residual.css → component css) is preserved exactly. @keyframes move by name (all globally unique); glass/backdrop-filter, cascade-override hooks, and library-DOM hooks (WaveSurfer wfm-*, virtualized rows) keep winning over @layer utilities because they stay unlayered. Zero visual/behaviour change — proven by the 48-snapshot visual suite passing with no PNG diffs. CSS files: 31 → 2 (src/index.css + src/test/visual/harness.css, test-only). Deleted (29), each import removed from its component: styles/residual.css, components/{Misc,firstrun,Sidebar,LogsFooter,CaptureWidget, CompareModal,FloatingPill,DubSegmentRow,DubSegmentTable,SegmentTrack,VoicePreview, WaveformErrorBoundary,WorkspaceHistory,WorkspaceVoices}.css, components/dub/dub.css, components/donate/{DonateGoal,Postcard}.css, components/settings/{AppearancePanel,PerformancePanel,VoicePanel}.css, pages/{AudiobookTab,BatchQueue,Settings,VoiceGallery}.css, ui/{Dialog,Menu,Table,Tooltip}.css Kept: src/test/visual/harness.css (test-only harness chrome; not shipped). Guard update: test/workspaceHistoryReflow.test.js now slices the WorkspaceHistory block out of index.css by its provenance markers, so the #476 CTA-clipping regression guard (no @media max-width, shell-class reflow, sticky action bar) still holds on the relocated rules. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> feat(settings): rebuild Settings as a sidebar-nav hub with full app-level IA (#835) * 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. * 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. * 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. * 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: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> docs(contributing): update CSS guidance for the shadcn/Tailwind end-state (#834) The CSS→Tailwind/shadcn migration is largely complete: every screen is on shadcn/ui primitives + Tailwind utilities, and the design tokens were consolidated into a single foundation file (`tokens.css`/`themes.css` folded into `src/index.css`'s @theme/[data-theme]). The old note still pointed at the deleted `src/ui/tokens.css` and said "migration in progress". Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> feat(ui): rewrite demo/transcription/queue components on clean shadcn, delete their CSS (fast mode) (#833) FAST-mode shadcn migration of the tail components — the demos, the transcriptions history, the batch queue, and the audiobook tab — onto the shared src/ui primitives (Button/Panel/Badge/Tabs) + Tailwind token utilities (bg-card/text-fg/border-border + standard spacing), dropping each component's stylesheet where the residual rules reduce cleanly to utilities. Fully deleted (residuals inlined as utilities): - DictationDemo.css — status pills, scripts grid, result boxes (gruvbox hues preserved as arbitrary utilities; em → not-italic; .dictation-demo/.__scripts class hooks kept for tests) - DubbingDemo.css — container/loading shell, 720px collapse → max-[720px]:, checkbox accent, pane-label/video, active chip - Transcriptions.css — search input (placeholder:/focus:), item hover/active, seg-title h4 → div (escapes the unlayered global h1-h4 rule); list scrollbar dropped as redundant with the global ::-webkit-scrollbar Trimmed to genuinely-irreducible only (import kept): - BatchQueue.css — only the progress-fill gradient + ::after shimmer + @keyframes remain; the bar heading (h1 → div role= heading) and per-status card borders are now utilities - AudiobookTab.css — only the <textarea> override (beats the unlayered textarea.input-base + custom 900px floor) remains; title (h2 → div role=heading), field labels (utility const), body/side collapse (max-[900px]:), and the redundant select width are now utilities Behavior preserved: test class hooks intact, headings keep heading semantics via role/aria-level. Verified: vite build, oxlint (0), oxfmt clean, vitest 641/641, visual 48/48, bun install --frozen-lockfile clean. Eyeballed all three pages + states in the dev app. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> feat(ui): rewrite workspace/voice tail components on clean shadcn, trim their CSS (fast mode) (#832) FAST-mode shadcn/Tailwind migration of the workspace + voice "tail" components: the cleanly JSX-controlled chrome moves onto the JSX as Tailwind v4 utilities (token utilities + arbitrary var()/px to preserve exact pixels/colors), with irreducible CSS kept co-located. - WaveformPlayer: all three render branches (player, native fallback, missing notice) converted to Tailwind; WaveformPlayer.css deleted (-87). The `wf-player__btn` class is retained as the focus-visible hook for the shared a11y ring in index.css; the dead `wf-player__spin` rule + `wf-spin` keyframe + its reduced-motion block were removed. - VoicePreview: popover container/header/title/close/body/foot/hint converted to Tailwind; VoicePreview.css trimmed 87->23 lines. Kept the `voice-preview-in` entrance @keyframes (referenced via animate-[…]) and the `.voice-preview__select`/`__text` rules — they layer on top of the *unlayered* shared `.input-base`, and Tailwind utilities (in @layer utilities) would lose that cascade, so they stay unlayered. - WorkspaceHistory: finished the voice variant, which #781 left on the now-deleted `.wh`/`.wh__head`/`.wh__title`/`.wh__scroll`/`.wh__empty` classes (rendering unstyled). Converted them to the same Tailwind utilities the dub variant already uses. Kept the studio-with-history/ studio-right/shell-narrow layout + the `.studio-action-bar` sticky override (#476, guarded by workspaceHistoryReflow.test.js). - WorkspaceVoices: already fully converted by #781; its `.wv*` chrome is shared with the out-of-scope WorkspaceProjects.jsx, so the CSS stays. Verified: vite build OK, oxlint exit 0, oxfmt --check clean, 641 vitest pass (incl. workspaceHistoryReflow + waveform), 48 visual pass, bun install --frozen-lockfile clean. Eyeballed the Voice workspace (history rows + waveform players) and the VoicePreview popover in a live dev run. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> feat(ui): rewrite dialog/panel tail components on clean shadcn, delete their CSS (fast mode) (#831) Migrate the tail dialog/panel components onto the shadcn-backed `src/ui` primitives + Tailwind utilities, removing their bespoke stylesheets. - BatchAddDialog: rebuilt on the `Dialog` primitive (header/body/footer + Radix overlay/animation/focus-trap replace the hand-rolled overlay/card), drop zone + toggle + select moved to Tailwind / the `Select` primitive. BatchAddDialog.css deleted. - KeyboardCheatsheet: rebuilt on the `Dialog` primitive; kbd pills, section grid, rows and footer are now Tailwind utilities. KeyboardCheatsheet.css deleted. - CompareModal: kept as the deliberate non-modal bottom drawer (preserves the "app stays interactive behind" behavior — a shadcn modal Dialog would regress it). Inner content already rode the shadcn primitives; migrated the two remaining CSS-class deps (`.compare-textarea--noresize` -> `resize-none`, `.ui-compare__grid` base -> Tailwind `grid grid-cols-2`). CompareModal.css slimmed to just the irreducible drawer chrome + slide-up keyframe; the responsive one-column collapse stays owned by index.css via the retained `ui-compare__grid` class hook. - GlossaryPanel: table styling moved to Tailwind (`[&_th]`/`[&_td]` descendant utilities); the `.glossary-panel .ui-panel__body` max-height override replaced by a `max-h-[35vh] overflow-y-auto` wrapper. GlossaryPanel.css deleted. - Misc.css: removed only the CompareModal-owned `.compare-textarea--noresize` rule; the rest is shared by out-of-scope components (CheckpointBanner, DirectionDialog, App startup/wizard, AudioTrimmer) and is kept intact. Behavior preserved exactly (batch add flow, cheatsheet overlay, compare A/B, glossary add/edit). Verified: vite build, oxlint (0), oxfmt --check clean, vitest (641 pass), visual (48 pass), bun install --frozen-lockfile. Eyeballed all four via a temporary Playwright harness. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> feat(ui): migrate Settings models/reco/engines styling to shadcn, trim Settings.css (fast mode) (#830) Migrate the last reducible CSS chunk in Settings.css — the recommendation banner, the models/engines toolbar chrome, and the role-tab/search controls — to Tailwind utilities (chrome tokens kept) at the JSX, following the established shadcn fast-mode convention. Behavior and palette unchanged. What moved to Tailwind: - RecoBanner (.reco-banner* → utilities on models/RecoBanner.jsx) - Models/Engines toolbar (.models-toolbar* → ModelStoreTab.jsx + EnginesTab.jsx), including the previously-unstyled HF-token inline chrome - Role tabs + search (.models-controls/.models-search/.models-roletabs) What was deleted as dead CSS (zero consumers, grep-verified): - the entire .engines-* block (EnginesTab already on shadcn; no consumer) - the .models-table__body > .models-row override (selector no longer matches the body > virtual > row DOM the table renders) What was KEPT as irreducible styling hooks (cannot be utilities): - .models-table* + .models-row* — the virtualized table geometry. Rows are absolutely positioned with an inline translateY from the virtualizer; the table body/virtual spacer and per-cell hooks must stay class-based. Settings.css: 386 → 225 lines (−161). Not deleted (virtualized hooks remain). Verified: vite build ✓, oxlint 0, oxfmt clean, vitest 641/641, visual 48/48, bun install --frozen-lockfile ✓. Live-eyeballed Settings → Models (store + 17-row virtualized table + reco banner) and Engines (matrix + toolbar) against the live backend; rows render correctly and chrome is coherent. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> refactor(ui): consolidate tokens.css + themes.css into index.css (P5, single foundation file) (#829) Fold src/ui/tokens.css (134 lines) and src/ui/themes.css (195 lines) into src/index.css so the design-token foundation lives in ONE file, then delete the two source files and repoint every import. Pure consolidation — zero behavior/visual change. Cascade is preserved EXACTLY. The previous cross-file load order was tokens.css -> themes.css -> index.css (ui/index.js imported the first two, main-app.jsx imported index.css after). The inlined content reproduces that order inside index.css: the token :root first, then the [data-theme] blocks, then index.css's @theme bridge + its own legacy/chrome :root + rules. The [data-theme] blocks intentionally sit AFTER the token :root but BEFORE the legacy/chrome :root so the --chrome-* tokens (declared in both a plain :root and the [data-theme] blocks at equal specificity) keep resolving by source order exactly as before. Imports updated: - src/ui/index.js: the two token-CSS side-effect imports -> import '../index.css' (preserves "import a primitive, get the full token scale" for every consumer). - src/test/visual/harness.jsx: drop the tokens/themes imports, keep index.css. - src/test/tokenParity.test.js: read the token :root from index.css (located by its --color-muted-mono signature) instead of the deleted ui/tokens.css. Verified: vite build OK; oxlint 0; oxfmt --check clean; vitest 641 pass (incl. tokenParity); visual suite 48 pass with NO baseline changes (default/ midnight/catppuccin render pixel-identical); bun install --frozen-lockfile clean. Live full-app check (data-theme on <html>) confirms semantic tokens recolor per theme while chrome tokens hold the :root value — identical to pre-consolidation behavior. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> feat(ui): migrate settings primitives + panels to shadcn, delete primitives/Settings CSS (fast mode) (#828) FAST-mode shadcn migration of the shared Settings primitives and their ~8 consuming panels onto Tailwind utilities layered on the OmniVoice `--chrome-*` / `--space-*` token bridge — palette and behavior preserved exactly (every migrated snapshot is pixel-identical to its old-CSS baseline). Primitives migrated off the `.st-*` CSS class family (all in JSX now): - SettingsSection → token-bridge Card surface (exported SETTINGS_SECTION_SURFACE + `data-slot="settings-section"` so the raw EnginesTab / ModelStoreTab sections and the Settings.css table hooks stay coupled without `.st-section`). - SettingRow → Tailwind grid; new `stack` prop replaces the `st-row--stack` className; control slot carries `data-slot="setting-row-control"`. Row-stacking reproduced with the Tailwind v4 named-container variant `@max-[600px]/settings:` plus the legacy `max-[560px]:` viewport fallback. - SettingsToggle, SettingsInput, InfoHint, Collapsible → Tailwind utilities. Consumers updated to the new API: - GeneralTab, StoragePanel, CredentialsTab, AppearancePanel, HFMirrorPanel, RemoteBackendPanel: `st-row--stack` → `stack` prop; raw `.st-input` inputs → SettingsInput; raw `.st-section` (EnginesTab, ModelStoreTab) → token surface + data-slot. - AppearancePanel.css / VoicePanel.css `.st-row__control` hooks → `[data-slot=setting-row-control]`; Settings.css `.st-section` hooks → `[data-slot=settings-section]`; `.models-search.st-input` → `.models-search`. Deleted primitives.css (368 lines) and removed its imports (primitives barrel + visual harness). The `.models-*` / `.reco-*` / `.engines-*` table families in Settings.css are intentionally LEFT intact (out of `.st-*` scope). Verified: vite build ✓, oxlint 0, oxfmt clean, vitest 641 ✓, visual 48 ✓ (baselines pixel-identical — only the harness CSS import changed), bun install --frozen-lockfile ✓, and a live Playwright eyeball of Settings → General / Appearance / Models / Engines (incl. embedded Storage / Performance / HF-mirror panels) confirms every tab is coherent on-palette. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> feat(ui): rewrite misc components on clean shadcn, delete their CSS (fast mode) (#826) Migrate a batch of MISC components to clean shadcn primitives + Tailwind token utilities, deleting per-component CSS where the styling is fully expressible as utilities. Palette and behavior are preserved exactly. Fully migrated (CSS deleted): - VoiceProfile (+ ProfileHeader / ProfileDetails / ProfileActivity): all voice-profile__* layout/spacing classes → token utilities; the hero panel body becomes an explicit flex wrapper inside <Panel> (drops the external .ui-panel__body override). Deletes VoiceProfile.css (217 lines). - Projects (OmniDrive): title / search input / view-toggle / filter rail / card grid+list variants → utilities (list/grid driven by a `view` prop instead of descendant-combinator CSS; per-card --card-accent kept via inline style + arbitrary utilities for border-left and the color-mix hover). Deletes Projects.css (181 lines). - NotificationPanel: the .notif-* dropdown rules were already dead (the JSX migrated to utilities in a prior wave; the dropdown now lives in LogsFooter). Drops the dead import + deletes NotificationPanel.css (201 lines). Trimmed (irreducible CSS kept): - CaptureWidget: content / label / timer / dismiss / spinner moved to utilities (spinner uses motion-safe:animate-spin). Kept the irreducible glass always-on-top window shell, state borders, slide-in/dot-pulse keyframes, reduced-motion, and the `body:has(.capture-pill)` standalone- window transparency rule. Kept as-is (with reason): - FloatingPill: its remaining CSS is all irreducible — fixed+glass shell, enter/exit + dot-pulse + indeterminate-sweep keyframes, and unlayered --done/--error border/label overrides that must out-rank @layer utilities (the file's own comments document this). Content/meta/progress/dismiss were already utilities. - PerformancePanel: already built on the shared SettingsSection/SettingRow primitives; its CSS (.perfpanel__error/__row/__badge/__help) is a SHARED stylesheet consumed by 7+ settings panels (MCPBindings, RemoteBackend, Refinement, LLMEndpoint, Pronunciation, HFMirror, …), so it can't be deleted without migrating out-of-scope panels. Verify: vite build ✓, oxlint (0), oxfmt --check clean, vitest 641 pass, visual 48 pass, bun install --frozen-lockfile ✓. Eyeballed Projects + VoiceProfile + header bell via Playwright (real backend proxied through route interception) — coherent, zero console errors. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> feat(ui): rewrite modals + segment/matrix components on clean shadcn, delete their CSS (fast mode) (#825) Migrate the independent modal + segment/matrix components onto the shadcn-backed `src/ui` primitive surface + Tailwind utilities, deleting their bespoke component CSS. Palette kept, behaviour intact. - SupertonicLicenseDialog: rebuilt on the shadcn Dialog primitive (Radix focus-trap / scroll-lock / ESC); non-dismissable while the license POST is in flight. Accept/Cancel via shadcn Button. Deletes SupertonicLicenseDialog.css. - ExportModal: kept as the non-blocking bottom drawer (background stays interactive — Radix Dialog would break that), but folded the track chips / tab strip / toggles / drawer shell into Tailwind utilities and swapped the slide-up keyframe for tw-animate-css. Deletes ExportModal.css. - ErrorBoundary (WaveformErrorBoundary.css): fallback UI rebuilt on Tailwind + shadcn Button. Removed the `errbnd-*` block from the shared CSS; the `wfm-*` WaveformTimeline rules stay (file still imported by WaveformTimeline). - EngineCompatibilityMatrix: folded the GPU-chip color system, `is-effective` highlight, `Why unavailable?` disclosure triangle, and the horizontal-scroll table min-width into Tailwind. Kept the `is-effective` marker class (matrix test asserts it), roles, testids, and aria-labels. Deletes EngineCompatibilityMatrix.css. DubSegmentRow / SegmentTrack were already migrated in a prior wave and already use the shadcn-backed Button/Badge/Menu; their remaining CSS is the deliberate irreducible remainder (cascade-fighting `!important` state rules that must stay unlayered to beat index.css, `font:inherit` focus rings, `input-base`/range overrides), so it stays co-located. The shared `segment-*` contract in index.css is left untouched. Verified: vite build, oxlint (0), oxfmt --check clean, full vitest (641 pass incl. ExportModal/SegmentTrack/EngineCompatibilityMatrix/ ErrorBoundary), visual suite (48 pass), and a real-browser eyeball of all four rewritten components via the visual harness. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> feat(ui): migrate app-container shell + Sidebar to utilities, trim index.css (fast mode) (#824) Shell (app-container grid) — KEPT as-is, by design. The outer `.app-container` grid family is the canonical cross-cutting positioning hook and is deliberately left in index.css: - `appShellScale.test.js` parses the literal `.app-container { … }` block and asserts the `zoom`/`calc(100vw/--ui-scale)` scale pattern + the `[data-zoom-layout=off]` 100vw/100vh fallback — migrating the base rule away would break that regression guard. - `LogsFooter.css` hooks `.app-container .logs-footer` and `.app-container.rail-right .logs-footer` (+ a ≤600px media query) via ancestor combinators that Tailwind utilities can't express. - Child placement (nav-rail / history-panel / main-content) comes from `.app-container > .child` descendant combinators that reflow `grid-column` across six dynamic state classes (sidebar-collapsed / sidebar-hidden / rail-right / shell-narrow / shell-mini); reproducing that as utilities would require editing out-of-scope child components. Net index.css delta: 0. Sidebar.css — safe, contained migrations + dead-rule removal: - Moved the two collapsed combinators whose base is already utilities to conditional utilities in Sidebar.jsx: `.sidebar.is-collapsed .sidebar__tabs` and `.sidebar__scroll.is-collapsed` (mutually-exclusive conditional classes, so no Tailwind same-property ordering trap). - Removed dead/redundant rules: `.sidebar.is-collapsed .sidebar__tab svg` (icon size already set by the JSX `size` prop) and the `.sidebar.is-collapsed .sidebar__subtitle` / `__search` hides (both blocks are already gated out of the JSX when collapsed). Kept (reported): `.sidebar__tab` base + :hover/.is-active/:focus-visible (is-active must beat :hover via source order — not reproducible cleanly in layered utilities), `.sidebar.is-collapsed .sidebar__tab` (its base is still unlayered CSS, so the override must stay unlayered too), `.sidebar__search-input` (overrides the unlayered `.input-base` primitive), `.sidebar__search-clear` (!important Button overrides), `.sidebar__save-btn*` (consumed by out-of-scope WorkspaceProjects.jsx), `.sidebar__section-title:hover` + `.sidebar__icon-tile` states (prior-wave unlayered-by-design), `.sidebar.is-collapsed .sidebar__empty` (shared EmptyState has no collapsed prop), and all `history-*` rules (consumed by the out-of-scope Workspace* feature panels). Verified: vite build, oxlint (0), oxfmt --check clean, vitest 641/641 (incl. appShellScale guard), visual 48/48, bun install --frozen-lockfile. Eyeballed Launchpad + responsive widths (1280/1000/560/1366) + a forced-render of the collapsed Sidebar: rail/header/main/footer placement intact, footer reclaims full width at ≤600px, 0 console errors. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> feat(ui): rewrite marketing/donate pages on clean shadcn, delete their CSS (fast mode) (#823) Rewrites the static marketing/info surfaces on shadcn primitives (Card / Button / Badge) + Tailwind token utilities, dropping the three legacy page stylesheets. FAST mode: clean shadcn + Tailwind defaults, palette kept (via the existing color-mix + var(--chrome-*) arbitrary utilities), behavior intact, not pixel-perfect. - SupportPage.jsx (SupportView=donate + LicenseView=enterprise): hero, segmented Support/License toggle, Fund-Claude-Max goal Card, amount picker, Ko-fi/PayPal link cards, benefit Cards, and the per-deployment quote panel — all on Card/Button/Badge + Tailwind. All i18n keys, URLs, openExternal, amount state, and view toggling preserved. - ContactPage.jsx: hero + Discord/Email/Issues/Website channel cards rebuilt as hue-tinted Tailwind link rows. - Deleted DonatePage.css (282), EnterprisePage.css (233), SupportPage.css (140) = 655 lines removed; no JS imports them anymore. Kept (shared, untouched): index.css `.lp-aurora*` + `.lp-hero__sweep` (also used by Launchpad). Left the donate widgets (GoalBar/Pip/Postcard) and their DonateGoal.css/Postcard.css in place — already Tailwind-based with genuinely irreducible keyframes (goal-fill grow, Pip bob/wave, postcard stamp/perforation), the sanctioned "small co-located keyframe CSS" exception. The dead no-op `lp-glow-card` class (never defined in CSS) was dropped. Verified: vite build ✓, oxlint 0, oxfmt clean, vitest 641 pass, visual 48 pass, bun install --frozen-lockfile ✓. Eyeballed Donate/Enterprise/Support + Contact in chromium against a stubbed backend — all coherent and on-palette. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> feat(ui): rewrite clone/design on clean shadcn, delete CloneDesign CSS + studio shell (fast mode) (#819) Migrate the Clone / Voice-Design feature area to clean shadcn primitives + Tailwind utilities, deleting the 338-line CloneDesignTab.css and trimming the `studio-*` shell from index.css. Fast mode: palette kept, behavior intact, no pixel-perfect reproduction. Components rewritten on utilities (token utilities bg/border/text + standard spacing), behavior preserved exactly: - MicButton: mic-btn idle/recording/cleaning → utilities; pulse/spin reuse the global keyframes via `animate-[…]`. - ScriptPanel: studio-column/studio-panel, the ⊕ Insert button + popover, coachmark close, and the script textarea → utilities. - AudioMethodPanel: drop zone (clone-drop-zone padding override folded in), design-seed input, save-as-profile row → utilities. - DesignMethodPanel: describe textarea, Starting-points scroll lane (mask edge fade), identity recipe line, category chip/select grid → utilities. - ActionBar: production-override sliders row, language/steps controls, overrides disclosure, footer CTA → utilities. - CloneDesignTab: clone-split-grid + voice column/panel → utilities; CSS import removed. studio-* shell decision (grep-verified cross-file): - `.studio-panel` — KEPT (dub: DubLeftColumn/RightColumn/Footer, IdleSkeleton). Clone usages migrated to inline utilities. - `.studio-action-bar` — KEPT, relocated from the deleted CSS into index.css. WorkspaceHistory.css adds its `position: sticky` narrow-shell override (#476 CTA-clip fix, guarded by workspaceHistoryReflow.test.js), so it stays a class. Its __row/__lang/__steps/__overrides children migrated to utilities. - `.studio-column` — DELETED (only consumers were clone; now utilities). Removed `.identity-line`/`.clone-insert-btn`/`.studio-action-bar__overrides` from the shared focus-visible rule; the accent ring is now inline on each. Verify: vite build ✓, oxlint 0, oxfmt clean, vitest 641 pass, bun --frozen-lockfile ✓. Eyeballed both From-audio and By-design sub-views (incl. production overrides) in chromium — coherent. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> feat(ui): rewrite Gallery/Stories/Logs on clean shadcn, delete their CSS (fast mode) (#822) FAST-mode shadcn migration of three independent areas — Voice Gallery, Stories editor, and the logs/status footer — onto shadcn primitives (src/ui barrel over src/components/ui/*) + Tailwind token utilities. Palette kept; behavior preserved; ~1000 lines of bespoke CSS removed. Voice Gallery (VoiceGallery.jsx + gallery/{ArchetypeCard,ArchetypesZone, CommunityZone,ImportsZone}.jsx): - Zone toggle → <Segmented>; category chips → Button variant="chip"; facet dropdowns → <Select>; grid/list view toggle → <Segmented>; cards/chips/ buttons/empty/loading → Tailwind token utilities. - VoiceGallery.css 427 → 70 lines: kept only the now-playing equalizer @keyframes, the .arch-avatar/.accent-flag/.flag-globe classes rendered by the out-of-scope utils/archetypeIcons.jsx, and the app-wide .spin helper (it lived here, NOT in index.css — kept to avoid breaking ~30 consumers). Stories editor (StoriesEditor.jsx): - Track grid, chapter bar, cast/projects/split panels, tone/speed drawer, and native textarea/select/range chrome → Tailwind utilities; reusable class-string consts hoisted. Drag-reorder, preview chain, generate/stems, global speed, refs, i18n keys and aria-labels all unchanged. - StoriesEditor.css 349 → 0 lines (file deleted; import removed). The dead .stories-track__voice-dot[data-char] palette (no data-char ever set) and cosmetic webkit scrollbars were dropped. - Native <select>s get [color-scheme:dark] so the cast/voice pickers render on dark chrome across WebKit/WebView2/WebKitGTK (matches the old .facet-select intent; the original cast select was unstyled/light). Logs/status footer (LogsFooter.jsx): - Icon buttons, source pills + severity badges, version badge + pulse dot, discord/contact/donate, log lines and notification severity → Tailwind utilities. Spinner → motion-safe:animate-spin; reduced-motion via motion-reduce: variants. - LogsFooter.css 376 → 78 lines: kept the position:fixed shell + the .app-container/.rail-right/≤600px ancestor-combinator insets (can't be element-local), the body ::-webkit-scrollbar, the collapsed/open heights, and the version-dot-pulse/heart-glow @keyframes. - The shared --chrome-* token vars and index.css are untouched; Header is unaffected (no logs-footer__* class is referenced outside this component). Verified: vite build, oxlint (0), oxfmt --check (clean), vitest (641 passed), bun run test:visual (48 passed), bun install --frozen-lockfile. Eyeballed all three areas in a stubbed dev build — coherent and on-palette. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> feat(ui): move Settings page chrome to Tailwind, drop page-specific CSS (fast mode) (#820) FAST-mode shadcn pass over the Settings *page chrome*. The settings tab components already render on shadcn — the live `src/ui/*` primitives (Button/Badge/Tabs/Segmented/Slider/Input) are thin wrappers over the `src/components/ui/*` shadcn primitives via the index.css token bridge — so the only non-shadcn layer left here that is *safe to migrate* is the page layout itself. What changed: - Settings.jsx: `.settings-page` / `.settings-content` are now Tailwind on the token bridge — the centered, scrollable column that becomes a [rail | content] grid at ≥760px, and the content column that establishes the `settings` container query primitives.css relies on. No behavior change: tab nav, deep-link tab, and every panel render exactly as before. - Settings.css: removed the page-chrome rules now living in Tailwind (`.settings-page` + grid, `.settings-content`) and the dead ones (`.settings-row__mono`, `.settings-section__head-*`). Kept what can't migrate: the tab-rail look (must stay UNLAYERED to win over the shared shadcn Tabs primitive), `.settings-prose strong`, and the Models/Engines/recommendation rules consumed by their sub-components. - index.css: removed the duplicate base `.settings-page` block. Deliberately NOT deleted (verified by cross-file grep, per "delete once unused"): primitives.css + the `.st-*` class contract (out-of-scope StoragePanel passes `st-row--stack`; AppearancePanel.css/VoicePanel.css/PronunciationPanel test reach into `.st-row__control`), and Settings.css's `.models-*`/`.engines-*`/ `.reco-*` (consumed by out-of-scope ModelsTable / RecoBanner / EngineCompatibilityMatrix). Deleting either would break out-of-scope code and main CI. Net: 3 files, ~51 fewer CSS lines. Verified: vite build, oxlint (0), oxfmt, vitest (641), visual (48, no baseline change — snapshotted components untouched), bun install --frozen-lockfile. Eyeballed Settings (General + Logs) via the visual harness: rail + content grid + centered max-width + active-tab accent + tab switching all coherent. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> feat(ui): rewrite dub studio on clean shadcn, delete dub CSS (fast mode) (#818) FAST-mode shadcn migration of the Dub Studio feature area. The dub components now style with Tailwind utilities on the OmniVoice palette tokens (bg/text/border via chrome-* + space/text vars) plus the src/ui shadcn primitives (Button/Badge/Progress/Segmented/Table), and the 800-line page stylesheet is gone. What changed - Deleted frontend/src/pages/DubTab.css (800 lines). The irreducible pieces that can't be utilities — keyframe motion (stepper spin, idle-drop pulse, skeleton shimmer), ::before stepper connectors, and a handful of rules that must override other *global* design-system classes (.studio-panel / .label-row / .override-toggle / .segment-del) — moved to a small co-located frontend/src/components/dub/dub.css. - Converted the dub-* presentational classes to inline utilities across DubFooter (footer panel, export-track chips, compression warn), DubLeftColumn (generating overlay, cast strip, the whole translation settings bar + fields), DubRightColumn (output-options rows, transcript body, glossary chip, bulk-select row), IdleSkeleton (speakers input, ingest opt-in, landing advanced, ghost footer + buttons), and TranscribeOverlay (stats row). - Rewrote FooterBtn off the global .btn-primary / .dub-footer-btn subsystem onto a Tailwind tone map (idle/danger/green/pink/amber/…), preserving the flat tinted-outline look. - Removed the dub-* fragments from src/index.css (tabular-nums group, focus-visible group, and the dub-split-grid / dub-settings-bar / dub-footer-btns responsive media queries — now inline max-[…] utils). Kept .btn-primary base (still used by ErrorBoundary) and all shared design-system classes. Left intact (reported): the segment-* subsystem (DubSegmentTable.jsx/css, DubSegmentRow.jsx/css, segment-* in index.css). It's the lowest-risk option for the core, most test-covered segment table; ModelsTable and EngineCompatibilityMatrix were verified NOT to consume segment-* (they use models-*/engine-matrix-*), so nothing else breaks. Behavior preserved exactly — every onClick/state/prop/hook untouched. Verified: vite build ✓, oxlint 0 ✓, oxfmt --check clean ✓, vitest 641/641 ✓, bun install --frozen-lockfile ✓. Eyeballed the idle dropzone and the loaded skeleton (stepper, settings bar, skeleton segment table, footer) in Chromium — palette + layout coherent. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> feat(ui): rewrite first-run/setup on clean shadcn, delete frs CSS (fast mode) (#817) Rebuild the first-run / setup feature area on standard shadcn primitives (Button/Input/Select/Progress/Badge from src/ui) + Tailwind utility classes themed by the OmniVoice palette tokens, replacing the 954-line bespoke "studio console" stylesheet wholesale (FAST mode: clean shadcn look, not a per-pixel reproduction of the old design). Components rewritten: - FirstRunSetup.jsx (install-plan screen: mode/storage/compute/channel, live disk gate, mirrors, Start) - BootstrapSplash.jsx (install progress, steps, activity log, failure hints + retry, awaiting_setup → FirstRunSetup handoff) - WizardLibrary.jsx (unified model/engine list + SSE download progress) - HfTokenCard.jsx (inline HF token bar) - SetupWizard.jsx (preflight + models + dictation acts, stepper nav) All behavior preserved: every onClick/state/prop, the radio-group keyboard nav, the disk-space blocker logic, the SSE progress aggregation, retry / clean-retry, the launch flow, and all exported pure helpers (kept the unit-tested fmtBytes/fmtRate/isPlatformPick/aggregate/progressFromAgg/ radioGroupNav exports). CSS deleted: FirstRunSetup.css (954) + SetupWizard.css (184) + the dead swiz-check* block in Misc.css (~21). The only bespoke CSS kept is a new 63-line firstrun.css holding the three irreducible keyframes (breathing waveform, rise-in stagger, active-step LED pulse) that Tailwind utilities can't express — net ~1075 lines of bespoke CSS removed. index.css had no frs-* rules (0 line delta there). Verified: vite build, oxlint (0), oxfmt clean, vitest (641 pass), bun install --frozen-lockfile. Live-eyeballed all four screens (FirstRunSetup, install splash, failed state, wizard) via Playwright — palette correct, layout coherent, no UA button-chrome leaks. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> feat(ui): migrate waveform-* to utilities, trim index.css (P4) (#816) Move the waveform-* global class family off index.css onto Tailwind v4 utilities on WaveformTimeline.jsx, then delete the now-dead rules. Migrated to utilities (rules deleted): waveform-timeline (mb), waveform-controls + -left/-right (flex/items/justify/gap), waveform-btn + :hover/:disabled and waveform-btn-play + :hover (shared WF_BTN/WF_BTN_PLAY consts; UA <button> padding/font preserved since the app ships no preflight), waveform-time (text/border/bg/mono/tabular-nums), waveform-zoom-slider (important w/h/mt). States -> hover:/disabled: variants; no-preflight borders -> explicit [border:1px_solid_...]; exact px via arbitrary values. Deleted as dead (zero usages anywhere): waveform-video-preview, waveform-track-bg (+ nth-child + the 800px media-query track rows). Kept (irreducible): .waveform-container and its .waveform-container [data-id^="wavesurfer-region"] descendant rules (+ the 800px container/region media query) — those style WaveSurfer-generated DOM we don't render in JSX, so they can't be utilities. The class stays as a hook. index.css net -55 lines (+7/-62). Cascade-correctness verified live (Playwright getComputedStyle, both stylesheets loaded): new utilities reproduce the pre-migration computed styles exactly. Caught two subtleties: (1) controls margin-top is 3px (unlayered wfm-controls already wins over the old 4px), so no mt utility is added; (2) referencing var(--chrome-font-mono) in a class string tripped the global [class*="chrome-font-mono"] selector (adds slashed-zero + ss02) — switched the time font to var(--font-mono) (identical stack) to avoid the substring match. Screenshot pixel-diff old vs new = 0 (AE). Updated record_promo.js's fallback selector (.waveform-controls -> [aria-label="Playback controls"]). Gates: oxlint 0, oxfmt clean, vite build, vitest 641 pass, test:visual 48 pass, bun install --frozen-lockfile no change. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> feat(ui): migrate ss-*/file-drag to utilities, trim index.css (P4) (#815) Move the searchable-select (`ss-*`) and file-dropzone (`file-drag`) global class families out of `src/index.css` into inline Tailwind utilities, then delete the now-dead rules (-131 lines net in index.css). - SearchableSelect.jsx: trigger/label/chevron/popup/search/list/group-label/ option (incl. the highlight + selected + selected-highlighted cascade)/ kind-icon/check/empty/more all rendered with token utilities + arbitrary var()/px values; no-preflight borders made explicit; `:focus`/`:hover` and the `::-webkit-scrollbar` pseudo-elements moved to Tailwind variants. The `.ss-sm/.ss-md .ss-trigger` descendant rules collapse to a size-conditional class on the trigger. `ss-wrap` keeps its class *name* only (its style is now utilities) because residual.css targets `.voice-selector > .ss-wrap` via a cross-file child combinator — deleting the name would break VoiceSelector layout. - AudioMethodPanel.jsx: `.file-drag` (+ `:hover`/`.is-dragging`/`p`) → utilities; `is-dragging` stays a JS-toggled marker matched via `[&.is-dragging]:`. The out-of-scope, unlayered `.clone-drop-zone` padding override still wins. - index.css: removed the `.ss-*` block, the dead `.ss-popover/.ss-menu/ .ss-dropdown/.ss-item/.ss-highlighted` rules (zero JSX usages), and both `.file-drag` blocks, leaving migration breadcrumbs. Verified live (Vite + Playwright/chromium): the Clone screen's dropzone and an open SearchableSelect popup (search box, POPULAR group label, highlighted option) render coherently. Gates: oxlint 0, oxfmt clean, vite build OK, vitest 641 pass, visual suite 48 pass, `bun install --frozen-lockfile` no-op. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> feat(ui): migrate history-* to utilities, trim index.css (P4) (#814) P4 of the shadcn/Tailwind migration for the `history-*` global class family. The family is a shared, cross-file-composed component system used across WorkspaceHistory, Sidebar, WorkspaceProjects and WorkspaceVoices, so most of it is irreducible to per-usage utilities. Migrated the one cleanly-isolable class: - `.history-row-head` -> `flex items-center justify-between gap-2 min-w-0` (pure flex layout; no variants, pseudo-elements, descendant selectors, or cross-file/selector coupling). Converted all 9 usages, deleted the index.css rule (now zero usages). Verified in the running app that the utilities compute byte-for-byte identically to the old rule (display:flex / center / space-between / gap 8px / min-width 0). Kept (composed cross-file / irreducible) and documented for later: - `.history-item` (::before accent bar, descendant hover-reveal, `.project-active` compound, `--row-accent` set inline + `--dub` variant in Sidebar.css, duplicate !important defs) - `.history-panel` (selector target of out-of-scope `.app-container > .history-panel` / `.glass-panel.history-panel`) - `.history-kind` / `.history-meta` / `.history-title` / `.history-subtitle` (each has `--audio` / `--locked` / `--clamp`/`--expanded` / `--italic`/`--seed` variants defined in Sidebar.css) - `.history-actions` (revealed via `.history-item:hover/:focus-within` descendant selector) - `.history-action-btn` / `.history-action-icon` (compound `.accent`/`.danger` hover modifiers; ~30 usages; kept whole as a cohesive subsystem) Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> feat(ui): migrate chip/preset/tag classes to Button variants/utilities, trim index.css (P4) (#813) P4 shadcn/Tailwind migration of the chip/preset/tag global class families out of src/index.css and onto their components as Tailwind utilities. - personality-chip (+ __icon, + .active): -> token utilities inline in clone/DesignMethodPanel.jsx (PCHIP_* consts). Active stays chrome-accent (pink); icon span -> inline-flex items-center. The cross-file `.starting-points__strip .personality-chip { flex:0 0 auto }` in CloneDesignTab.css moved onto the chip as the `flex-none` utility and the dead rule was removed. - chip-group .chip (+ :hover/.active) and the chip-group container: chips -> token utilities (CHIP_* consts) in DesignMethodPanel.jsx; the container's flex layout -> `flex flex-wrap gap-1` utilities. The `chip-group` class name is KEPT on the container purely as a JS hook (CloneDesignTab's roving-tabindex keyboard nav does `closest('.chip-group')`). - tag-btn (Insert-menu token chips): -> token utilities in clone/ScriptPanel.jsx (TAG_BTN const), preserving the mono face. Removing tag-btn's `!important` un-masks the intended `.clone-auto-extract-btn` green on the [CMU] button (author intent restored; palette-coherent). - preset-btn: had ZERO usages -> both rule blocks deleted. - The shared 10x a11y focus ring is reproduced on the migrated chips via a `focus-visible:[outline:2px_solid_var(--chrome-accent)]` utility, on top of the app's global `:focus-visible` ring. Kept (irreducible): the shared `.personality-chip:focus-visible, .chip:focus-visible, ...` a11y rule (groups out-of-scope selectors); `.chip-auto`, `.preset-grid`, `.tags-container`, `.personality-strip` (out of scope, still used). index.css: +13 / -126 (net -113). Verified live (Clone "By design": personality chips, identity chip-groups, Insert tag popover) before/after — pixel-coherent. Gates: oxlint 0, oxfmt clean, vite build, vitest 641 pass, test:visual 48 pass, bun --frozen-lockfile no change. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> feat(ui): migrate hq-* classes to utilities, trim index.css (P4) (#812) Move the header "quick" chrome (hq-*) global class families out of src/index.css onto Tailwind utilities on their sole consumer, Header.jsx, then delete the now-dead rules. No visual change (verified live below). Migrated families: hq-col-* (layout columns), hq-logo-*, hq-breadcrumb-sep, hq-view-* (breadcrumb title/dot/kicker/label/project + icon), hq-stats* (readout + status badge override), hq-flush-btn/reload-btn, hq-flush-dropdown* (portalled memory dropdown), hq-wave/hq-wave-bar (mini waveform). The three @keyframes (flush-slide, hqPulse, hqBounce) are kept in index.css and driven via [animation:...] arbitrary utilities. - no-preflight: borders set explicitly with [border:...] arbitrary props. - Badge override (hq-stats__status-badge) uses important modifiers (foo!) to beat the primitive's own utilities. - @media responsive rules become max-[Npx]: variants on the elements. Tailwind v4's max-[N] compiles to `not all and (width>=N)` = strictly `< N`, whereas the original `@media (max-width: N)` is `<= N`; bumped each breakpoint +1px (e.g. 820 -> max-[821px]) so the boundary pixel matches exactly. - The dead `.hq-scale` rule (zero usages) is dropped; the surviving non-hq @media rules (.header-area reload/wordmark hide) stay in index.css. index.css: 224 lines removed, 2 added (net -220). Verified live (vite :3922, Playwright chromium, backend :3900 stubbed): header at 1600/1000/820px + flush dropdown open, before vs after pixel-diff — 820px identical (0px); residual sub-1% diffs at other widths are purely the live pulse-dot / wave-bar animation phase (the only red regions in the diff). Gates green: oxlint 0, oxfmt clean, vite build, vitest 641 passed, bun install --frozen-lockfile no change, test:visual 48 passed. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> feat(ui): migrate nav-rail/rail-btn to utilities, trim index.css (P4) (#811) Move the `nav-rail` + `rail-btn` global class families out of src/index.css into Tailwind utilities on NavRail.jsx, deleting the entire 120-line nav-rail CSS block. - `.rail-btn` / `:hover` / `.active` (+ accent `::before` indicator bar) → utilities on the shared RailBtn button; active state and the edge-indicator side are driven by props (`active`, `side`) instead of the `.nav-rail.rail-right` descendant selectors. - `.rail-label` tooltip → group-hover utilities; flips edge by `side`. - `.rail-flip` and `.donate-pill` (+ `donate-pill__heart`, reduced-motion) → utilities, incl. `motion-reduce:` for the heart. - `.nav-rail .rail-top` / `.rail-bottom` → flex utilities. The `nav-rail` CLASS is retained on the <aside> purely as the layout hook the out-of-scope `.app-container > .nav-rail` grid rules position by (those selectors are unlayered, so they still win over the layered utilities); only its visual rules are deleted. No-preflight safe: borders use explicit per-side `[border-*:1px_solid_…]` shorthands (the flip button uses four independent side shorthands so the top hairline can't be reset by a `border` shorthand override). Verified: live before/after pixel-diff of the rail on Launchpad + Gallery is pixel-identical (AE=0). oxlint/oxfmt/vite build clean, vitest 641 passed, frozen lockfile unchanged. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> feat(ui): migrate Launchpad lp-* classes to utilities, trim index.css (P4) (#810) Part 4 of the shadcn/Tailwind migration. Moves the Launchpad's static layout/typography lp-* global classes from src/index.css onto the component as Tailwind utilities (token-referencing arbitrary var() values to preserve exact spacing/colour, explicit border shorthand for the no-preflight setup, max-[900px]/max-[640px] variants for the former @media rules), then deletes the now-unused rules from index.css. Migrated + deleted: lp-hero (+__row/__col/__kicker-row/__wave-group), lp-kicker, lp-hero__title (+em), lp-hero p / .lp-pill, the dead .lp-underline rule, lp-actions (grid container), lp-section, lp-section-title (+::after divider via after:), lp-section__grid, lp-col, lp-proj-icon--* tints, lp-proj-meta--italic, lp-files__head/ __grid + lp-view-all + lp-file-card, lp-locked-badge, lp-empty (+__inner/ __bars/__hint), lp-dub-thumb, lp-demo-callout (+__icon/__btn), lp-project-card (+ .proj-icon/info/name/meta/action), lp-ab-compare, and the unused lp-action-card__emoji. Kept (reported, not forced): - Cross-file shared, reused by ContactPage/SupportPage/DonatePage.css/ EnterprisePage.css: .lp-aurora, .lp-aurora__blob(+--pink/green/amber), .lp-hero__sweep (+ their @keyframes). - ::pseudo / structural-selector / cursor-tracking component that can't be flat utilities: the .lp-action-card family + .lp-glow-layer (::before spotlight, ::after breath ring, nth-child stagger), .lp-animate. - @keyframes-driven: .lp-wave-bar, .lp-hero__halo, and all @keyframes (lpDrift1-3, lpHeroHalo, lpHeroSweep, lpBreath, lpFadeUp, lpWaveBeat) + the prefers-reduced-motion block. The bare `h1,h2,h3,h4` rule is unlayered, so the hero title's serif font-family + letter-spacing utilities use `!` to win the cascade over it. Verified: Launchpad landing screenshot is pixel-identical before/after (Playwright chromium, animations disabled). oxlint 0, oxfmt clean, vite build, vitest 641 passed, test:visual 48 passed, frozen lockfile unchanged. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> feat(ui): migrate settings/form/row global classes to utilities, trim index.css (P4) (#808) P4 of the shadcn/Tailwind migration. Targets the settings/form/row LAYOUT globals in src/index.css: - .settings-log: converted its sole usage (LogsTab.jsx) to Tailwind utilities (bg/border/rounded/padding/max-h/overflow/font-mono/whitespace), then deleted the rule. --chrome-font-mono is an alias of --font-mono, so `font-mono` is exact parity; no visual change (live-verified on the Logs tab). - .settings-section + .settings-section h2 and .settings-row(.label/.value/ :last-child): zero remaining usages — superseded by the st-section primitive (components/settings/primitives/SettingsSection.jsx) in an earlier wave. Deleted as dead code. Left BLOCKED (cross-file/cross-wave contracts, not forced): - .settings-page / .settings-page h1 / .settings-page .settings-subtitle — extended by pages/Settings.css via descendant selectors and a media-query grid override that depend on the class living in the DOM. - .label-row / .label-icon — owned by the clone/dub workspaces (out of scope), extended in CloneDesignTab.css and DubTab.css. Verified: oxlint (0), oxfmt clean, vite build, vitest (641 passed), bun install --frozen-lockfile (no change), test:visual (48 passed), and live Settings screenshots (General/Appearance/Models/Engines/Credentials/Logs) before-vs-after coherent. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> feat(ui): migrate misc global helper classes to utilities, trim index.css (P4) (#807) P4 of the shadcn/Tailwind migration — eliminate small, self-contained MISC global helper classes from src/index.css by converting their raw-className usages to Tailwind utilities, then deleting the dead rules. Migrated + deleted: - .grid-2 (1 usage, AudioMethodPanel.jsx) → grid grid-cols-2 gap-[6px] max-[700px]:grid-cols-1, preserving the responsive single-column collapse. - .grid-4 (1 usage, clone/ActionBar.jsx) → grid + arbitrary [grid-template-columns:repeat(auto-fit,minmax(120px,1fr))] gap-[6px] max-[500px]:grid-cols-2, preserving the responsive collapse. - .val-bubble (7 usages, clone/ActionBar.jsx) → text-[0.65rem] bg-black/35 px-[5px] py-px rounded-[3px] explicit border (preflight is disabled) + [font-variant-numeric:tabular-nums]. - .grid-3 was already dead (no base rule, no usages — only stray media-query overrides) and is dropped alongside the grid-2/grid-4 collapse block. index.css net -10 lines. The other class families in this file are component-scoped (hq-*, lp-*, ss-*, segment-*, waveform-*, settings-*, etc.) or owned by other waves/agents, so they were left untouched. Verified: Clone tab (base + Production Overrides expanded) screenshots are pixel-identical before/after. Gates: oxlint 0, oxfmt clean, vite build, vitest 641 pass, visual suite 48 pass, bun install --frozen-lockfile no-change. Part of a HELD batch — do not merge standalone. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> feat(ui): migrate global .ui-btn-* classes to shadcn Button, delete ui/Button.css (P4) (#805) P4 of the shadcn migration. Removes the global `.ui-btn*` button design-system family (the last raw-className button class set still applied directly in JSX) by routing every consumer through the shadcn-backed Button component / `buttonVariants()` helper, then deletes the now-dead stylesheet. Migrated — AudiobookTab.jsx (9 raw `.ui-btn*` sites): - `<button>` actions (Preview plan / Create / cover-remove / lex-remove / Add word / chapter-preview) → `<Button variant={subtle|primary|icon}>`. - non-<button> elements that can't be the component (file-picker `<label>`s, the download `<a>`) → shadcn `buttonVariants({ variant:'subtle' })` className, preserving label/anchor semantics + href/download/file input. - onClick / disabled / aria-label / inline style all preserved verbatim. Deleted: - `src/ui/Button.css` (177 lines) — the entire `.ui-btn*` family; it had no remaining consumers (the Button component stopped emitting these classes in the earlier shadcn wrap). Dropped its import from `ui/Button.jsx` and refreshed the stale comment in `index.css` that referenced it. Left for a later wave (blocked — see step 4): - `.btn-primary` (index.css) — composed/extended by DubTab.css (`.dub-footer-btn` tone family, `.dub-change-row__cta`, `.dub-skel-gen-btn` all "sit on .btn-primary") + index.css media queries; deleting needs a refactor of the whole dub footer button subsystem. Risky, left intact. - `.frs-btn` (FirstRunSetup.css) — custom LED indicator (`.frs-btn__led`) + `.is-armed` animated state with no Button-variant equivalent, spanning the entire first-run/setup flow (the project's Core Value). Left intact. Part of a held batch — do not merge standalone. Verified: live screenshots of Launchpad + AudiobookTab before/after (buttons render on-palette — brand-pink primary, bordered subtle pills, correct sizes); oxlint 0; oxfmt clean; vite build ok; vitest 641 pass; test:visual 48 pass; bun install --frozen-lockfile no change. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> feat(ui): back Dialog/Tooltip/Tabs/Menu/Panel with shadcn (prop APIs preserved) (#803) Migrate the five overlay/nav primitives in src/ui to compose the shadcn/ui layer in src/components/ui, while keeping their existing prop surfaces and exports byte-for-byte so no call site changes. shadcn wraps the SAME @radix-ui primitives these already used (dialog, tooltip, tabs, dropdown-menu) plus Card for Panel, so the swap is structural, not a behavior change. No new dependencies — every required @radix package was already pinned; package.json and bun.lock are unchanged. - Added src/components/ui/{dialog,tooltip,tabs,dropdown-menu,card}.tsx (new-york style, themed through the existing index.css token bridge; DialogContent gains showCloseButton, TooltipContent gains showArrow, Card gains asChild so the wrappers can preserve their exact look/markup). - Wrappers now delegate positioning + open/close animation to shadcn (Radix data-[state]/data-[side] + tw-animate-css animate-in/out). The GLASS look that utilities can't express in this Tailwind v4 build (backdrop-filter + layered gradients) stays in CSS, now keyed off shadcn data-slots / passed via the .ui-* classes — unlayered, so it wins over shadcn's bg-popover/bg-card. - Dialog.css/Menu.css/Tooltip.css trimmed to surface-only (obsolete position + @keyframes removed); residual.css .ui-panel--glass unchanged. - Tabs active/inactive state moved to data-[state] variants so it has the right specificity to override shadcn's TabsTrigger defaults; .ui-tabs/.is-active and all other cross-file hooks preserved. Verified: oxlint (0), oxfmt clean, vite build, vitest (641 pass), bun install --frozen-lockfile clean, and the visual suite (48 pass) — Panel + Tabs render pixel-identical to existing baselines, so no baseline updates were needed. Dialog/Menu/Tooltip are Radix-portal and not snapshot-harness-coverable; verified via build + vitest + review. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> feat(ui): back Button/Badge/Progress/Segmented with shadcn (prop APIs preserved) (#799) Migrate four OmniVoice UI primitives onto shadcn/ui foundations while keeping their exact legacy prop APIs, so no call site changes. - Button: thin wrapper over src/components/ui/button.tsx. Extends the shadcn CVA with the OmniVoice variants (primary/subtle/softGhost/danger/chip[+Active]/ preset[+Active]/iconBtn[+Active]) + sizes (omniSm/omniMd/chip/preset/iconSm/ iconMd), styled via palette token utilities. Maps variant/size/iconSize/active/ loading/leading/trailing/block/ref. Each variant sets an explicit border (transparent where needed) since the app ships Tailwind without Preflight. - Badge: new src/components/ui/badge.tsx; CVA carries the tones (neutral/brand/ success/warn/danger/info/violet) + xs/sm sizes. Wrapper maps tone->variant and keeps the ui-badge / ui-badge__dot hooks so the Header --pulse animation works. - Progress: new src/components/ui/progress.tsx (on @radix-ui/react-progress) with indicatorClassName + indeterminate support. Wrapper keeps per-tone gradients, sizes, shimmer overlay, and the ui-progress / has-shimmer / is-indeterminate hooks (residual.css keyframes unchanged). - Segmented: new toggle.tsx + toggle-group.tsx (adds @radix-ui/react-toggle). The `seg` toggle variant reproduces the segmented look; wrapper preserves the items/value/onChange/size API. residual.css: drop the obsolete .ui-seg__opt:focus-visible rule (focus now falls through to the global ring). Badge pulse + Progress shimmer/indeterminate rules kept (still needed). Visual baselines: Badge/Progress/Segmented render byte-identical to before; only Button baselines updated (shadcn markup differs in padding/radius, palette- coherent across default/midnight/catppuccin). All gates pass: oxlint, oxfmt, tsc, vite build, vitest (641), test:visual (48), bun install --frozen-lockfile. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> chore(dev): bun install before dev/desktop so pulled deps are present (#800) `bun desktop`/`bun dev` assumed node_modules was current, so after pulling a branch that adds a frontend dep (e.g. the shadcn migration's tw-animate-css / @radix-* packages) vite failed with "Can't resolve '<pkg>'" until the user manually ran bun install. CI never caught it (CI does a frozen install). predev/predesktop now run `bun install` first (a no-op ~25ms when up-to-date), so a fresh pull just works. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> feat(ui): back Input/Select/Textarea/Slider with shadcn (prop APIs preserved) (#798) P1 of the shadcn/ui primitive migration (docs/shadcn-migration.md): route the OmniVoice form/data primitives through the shadcn components in src/components/ui/* while keeping their exact exports and prop APIs, so no call site changes. - input.tsx: export `inputBaseClass` (the shell) with no behaviour change — ShadcnInput baseline stays byte-identical. - New shadcn components: textarea.tsx, select.tsx (+@radix-ui/react-select), slider.tsx, table.tsx. - src/ui/Input.jsx (Input/Textarea/Select/Field): Input/Textarea now render the shadcn components; a small `fieldSizeVariants` cva (named palette utilities, tailwind-merge-clean) restores the OmniVoice padding-based sm/md/lg scale + filled bg-bg-elev-2 over the shell. Select stays a NATIVE <select> wearing the same shell — DubSegmentTable/CompareModal/GeneralTab depend on onChange={(e) => …e.target.value}, which Radix's value-only Select would break; the Radix select.tsx is added for new call sites only. - src/ui/Slider.jsx: wraps the shadcn Slider, keeping the number-based onChange + label/value-bubble chrome; track/thumb sized via the data-slot selectors. - Table deliberately NOT rerouted: ui/Table.jsx is a flex-<div> chrome wrapper whose .ui-table*/.segment-table global classes are a SHARED CONTRACT used directly by ModelsTable/DubSegmentTable/EngineCompatibilityMatrix (virtualised lists needing the div/flex layout, not a semantic <table>). table.tsx is provided for new tabular data; Table.jsx + its globals are untouched. Its toolbar inherits the shadcn-backed Input/Button for free. Verification: only the 3 Input-* visual baselines moved (palette-coherent across default/midnight/catppuccin); Slider/Table stayed within tolerance. vitest 641 green, oxlint 0 errors, oxfmt --check clean, vite build green, root bun.lock regenerated and bun install --frozen-lockfile in sync (Docker). Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> feat(ui): shadcn/ui foundation + OmniVoice palette token bridge (Button/Input proof) (#797) Lay the foundation for migrating OmniVoice's UI to clean Tailwind v4 + shadcn/ui WITHOUT changing the look: shadcn primitives inherit the existing OmniVoice palette (Gruvbox-pink default + every [data-theme] variant) through a semantic token bridge. Foundation only — no existing component is replaced. What landed: - shadcn init for Tailwind v4 + Vite + React 19: frontend/components.json (new-york, rsc:false, tsx:true), src/lib/utils.ts (cn = clsx + tailwind-merge), and a @/* -> src/* alias in vite.config.js + tsconfig.json so future `npx shadcn add` resolves. - Token bridge in src/index.css: a single `@theme inline` block maps shadcn's semantic vocab (--color-background/-foreground/-card/-popover/-primary/ -secondary/-muted/-muted-foreground/-accent-foreground/-destructive/-input/ -ring + --radius) onto the existing OmniVoice --color-* tokens. Because those tokens are re-declared per theme in ui/themes.css, theme switching recolors shadcn components automatically — no per-theme shadcn block. Existing --color-accent/--color-border and the --radius-* scale are left intact. - Two proof components: src/components/ui/button.tsx + input.tsx (verbatim shadcn new-york), rendered across default/midnight/catppuccin in the visual harness with committed baselines (brand-pink / purple / lavender confirmed). - New deps: class-variance-authority, clsx, tailwind-merge, tw-animate-css, @radix-ui/react-slot. Root bun.lock regenerated; `bun install --frozen-lockfile` verified in sync (Docker-green). - Migration plan at docs/shadcn-migration.md (bridge table, primitive->shadcn mapping, prop-compat wrapper strategy, staged waves, honest risk/effort). Verified: vite build, typecheck:ci, oxlint (0 errors), oxfmt --check, vitest (641 pass), test:visual (48 pass incl. 6 new baselines), frozen lockfile in sync. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> refactor(css): consolidate ~15 residual stub stylesheets into src/styles/residual.css (#795) After the Tailwind v4 migration, ~15 component .css files were reduced to tiny stubs holding only the few irreducible rules that can't be layered utilities (@keyframes animations, focus-visible rings, a glass surface, a <select> caret, ::before/::after pseudos, attribute-selector overrides). Each still lived as its own file + its own per-component import. They are all plain GLOBAL class selectors, so the file boundary bought nothing. This collapses them into one shared, intentionally-UNLAYERED stylesheet (src/styles/residual.css), loaded once at app root (main-app.jsx, right after index.css to preserve cascade order) and once in the visual harness (harness.jsx, which previously got these rules transitively via the component imports). Rules are moved verbatim — byte-identical selectors/keyframes/values — with a "from <Component>" provenance header above each block. No @layer wrapping, so they keep beating Tailwind's @layer utilities exactly as before. Zero visual change: all 42 visual-regression snapshots pass unchanged. Net -14 .css files (68 -> 54): 15 stubs removed, 1 consolidated file added. Deleted stub stylesheets (import removed from each component .jsx): - ui/Badge.css (.ui-badge--pulse dot animation) - ui/Input.css (.ui-select native caret) - ui/Panel.css (.ui-panel--glass backdrop surface + ::before) - ui/Progress.css (shimmer ::after + indeterminate keyframes) - ui/Segmented.css (.ui-seg__opt:focus-visible ring) - components/AudioTrimmer.css (.audio-trimmer layout) - components/DemoPresetGrid.css ([aria-pressed] active preview) - components/MultiLangPicker.css (.multi-lang__drop + mlp-in keyframes) - components/ReadinessChecklist.css (glass panel + rc-spin keyframes) - components/TranscriptionPicker.css (row hover/focus-visible combinators) - components/UpdatesPanel.css (updates panel chrome) - components/VoiceSelector.css (combinators + spin keyframes) - components/settings/ApiKeysPanel.css(.apikeys-row/badge test contract) - pages/ToolsPage.css (h1 + code/pre typography overrides) - components/BootstrapSplash.css (comment-only, no rules; import dropped) Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> feat(ui): convert more DubTab CSS to Tailwind (wave 2, live-screenshot-verified) (#794) Second-wave CSS->Tailwind conversion of DubTab, building on wave 1 (#788). Removes 119 more lines from DubTab.css (919 -> 800) by moving the stateless/standalone idle-skeleton rules into utilities in IdleSkeleton.jsx. Every conversion was proven pixel-identical against the LIVE app (real Dub screen on a dev server, not the isolated component harness). A throwaway Playwright spec captured baselines of three reachable Dub states, the rules were converted, and the same states were re-shot and pixel-diffed with maxDiffPixels:0 (exact). States verified: - idle drop-zone (drop-zone leaves, URL ingest row, landing options) - idle + Advanced expanded (landing-adv field row) - file-loaded skeleton via setInputFiles, no backend upload (skel settings, skel table cells/headers/hint, cast strip, stepper) Converted (base/standalone rules -> utilities): dub-idle-drop__lines/__title/ __sub, dub-ingest-row + __input, dub-idle-upload-label, dub-hidden-file, dub-landing-opts + __label, dub-landing-opts__lang base, dub-landing-adv + __field base, dub-cast base + __row + __kicker/__label base + --muted__chip, dub-skel-settings, dub-skel-field/--sm, dub-skel-translate-btn, dub-skel-transcript-toggle, dub-inline-icon, dub-skel-cell-*/header-* cells, dub-skel-hint, dub-skel-gen-row. Deliberately LEFT as CSS (would regress, per the diff oracle / wave-1 doctrine): anything with @keyframes/animation (dub-skel-bar shimmer, dub-idle-drop pulse), :hover/state interplay (dub-ingest-row__cta.is-ready, dub-landing-opts__adv, dub-cast__pair), and cross-file unlayered overrides that a layered utility would lose to (dub-skel-table on .segment-table, dub-skel-row on .segment-row, dub-skel-gen-btn / dub-change-row__cta on .btn-primary, dub-skel-transcript-toggle__inner on .override-toggle, dub-skel-cell-acts__icon on .segment-del, dub-speakers-input on .input-base, dub-ghost-footer on .studio-panel). Class hooks were kept on elements whose .dub-cast--muted / --grow / select descendant rules still need them. Gates: oxlint (0), oxfmt --check (clean), vite build, vitest (641 pass), bun install --frozen-lockfile (no change), bun run test:visual (42 pass). Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> test(visual): extend harness to render panels/pages with mocked store/query/i18n (#793) The visual-regression harness could only snapshot pure leaf components. Pages and settings panels couldn't render because they depend on the Zustand store, react-i18next, react-query, and direct api/* fetches — so the CSS→Tailwind migration had no pixel safety net for them. Add an OPT-IN provider wrapper (providers.jsx): a spec declaring a `providers` block gets a seeded Zustand store, forced-English i18n, a snapshot-tuned QueryClient pre-filled via setQueryData, and an optional window.fetch stub for components that call api/* directly. Nothing runs for pure leaf specs, so existing leaf baselines are byte-for-byte unaffected (verified: 0 leaf PNGs changed on regenerate). Prove it on three CSS-heavy settings panels, each x3 themes: - AppearancePanel — store + i18n only - GeneralTab — store + i18n + seeded useSystemInfo query - StoragePanel — fetch-stubbed GET on mount ModelStoreTab is documented as not-harness-able yet (live EventSource SSE + virtualized react-table + required props). No new deps. Suite stays local-only (bun run test:visual), not a CI gate. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> feat(ui): convert Settings + misc page CSS to Tailwind utilities (partial) (#791) Mechanical, conservative CSS→Tailwind v4 migration of the safe layout/spacing/ typography 80% across the Settings page and several smaller pages. No intended visual change. Kept in CSS (per the migration plan's "hard 20%"): @keyframes, ::before/::after, :has()/child/sibling combinators, glass/backdrop-filter, !important, media/container queries, state-modifier specificity interplay, and any rule that fights an unlayered global element rule (h1..h4 font/letter-spacing, code/pre, a) which would beat @layer utilities. Conventions followed: BEM class names retained alongside utilities so external selectors and removal stay safe; only @theme tokens use named utilities (text-fg, bg-bg-elev-2, rounded-lg, font-mono); --chrome-*/--space-*/--text-*/ --frs-* and exact pixels use arbitrary var()/px values; no-preflight borders via [border:1px_solid_...]; transitions via arbitrary [transition:...]. Files (rules removed → utilities; rules kept = the hard 20%): - ToolsPage: page/card layout → utils; kept h1, code/pre descendants. - BatchQueue: page/cards/progress/meta/outputs → utils; kept h1, card status modifiers, progress-fill shimmer pseudo + keyframes. - Transcriptions: header/list/detail/segments → utils; kept search input (+placeholder), list scrollbar, item hover/active interplay, h4 seg-title. - Projects: page/header/toolbar/search/rail/body/content/empty → utils; kept title h1, search input, view-toggle + rail-item + card clusters, list-view descendants, content view modifiers. - AudiobookTab: page/head/body/script/side/field/duo → utils; kept title h2, scoped .field-label, textarea/select descendants, @media collapse. - Donate/Support/Enterprise (shared across SupportPage + ContactPage): page, content, hero subtitle, footer, social-proof, amounts, topbar, spacer, methods, chips, contact value, ent kicker/subtitle/why-grid/label/desc → utils; kept all animation/pseudo/state/color-mix/custom-prop chrome. - SetupWizard: standalone swiz-slide/note/checks/loading + frs-embed → utils; left frs-coupled lib/hfbar rows in CSS (first-run, extends frs primitives). - Settings: settings-muted/prose(base)/log-meta/log__empty/link-row/actions-row + models-row__progressline → utils in the settings/* tab components; kept the page grid/container-query shell, tab-rail rules, tables, rows, reco-banner. Verified: vite build clean, oxlint exit 0 (only pre-existing warnings), oxfmt --check clean, vitest 641/641 pass, bun install --frozen-lockfile no change. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> feat(ui): convert VoiceGallery + CloneDesign page CSS to Tailwind utilities (partial) (#792) Mechanically convert the safe, low-risk page CSS of the Voice Gallery and Clone/Design pages to Tailwind v4 utilities, removing each converted rule from the page CSS so there is a single source of truth. Scope (conservative, partial — complex rules left as CSS): - VoiceGallery.css: pure flex/grid containers + text/ellipsis spans converted (voice-gallery, gallery-header, header-top, gallery-sub, gallery-search, search-row base, search-results-panel, panel-header, results-list, result-*, content-header, content-title base, count-badge, voice-list base, voice-info/ name/meta/actions base, arch-head/title, archetype-name/sub/chips, arch-foot, archetype-section, load-more, import-explainer, community-explainer, submit-actions). - CloneDesignTab.css: studio-def-col, clone-script-wrap, clone-insert-backdrop, clone-prod-col/check, clone-hear-demo-chip, clone-drop-row, clone-drop-filename, grid-2--indent, describe-voice-block margin / hint / feedback, starting-points (+__label), clone-sliders-col, clone-slider-kicker, identity-line__kicker/recipe, design-seed(+__row/__keep), clone-coachmark(+__icon/__msg), clone-profile-banner (+__label), clone-save-profile(+__row base). Rules followed: - No reliance on Tailwind preflight: borders use arbitrary [border:...]; only @theme tokens map to named utilities (bg-bg-elev-2, text-fg, text-success, rounded-lg/md), everything else (--chrome-*/--space-*/--text-* + literal px) stays exact via arbitrary var()/px. - Left in CSS: keyframes, ::before/::after, :has/combinators, masks, scrollbar pseudo, !important, media queries, hover/border-heavy buttons & chips, and any rule overriding an unlayered base (.file-drag/.input-base/.studio-panel) that a @layer utility can't beat. - Retained class names that anchor kept descendant selectors (search-row, clone-save-profile__row). Verified: oxlint 0, oxfmt --check clean, vite build OK, 641 vitest pass, bun install --frozen-lockfile unchanged. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> feat(ui): convert misc component CSS (Sidebar/EngineMatrix/donate/…) to Tailwind utilities (#790) Move the mechanical, self-contained layout/spacing/typography/simple-color CSS of six leaf/misc components to Tailwind v4 utilities in their JSX, deleting the now-redundant rules from each component .css. No intended visual change. Conversion rules followed (matching the prior ui/ migration PRs): - No preflight reliance: borders use `[border:1px_solid_…]`, button resets are replicated (border/background/padding) rather than assuming a base. - Only @theme tokens become named utilities (font-sans/serif/mono, rounded-lg, text-fg…); --chrome-*/--space-*/--text-*/shadows use arbitrary `var()`/exact px. - Component .css is unlayered and outranks @layer utilities, so a class is only converted when its rule is removed; classes still governed by an unlayered global rule (.input-base) or a remaining state rule keep their CSS. - Kept in CSS: @keyframes, ::before/::after, :has()/child/sibling combinators, :hover/:focus-visible/.is-active states, gradients/box-shadow/glass, animation, !important, and @media. Class names are retained on the elements so those rules (and the test selectors) keep matching. - Shared/other-owned classes left alone: Sidebar's history-*/save-btn (rendered by Workspace*), EngineMatrix's chip block (tested `.is-effective`, color-mix variants) and __table (Table primitive), all Pip animation classes. Files (rules removed vs kept): - Sidebar: tabs/badge/search/empty/section-title/icon-tile/subtitle/scroll/tile bases → utilities; kept .sidebar__tab (interactive), search-input (.input-base override), search-clear (!important), save-btn (shared), is-collapsed combinators, hovers. 286→182. - EngineCompatibilityMatrix: matrix/head/title/body/row/cells/name/id/reason/ hint/last-error/why/why-body/chips/result/tabs/empty → utilities; kept table, why-summary pseudo triangle, chip color system + tested .is-effective. 289→104. - donate/Postcard: close/body/title/lead/goal-link/actions/cta/later/minor/star/ optout bases → utilities; kept the animated card, ::before perforation, grain, stamp, hovers, keyframes, reduced-motion @media. 229→134. - donate/DonateGoal (GoalBar): goal root/head/title/pct/track/caption/remaining/ caption-met → utilities; kept fill/shimmer/pip animations, --met/--mini overrides, amounts-strong combinator, all Pip classes. 179→128. - VoiceSelector: container/adornments/btn base → utilities; kept > .ss-wrap combinator, btn hover/disabled, spin animation. 45→23. - TranscriptionPicker: search/list/row/text/meta/empty base → utilities; kept search>* and meta-span combinators, row hover/focus-visible. 29→10. Verified: oxlint (0 errors), oxfmt --check clean, vite build, vitest (641 passed), bun install --frozen-lockfile (no change). Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> feat(ui): convert StoriesEditor + LogsFooter CSS to Tailwind utilities (partial) (#789) Migrate the safe, mechanical layout/spacing/typography CSS of two components to Tailwind v4 utilities, leaving the hard-to-express rules in their .css files. Conservative + partial by design (per the migration plan §8): no preflight is assumed, so borders/transitions/chrome tokens stay as arbitrary properties referencing the exact original vars (`[border:1px_solid_var(--color-border)]`, `[color:var(--chrome-fg-muted)]`), @theme tokens use named utilities (text-fg, bg-bg-elev-2, rounded-sm/md, text-accent/brand, bg-border), and every non-@theme value (--chrome-*, --space-*, --text-*) is exact px or `var()`. StoriesEditor.css 525 -> 349 (-176): converted the editor shell, header, subtitle, toolbar groups/divider, stats/footer, empty state, cast/split panels, the panel title, voice/cast dot, and the tone/drawer containers. KEPT: the h2 title (global `h1..h4` element rule is unlayered and would beat a `font-serif` utility), the `.stories-track` grid + its hover/active/drag combinators, all native controls (textarea/select/range + their focus states), every button (UA reset + hover/disabled/`--on`/`--delete` states), the chapter bar (hover combinators), the `::-webkit-scrollbar` pseudos, and the `[data-char]` color palette attribute selectors. LogsFooter.css 507 -> 376 (-131): converted the resize handle, top bar, left/right clusters, the LOGS title, the count-badge base, the log-line base + icon + line-text base, and the notification panel (body/item/icon/content/msg/ action). KEPT: the `.logs-footer` fixed shell (anchor for the `.app-container .logs-footer` inset combinators + the <=600px media query), every button (toggle/pill/version/discord/donate/icon-btn with hover/disabled/ animations), the severity color modifiers + their descendant overrides (`.logs-footer__line--error .logs-footer__line-text`, badge/item variants, clickable hover), the body scrollbar pseudos, the `notif-content strong` rule, and all @keyframes + the reduced-motion block. Classes that remain referenced by kept CSS (combinators/pseudos/attrs) keep their BEM class in the JSX alongside the new utilities; fully-removed rules drop the class entirely. No class used elsewhere in the tree was removed (grep-checked across frontend/src). Verified: npx oxlint (0 errors), oxfmt --write src + --check . (clean), vite build (ok), vitest run (641 passed), bun install --frozen-lockfile (no changes). Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> feat(ui): convert DubTab page layout CSS to Tailwind utilities (partial; complex/stateful CSS kept) (#788) Mechanically convert the low-risk layout/spacing/typography/simple-color rules on the Dub section components to Tailwind v4 utilities, removing each converted rule from DubTab.css so the unlayered page CSS can't shadow the utilities. Converted (rule removed from CSS + utilities applied in JSX): - DubHeader/IdleSkeleton: .dub-head strip, __filename/__meta/__project/__actions/__primary - PrepOverlay: .dub-prep-overlay base, .dub-prep-chips base, __title/__note/__detail - TranscribeOverlay: .dub-trans-overlay base, __head/__title/__bar - DubFailureNotice: .dub-failure-notice + __hint/__actions - DubFooter/IdleSkeleton: .dub-footer-banner, __badge-gap - DubRightColumn: .dub-bulk-row__label-brand, .dub-lazy-fallback - DubTab/IdleSkeleton: .dub-col, .dub-split-1, .dub-split-2 - IdleSkeleton: .dub-change-row, .dub-speakers-hint Kept in CSS (left as-is, by the project's gotchas): - .dub-head__title (coexists with the unlayered global .label-row it overrides) - .dub-panel-col / .dub-ghost-footer (sit on .studio-panel, override its overflow/padding) - .dub-change-row__cta (sits on .btn-primary, overrides its margin-top) - .dub-trans-overlay__stats (targeted by the global tabular-nums rule) - .dub-prep-bar/__fill, .dub-prep-chip, --large/--lg modifiers (combinators/state/animation) - .dub-hidden-file (used outside the converted files) - all keyframes/animations, ::before, :has/combinators, !important, media queries, chrome-token surfaces, the stepper, skeleton bars, footer-btn family, etc. Tokens preserved exactly: spacing/text/chrome → arbitrary var()/px; @theme colors + radius + weight → named utilities. Borders use the [border:...] arbitrary form since the app ships Tailwind v4 without preflight. DubTab.css: 989 → 919 lines (92 CSS lines removed, 22 explanatory notes added). Verified: oxlint 0, oxfmt clean, vite build, vitest 641 pass, bun --frozen-lockfile no-op. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> feat(ui): convert FirstRunSetup layout CSS to Tailwind utilities (partial; animations/states kept) (#787) Converts only the clearly-mechanical, low-risk layout rules of the shared "studio console" sheet (FirstRunSetup.css) to Tailwind v4 utilities in the JSX consumers (FirstRunSetup, BootstrapSplash, SetupWizard). Most of the 1020-line sheet stays in CSS by design. Converted (15 static layout-only rules, full property sets): - containers: .frs__deck, .frs__col, .frs-panel, .frs__grid - masthead: .frs__mast, .frs__mast-row, .frs__mast-meta, .frs__mast-selects, .frs-wsteps - misc layout: .frs-opt__head, .frs__hw, .frs-row__gauge, .frs__foot-row, .frs-log__bar, .frs-banner__actions Approach honoring the no-preflight setup (only theme.css + utilities.css are imported): exact rem/px preserved via arbitrary values (gap-[1.1rem], grid-cols-[minmax(0,7fr)_minmax(0,5fr)], etc.); each base rule is removed from CSS (component CSS is unlayered and would otherwise beat @layer utilities) and replaced with a one-line breadcrumb. Every remaining override stays in CSS and still wins because it is unlayered: responsive media queries (.frs__grid/.frs__mast-row/.frs__foot-row/ .frs-row__gauge), modifier classes (.frs__deck--focus, .frs-banner__actions--end, .frs-wsteps--journey), and descendant rules (.frs-row__gauge .frs-meter). Kept in CSS (unchanged): all @keyframes/animations (rise, breathe, alarm, hw-pulse, meter), ::before/::after, glass/masks, color-mix backgrounds, hover/focus/state (.is-active/.is-armed/etc.), typography, and media queries. Cross-file/combinator-bound classes (.frs-wnav, .frs-embed, .frs-row*, .frs-check*) left as CSS. Verification: oxlint 0, oxfmt --check clean, vite build OK, vitest 641 passing, bun install --frozen-lockfile no change. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> feat(ui): convert modal/dialog CSS to Tailwind utilities (#783) Move the mechanical layout/typography rules of five modal/dialog/panel components from their .css files onto JSX utilities (Tailwind v4). Exact pixels preserved via arbitrary utilities referencing the same tokens/px; no preflight, so UA resets (bg/border/padding) are replicated explicitly. Overlays, positioning, open/close animations, state-class (.is-*) and descendant selectors, gradients-as-state, media queries, and any cross-file class are intentionally left in CSS. - ExportModal: converted drawer head/handle/close, body, presets, preset-chip, kicker, tracks, section-head, track-row, track-label, tabs container, grid, field/field-head/-label/-hint, note, mt6, pkg-grid/-card(+ghost)/-head/-body, summary(+left/-name/-right), license-notice/-link. Kept: overlay, sheet+keyframes, track-quick (button descendant), track (input descendant + .is-on/.is-dub.is-on), tab (.is-active + hover), toggle (input descendant + --indent). - CompareModal: converted drawer head/handle/title/close, body, foot, desc/head/audio/audio-empty. Kept: overlay, sheet+keyframes, and .ui-compare__grid (its responsive collapse is driven by a media query here AND in index.css — cross-file, STOP rule). - BatchAddDialog: converted head/title/close, body, drop-hint, file-input, files/kicker/file-row/-name/-size/-x, settings, field, foot/estimate. Kept: overlay, card+keyframes, drop (.is-over state), select (overrides global .input-base), toggle (input descendant). - SupertonicLicenseDialog: converted title, intro, sections, link, footer, actions. Kept: overlay, card + section (color-mix + unclassed h3/p/code descendants), buttons (color-mix :not(:disabled) states). - NotificationPanel: converted the bell trigger + count badge (made the color/bg conditional in JSX to avoid Tailwind utility-ordering ties). Unused .notif-panel/.notif-item/.notif-hf-input blocks left as-is (pre-existing dead code; removal is out of scope for this refactor). Verified: oxlint exit 0 (only pre-existing warnings), oxfmt --check clean, vite build OK, 641 vitest tests pass, bun install --frozen-lockfile unchanged. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> feat(ui): convert dub/demo component CSS to Tailwind utilities (#784) Move mechanical layout/typography from five component CSS files onto JSX as Tailwind v4 utilities. Conservative: rules that are shared across files, use !important/color-mix/compound or descendant selectors, focus rings, font:inherit, @media, or that would lose to unlayered index.css rules in the cascade are kept in CSS. Verified visual equivalence, oxlint (0), oxfmt, vite build, and 641 vitest tests; bun.lock unchanged. DemoPresetGrid: fully converted grid/cards/buttons; CSS trimmed to only the .demo-preset-card__preview[aria-pressed="true"] state (attribute selector kept unlayered so it wins over the button's hover utilities). DubbingDemo: converted head/title/dismiss/pane/caption/picker/chip/cta; kept the shared container base (reused by the loading state), the max-width:720px media query, and the input/pane-label-span/pane-video descendant + chip.is-active compound rules. DictationDemo: converted head/title/lede/card/lang/script/actions/result-base; kept .dictation-demo and .dictation-demo__scripts (queried by DictationDemo.test.jsx), plus status/result variants with their descendants. DubSegmentRow: converted the local cell badges/labels/time-spans/restore-button/ checkbox; kept the shared .segment-* row/state classes (used by DubSegmentTable.css, index.css, IdleSkeleton.jsx), the text inputs (font:inherit + focus), and the select/range/actions cells whose unlayered input-base / input[type=range] siblings would otherwise beat utilities. SegmentTrack: converted container/onsets/viewport-base/lane/label/handle-base/ actions/action-btn/playhead; kept the box and its JS-toggled state variants, handle edges with hover/selected compounds, the self-scroll viewport modifier, the disabled compound, and the visually-hidden announce region. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> feat(ui): convert settings panel CSS to Tailwind utilities (#782) Converts the clearly-mechanical CSS (layout/sizing/typography/simple color+border+radius, simple hover/focus/disabled) in the settings panels to Tailwind v4 utility classes on the JSX, mapping to @theme token utilities + arbitrary var()/px values for exact-pixel parity. The app ships without preflight, so borders use the `[border:1px_solid_…]` arbitrary-property form (matching ui/Badge.jsx) to keep border-style. No behavior change; verified by strict 1:1 mapping + build + full tests. StoragePanel: fully converted → StoragePanel.css DELETED (import removed). field/input/buttons/restart/error all utilities; placeholder + focus-ring via placeholder:/focus-visible: variants. SharingPanel: fully converted → SharingPanel.css DELETED (import removed). section/row/addr/btn(+ghost)/iconbtn/tailscale-*/qr/note/envname/portinput. AppearancePanel: converted scale slider+readout, theme/font containers, and the range input (accent-color). KEPT in CSS: `.appearance-panel__row--fonts .st-row__control` (reaches into the SettingRow primitive), and the theme-dot + font-tile rules (stateful transitions, multi-layer box-shadow rings, is-active state) — not 1:1 utility-safe. ApiKeysPanel: converted error/rows/head/name/meta/set/unset/whoami/masked/ actions/input/buttons/clear-dialog/checkbox. KEPT in CSS: `.apikeys-row`, `.apikeys-row--active`, `.apikeys-badge`, `.apikeys-badge--active` — ApiKeysPanel.test.jsx selects these by class name (cross-file contract; STOP rule). VoicePanel: converted the warn banner + most of the speech-model dropdown (dropdown/trigger/name/list/item/itembtn/check/body/itemtop/itemname/ size/itemdesc/progresstext/action/iconbtn). KEPT in CSS: `.voicepanel__row--model .st-row__control` (primitive descendant), `.voicepanel__dd-chev`/`.is-open` (transform transition — Tailwind `rotate-*` targets the `rotate` property, not `transform`, so it wouldn't animate), `.voicepanel__dd-progress` + `> :first-child` (child combinator), and `.voicepanel__spin` + `@keyframes` (animation). PerformancePanel: UNTOUCHED. PerformancePanel.css is a de-facto shared stylesheet — `.perfpanel`, `.perfpanel__error`, `.perfpanel__row`, `.perfpanel__badge`, `.perfpanel__help` are used by 6 other panels (MCPBindings, LLMEndpoint, Refinement, RemoteBackend, HFMirror, Pronunciation), so the STOP rule leaves the whole file as-is. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> feat(ui): convert standalone widget CSS to Tailwind utilities (#781) Move the clearly-mechanical CSS (flex/grid, spacing, sizing, typography, simple colors/borders/radii, and simple hover/disabled states) for seven standalone widgets onto their JSX as Tailwind v4 utilities. Animation (@keyframes), glass/backdrop-filter, pseudo-element/compound/sibling selectors, !important, media queries, and any class referenced from another file are left in CSS verbatim. Exact pixels/colors are preserved via @theme token utilities plus arbitrary var()/px values; transitions use arbitrary-property syntax so the timing function stays identical (Tailwind's transition utilities inject a different default ease). No preflight is loaded, so every converted border pairs an explicit border-solid/border-dashed + color. - NetworkToggle: fully converted; NetworkToggle.css deleted and its import removed (all classes were local). - FloatingPill: converted the static content/label/meta/timer/error/progress track + dismiss button; kept the pill base (animation+glass+fixed pos), dot, progress-fill (base + indeterminate !important/animation), keyframes, the prefers-reduced-motion block, and the --done/--error descendant overrides. - AudioTrimmer: converted all audio-trimmer__* parts + trim-field*; kept the .audio-trimmer base rule (also targeted by unlayered overrides in index.css). - ReadinessChecklist: converted title/list/item/status-layout/label/detail/ fix/all-pass; kept the glass base, the rc-spin keyframe, and the dynamic status--pass/warn/fail/loading color+animation modifiers. - MultiLangPicker: converted chips/add/summary/search/list/section/option; kept the .multi-lang__drop dropdown (animation + shadow) and mlp-in keyframe. - WorkspaceVoices: converted only the local wv__active*/wv__empty-cta active- voice card; kept wv/wv__head/wv__title/wv__search*/wv__scroll/wv__empty/ wv--collapsed/wv__rename-input (shared with WorkspaceProjects.jsx). - WorkspaceHistory: converted the local wh/wh__* panel chrome (active chip state expressed as a mutually-exclusive ternary since utilities are equal specificity); kept the studio-with-history/studio-right/shell-narrow/ shell-mini layout rules (referenced by App.jsx, index.css, and tests). Verified: oxlint exit 0, oxfmt --check clean, vite build OK, 641 vitest pass, bun install --frozen-lockfile clean. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> feat(ui): convert Button/Panel/Input/Menu to Tailwind utilities (visual-verified) (#780) Migrate three UI leaf primitives from component .css to Tailwind v4 utilities, each verified pixel-identical against the visual-regression harness across all three baselined themes (default / midnight / catppuccin). Because the app ships Tailwind v4 WITHOUT Preflight and themes override the design tokens, colors/shadows/borders/transitions are expressed as arbitrary *properties* (`[prop:value]`) referencing the exact original CSS variables (avoiding `--tw-*` composition and color/length type ambiguity), while @theme-mapped tokens use named utilities (text-fg, bg-bg-elev-2, rounded-lg, text-danger…) which resolve to the same `var(--…)` and track themes. The harness renders resting state, so hover/focus/active are converted faithfully but not pixel-gated. - Button: component is now fully utility-driven and no longer emits `.ui-btn*` classes. Button.css is RETAINED unchanged because AudiobookTab.jsx consumes `.ui-btn--{subtle,primary,icon}` as raw classNames (out of scope to refactor); keeping the component class-free avoids double-application. - Panel: layout/border/radius/padding/header/title/actions + solid & flat variants → utilities. Panel.css trimmed to the glass variant only (backdrop-filter + layered gradient surface + ::before highlight, which utilities can't express). The header+body top-padding sibling rule is reproduced via a conditional `pt-` when a header is present. - Input: shared input/textarea/select shell, sizes, states, and the Field wrapper → utilities. The `:has(.ui-field__icon)` padding rule is reproduced by cloning the control with `pl-` when an icon is present. Input.css trimmed to the native <select> caret (SVG data-URI background) only. Added Input to the visual harness with a representative spread; baselines committed. - Menu: left as CSS. It is a Radix dropdown whose content renders through a Portal to document.body (outside the harness snapshot root #visual-root) and only renders when open + collision-positioned, so it cannot be captured in isolation here; its surface is also dominated by keep-as-CSS features (backdrop-filter glass, gradient, @keyframes pop-in, box-shadow token). Verified: bun run test:visual (18 passed), npx oxlint (0 errors), oxfmt --check (clean), vite build (ok), vitest (641 passed), bun install --frozen-lockfile (no changes). Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> feat(ui): convert Dialog/Slider/Table/Tabs to Tailwind utilities (visual-verified) (#779) Continue the component CSS -> Tailwind v4 utility migration for UI group 3. Added Slider, Table, and Tabs to the visual-regression harness (specs.jsx + manifest.ts) and committed machine-local baselines, then converted each component, proving the result pixel-identical with `bun run test:visual`. - Slider: fully converted; Slider.css deleted (no keyframes / complex selectors). Token + arbitrary-value utilities preserve exact pixels; thumb hover/active/focus-visible and the multi-easing transition are kept faithful via arbitrary-property utilities. Visual-verified across all 3 themes. - Tabs: fully converted; Tabs.css deleted. pill/underline variants, size, active and hover:not(active) states mapped to conditional utility sets. The `ui-tabs* / is-active / ui-tabs__icon` class names are retained as inert hooks so Settings.css's unlayered overrides (`.ui-tabs.settings-tabs-ui …`) keep winning over the layered utilities — Settings tab rail unchanged. Visual-verified across all 3 themes. - Dialog: partial conversion. Header / title / body / footer box-model + typography and per-size max-width converted to utilities; the glass gradient surface, backdrop-filter, fixed centering, and open/close @keyframes remain in Dialog.css (cannot be reduced to utilities). NOT visually verified: Radix Portal + position:fixed render the dialog outside the harness's #visual-root, so it can't be snapshotted in isolation; verified instead by 1:1 token equivalence + build + unit tests. - Table: LEFT AS CSS (STOP rule). Its classes are a shared CSS contract, not a private leaf — ModelsTable.jsx renders `ui-table-header`/`ui-table-header__cell` directly without the component, and DubSegmentTable.css, EngineCompatibilityMatrix.css, Settings.css, and index.css all hook those global classes. Removing Table.css would break them, so converting yields no safe net benefit. Added to the harness with a baseline for a future pass. Verified: oxlint (0 errors), oxfmt --check (clean), vite build, vitest (641 passed), bun install --frozen-lockfile (no changes), test:visual (24 passed). Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> feat(ui): convert Badge/Segmented/Progress to Tailwind utilities (visual-verified) (#778) Migrate three UI leaf primitives from component .css to Tailwind v4 utility classes, mapping colors/radii/fonts to the @theme token utilities and using arbitrary values (px / var() / color-mix / gradients) to preserve exact pixels. Each conversion is proven pixel-identical to its pre-conversion baseline by the Playwright visual-regression harness across all three themes. - Badge: base, sizes, tones, and dot moved to utilities. Kept the `.ui-badge--pulse .ui-badge__dot` rule in CSS — it is driven by an externally-applied parent class (Header status badge) + global `pulse` keyframes, which a utility on the component can't express. - Segmented: container, options, sizes, hover (Radix data-state=off) and active (data-state=on) moved to utilities. Kept `.ui-seg__opt:focus-visible` in CSS: the global `:focus-visible` rule is unlayered and would otherwise win over a layered utility, so the component override must stay unlayered too. - Progress: track, sizes, fill, and per-tone gradient fills moved to utilities. Kept the shimmer `::after` + indeterminate descendant rule + both `@keyframes` in CSS (pseudo-elements / keyframes are not expressible as utilities). - Tooltip: left as CSS. Its content renders through a Radix Portal into document.body, outside `#visual-root` (the only element the harness snapshots), so a conversion can't be visually verified — left untouched per the rule to not force an unverifiable change. Added Segmented + Progress to the visual harness (specs.jsx + manifest.ts) with representative variants/states and committed their baselines. Badge was already in the suite; its baseline is unchanged (byte-identical). Verified: oxlint (0 errors), oxfmt --check (clean), vite build, vitest (641 passed), bun install --frozen-lockfile (no change), test:visual (21 green). Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> test(visual): add Playwright component visual-regression baseline for CSS migration (#776) Gating prerequisite for the CSS -> Tailwind v4 migration: a pixel-for-pixel safety net so each utility conversion can be verified against a known-good baseline. There were previously no visual tests. Approach: a lightweight Vite-served harness (NOT @playwright/experimental-ct-react) that renders one presentational leaf component in isolation, with no Python backend. Chosen because it adds zero new deps (root bun.lock untouched -> no Docker frozen-lockfile risk), reuses the existing @playwright/test + bundled chromium, and renders through the project's real Vite 8 + Tailwind v4 + token pipeline so snapshots reflect the actual build output. CT's experimental React runner on Vite 8 + React 19 was an avoidable compatibility risk. - harness.html / harness.jsx: isolated render target driven by ?component=&theme= URL params; applies themes via [data-theme] (default = bare :root Gruvbox), loads the same fonts + token layers as the app, signals font-ready for stable shots. - specs.jsx: registry of pure variant spreads for Badge, Button, Panel, SettingRow, SettingsToggle. - manifest.ts: COMPONENTS x THEMES (default, midnight, catppuccin) the spec iterates -> 15 committed baselines in __screenshots__/. - playwright.visual.config.ts: dedicated config (separate from e2e), own Vite server on port 3902, animations disabled, caret hidden. - scripts: test:visual / test:visual:update. - README: how to add a component, how to update baselines after an intentional change, and why this stays local/manual (font/anti-alias differences across OSes) rather than a blocking CI gate for now. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> fix(css): make @theme the single source for design tokens (dedup drift) + parity test (#777) The Tailwind v4 `@theme` block in src/index.css and the unlayered `:root` in src/ui/tokens.css both declared the same `--color-*`, `--radius-*`, and `--font-*` tokens. Because `@theme` lands in `@layer theme` (low priority) while tokens.css's `:root` is unlayered, the tokens.css copy silently won — the `@theme` literals were dead, losing duplicates. The two copies had already drifted: the font stacks in `@theme` were the short variants while tokens.css carried the full stacks (with 'Söhne', 'Cascadia Code', etc.), so the resolved font-family came from tokens.css. Make `@theme` the single home for the overlapping color/radius/font tokens and delete the duplicates from tokens.css. To keep every resolved value byte-identical (this is a pure de-dup, not a restyle), `@theme` adopts the full font stacks that were actually winning at runtime. Tokens unique to tokens.css (--color-muted-mono, --radius-pill, --font-display, --font-ui, spacing, shadows, motion, z-index, etc.) are left untouched. Theme switching is preserved: themes.css's `[data-theme=...]` overrides are unlayered, so they still beat the now-@theme-sourced base (unlayered always wins over @layer theme, regardless of source order). Verification: a before/after `vite build` shows all 31 effective `--color/--radius/--font` values identical; full vitest suite (641 tests) green. Adds src/test/tokenParity.test.js, which fails if any color/radius/font token is ever re-declared in both @theme and tokens.css (catching the drift before it can recur). Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> docs(contributing): switch the frontend CSS guidance to utilities-first (#775) The "Vanilla CSS … no Tailwind" rule contradicted the (already-wired) Tailwind v4 setup and the CSS→Tailwind migration plan (#772). Replace it with the utilities-first standard: Tailwind utilities (bridged to the design tokens via index.css @theme) for layout/spacing/typography; keep .css files only for the hard parts (glass, keyframes, pseudo-elements, :has(), theme rules). Required by the docs-sync rule as P0 of the migration. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> chore(format): adopt oxfmt for JS/TS/JSX + CI format gate (#774) Adds oxfmt (Rust formatter, Prettier-conformant) — the repo had no formatter, so this is a one-time normalization of the JS/TS/JSX code (257 files; purely cosmetic — full suite stays 638/638). Scope is deliberately narrowed in .oxfmtrc.json to JS/TS/JSX only: - singleQuote:true + jsxSingleQuote:false — preserve the project's existing style (single-quoted JS, double-quoted JSX attrs), not oxfmt's double-quote default. (Flipping quotes globally also broke a source-string-parsing test; preserving them keeps featureCoverage green.) - Excludes **/*.css (the CSS→Tailwind migration will rewrite those — formatting them now is wasted churn), **/*.json (avoids reformatting 20 i18n locale files + config), **/*.toml, and src-tauri/** (Rust/Tauri config — out of scope for a frontend JS formatter; oxfmt was reformatting Cargo.toml/tauri.conf.json). Tooling: - `bun run format` (write) / `bun run format:check` (verify). - ci.yml: new "Frontend format check (oxfmt)" gate after the oxlint gate. Verified: format:check clean; oxlint 0 errors; vite build; full suite 638/638; bun install --frozen-lockfile in sync. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> chore(deps): add taze for manual dependency freshness checks (#773) Adds taze (root devDep) + `bun run deps:check` = `taze -r --maturity-period 7`: recurses the bun workspace (root + frontend), lists available updates, and is READ-ONLY (never writes package.json without -w). The 7-day maturity window skips just-published versions as a supply-chain precaution. Manual tool by design — no auto-update, no Renovate infra, nothing added to CI. Run `bun run deps:check` when you want a refresh overview; `taze major` for major bumps; add `-w` to apply. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> docs: add the CSS → Tailwind v4 migration plan (#772) Phased, bounded migration plan (not a big-bang): convert the mechanical ~80% (flex/grid/gap/padding/typography/simple color) to Tailwind v4 utilities, deliberately keep ~15-25% as CSS (glass/backdrop-filter, @keyframes, ::before/::after, :has(), !important). Realistic end state ~10-12k of 16.6k CSS lines removed across ~5-7 weeks of small PRs. Key gates the plan establishes before any conversion starts (P0): - A Playwright screenshot baseline (default + dark + light) — the className-diff trick used for the page refactors is useless here since class names change. - Fix the @theme ↔ tokens.css token drift (single source + a parity test). - Rewrite the CONTRIBUTING.md "no Tailwind" line (docs-sync rule). Companion to docs/maintenance-pages-modularization.md. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> chore: adopt knip + remove dead files, deps, exports, and types (#771) Add knip (dead-code/dep finder) for the bun workspace, then act on what it found. Complements the oxlint gate: oxlint flags per-file unused symbols; knip finds whole dead files/exports/deps across the project. Tooling: - frontend/knip.json + `bun run knip` script. Ignores the legitimate false positives: public/aec-worklet.js (loaded via a dynamic AudioWorklet URL), /@react-refresh (Vite dev inject), and tailwindcss + the Rust-side @tauri-apps/plugin-updater / plugin-window-state JS packages (used by the native plugin, not imported in JS). Removed (all verified — build + tests + tsc + oxlint green): - Dead files: CastingView.{jsx,css}, UpdateStatusChip.{jsx,css} (no refs; the latter only survived in a stale comment, now reworded), and ui/motion.js. - Unused deps: @radix-ui/react-popover, @radix-ui/react-select, @eslint/js, eslint-plugin-react-refresh (the last two orphaned when eslint.config.js was stripped for the oxlint adoption). - 44 dead exports + 56 dead exported types across api/*, store/*, ui/*, utils/*: deleted where used nowhere; dropped just the `export` keyword where still referenced in-file. Kept (justified): Slider primitive (keeps @radix-ui/react-slider meaningful), AppMode export (a test string-parses its source), tailwindcss (CSS @import + vite plugin). Verified: oxlint 0 errors; tsc clean; vite build; full suite 638/638; bun install --frozen-lockfile in sync (Docker rule). Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> refactor(tauri): use tauri-plugin-positioner for the dictation pill (replaces hand-rolled monitor math) (#770) The floating dictation pill (the "widget" window) was positioned bottom-center by three near-duplicate blocks in lib.rs that each read primary_monitor(), divided size by scale_factor, and called set_position(LogicalPosition...), with a win.center() fallback. Replace all three with the official tauri-plugin-positioner: window.move_window(Position::BottomCenter), preserving the center() fallback on error. - Add tauri-plugin-positioner = { version = "2", features = ["tray-icon"] } (tray-icon enabled because the app ships a system tray). - Register .plugin(tauri_plugin_positioner::init()) after single-instance. - Collapse the global-shortcut, tray "dictate", and pill-mode pre-position blocks to the plugin API. Behavior-preserving: same window, same trigger points, still bottom-center. Verified with cargo check (passes; the one warning is pre-existing in setup.rs). Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> refactor(tauri): remove dead pill-autostart code (#764) The enable/disable/is_pill_autostart commands (and pill_autostart_path) were defined in commands.rs and registered in lib.rs but NEVER invoked — no JS caller, no internal Rust call, and no Settings toggle. ~155 lines of unwired, hand-rolled cross-platform code (macOS plist / Windows registry / Linux .desktop) maintained for a feature that was never shipped. Investigated adopting tauri-plugin-autostart instead, but since nothing exposes the feature, replacing dead code with a plugin (+ a new toggle) would be building an unrequested feature. Removing the scaffolding is the honest cleanup; if the "launch dictation pill at login" feature is ever wanted, wire it then via tauri-plugin-autostart (init(LaunchAgent, Some(vec!["--pill"]))). Kept: dirs-next (still used by config.rs/setup.rs — comment updated) and the launch_as_widget config commands (those ARE used). cargo check passes. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> fix(api): route backend fetches through apiFetch so they carry LAN-share auth (#765) Under LAN-share / remote-backend (a PIN/API key is set), ~28 raw fetch() calls to the backend 401'd because they skipped the X-OmniVoice-Pin / Authorization headers that apiFetch injects. Route them through apiFetch — fixing the auth gap and adding the same transport-retry robustness (backend-restart windows become invisible) the rest of the app already has. Since apiFetch throws ApiError on !ok (and fires ov:pin-required on 401), the now-dead `if (!res.ok) {…}` blocks were removed; surrounding try/catch handles the ApiError. Streaming (.body.getReader), FormData, cache, and signal opts are all preserved (apiFetch passes opts through; apiUrl is idempotent for absolute URLs). Deliberately left as raw fetch (documented): the auth-exempt /health liveness probe (custom timeout/backoff), the RemoteBackendPanel pre-save connectivity test (uses a user-typed target+key), WaveformTimeline (branches on 404 + may be a blob: URL), VoiceGallery playUrl (also serves external community-CDN URLs), and bugReport's fetchJsonWithTimeout (hard 2.5s bound, no retry by design). Updated the #532 in-app-playback regression test to assert via apiFetch. Verified: oxlint 0 errors; vite build passes; full suite 638/638. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> chore(lint): adopt oxlint as the linter + CI gate; fix the bugs it surfaced (#761) ESLint was misconfigured (only globals.browser → 47 false no-undef) and run NOWHERE in CI, so 259 errors had accumulated unnoticed. Replace it with oxlint (Rust, ~50-100x faster) as the primary linter AND a real CI gate so lint debt can't silently pile up again. Tooling: - frontend/.oxlintrc.json — correctness=error; no-unused-vars with the existing ^[A-Z_] convention; node/vitest env overrides + AudioWorklet/__APP_VERSION__ globals (kills the false no-undef class); max-lines:500 (warn). - package.json: `lint` → oxlint, `lint:fix`, `lint:hooks` (advisory eslint). - eslint.config.js stripped to ONLY the React-Compiler rule family oxlint can't do yet (set-state-in-effect etc.), run via `lint:hooks`, NOT gated. Drop once oxlint's JS-plugin support leaves alpha. - ci.yml: new "Frontend lint (oxlint)" step in the Tests job — the gate. Real bugs oxlint caught (were buried in ESLint's noise): - GlossaryPanel: <X/> close-icon used but never imported → the edit-row cancel button threw ReferenceError at render. Imported X. - Two use*-named NON-hooks (useEngine action, useArchetypeAsProfile API call) tripped rules-of-hooks; suppressed with documented disables (renaming these misleading names is a worthwhile follow-up). Cleanup to reach a 0-error gate: removed 52 genuinely-dead vars/imports across 18 files (heavy in App.jsx — stale useState left over from prior refactors) and 4 behavior-preserving autofixes (no-useless-fallback-in-spread / no-useless-escape). Verified: oxlint 0 errors; bun install --frozen-lockfile in sync (Docker rule); vite build passes; full suite 638/638. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> refactor(pages): modularize Clone/Gallery/Profile pages (all files <500) (#760) Phase 3 — same standard as #758/#759, applied to the last three over-cap pages. Pure-mechanical, no behavior change. - VoiceGallery.jsx 768 → 205: relocate the already-separate zone components (ArchetypesZone, ArchetypeCard, CommunityZone, ImportsZone) + shared helpers into components/gallery/. - CloneDesignTab.jsx 837 → 395: split the ~540-line JSX return into section components (ScriptPanel, AudioMethodPanel, DesignMethodPanel, ActionBar) + MicButton, under components/clone/. State stays in the page. - VoiceProfile.jsx 515 → 287: split the main return into ProfileHeader / ProfileDetails / ProfileActivity under components/profile/. Safety contract for the JSX splits (no render tests): explicit NAMED props on every section so eslint no-undef verifies completeness on both ends; JSX moved verbatim. Verified: 0 no-undef across all changed files; every original className preserved (diffed main vs new set); every file <500 lines. Verified: vite build passes; FULL frontend suite 638/638 pass. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> refactor(dub): modularize DubTab page (1593→380 lines, all files under 500) (#759) * refactor(dub): extract DubTab sibling sub-components into components/dub (1593→1361) Phase 2 (partial). Move the 5 self-contained presentational sub-components out of the oversized DubTab.jsx into a new components/dub/ folder, matching the components/settings/ pattern. Pure-mechanical, logic byte-for-byte identical. Extracted (each with its own private helpers/constants): - DubFailureNotice, DubPipelineStepper (+DUB_PIPELINE/DUB_PHASE_BY_STEP), PrepOverlay (+PREP_FULL/PREP_CACHED/fmtBytesRate/fmtEta), TranscribeOverlay, FooterBtn. fmtDur stays — it's used by the main component. Pruned imports orphaned by the moves (copyText, errorDocsMap, a few icons). Verified: vite build passes; dub tests (dubExpiredJobError + DubbingDemo) 11/11 pass; no new lint errors. NOTE: DubTab.jsx is still 1361 lines — the main component is one ~1000-line stateful JSX return over 28 hooks. Getting it under the 500 cap needs that JSX split into section components, a higher-risk change deferred for a careful, test-backed pass (see docs/maintenance-pages-modularization.md). * refactor(dub): split DubTab JSX into section components (1361→380, all files <500) Completes Phase 2. The DubTab component was one ~1000-line stateful JSX return. Split that markup into five section components under components/dub/, keeping ALL state/hooks/handlers/effects inside DubTab — only the JSX moved (verbatim, by line-slicing). Safety contract (this is behavior-critical and has no render test): - Explicit NAMED props on every section (no bag/context object), so eslint no-undef verifies prop completeness on BOTH ends — a dropped value becomes a build error, not a silent runtime undefined. Verified: 0 no-undef across all files. - All 137 classNames from the original are preserved (diffed main vs new set). New sections: IdleSkeleton (368), DubLeftColumn (336), DubRightColumn (172), DubFooter (78), DubHeader (63). DubTab.jsx is now a thin composition (380). Verified: vite build passes; FULL frontend suite 638/638 pass; every settings & dub file now under the 500-line cap. --------- Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> refactor(settings): modularize Settings page (1969→399 lines, all files under 500) (#758) * refactor(settings): extract Settings.jsx tabs into components/settings (1969→602 lines) Settings.jsx had grown to 1969 lines — every edit reloaded the whole file into context and risked unrelated breakage. This finishes the migration the existing components/settings/*Panel.jsx pattern started: the page is now a thin orchestrator and each heavy tab lives in its own file. Extracted (logic byte-for-byte identical; only import paths adjusted + the shared isTauri/askConfirm moved to components/settings/native.js): - GeneralTab, ModelStoreTab, EnginesTab, HotkeyTab, CredentialsTab - native.js — shared isTauri() wrapper + askConfirm() Tauri-dialog helper Also establishes the standard so files can't silently regrow: - CONTRIBUTING.md: frontend file-structure & size limits (soft 300 / hard 500) - eslint.config.js: warn-only max-lines:500 guardrail (CI stays green) - docs/maintenance-pages-modularization.md: the phased refactor plan Verified: vite build passes (all imports resolve); 18/18 settings tests pass; no new lint errors introduced (the pruned imports were the only regressions). Follow-ups (tracked in the plan doc): ModelStoreTab.jsx is 836 lines and Settings.jsx 602 — both still over the 500 cap (warn-only); split next. * refactor(settings): split ModelStoreTab + Settings.jsx under the 500-line cap Follow-up to the tab extraction: bring the two remaining over-cap files into compliance with the new standard. Pure-mechanical, no behavior change. Settings.jsx 602 → 399: - Extract AboutTab, PrivacyTab, LogsTab into components/settings/ - Move the shared Row helper to components/settings/Row.jsx - LogsTab keeps its state in Settings() (lower-risk); About/Privacy take props ModelStoreTab.jsx 836 → 439, split into components/settings/models/: - format.js (fmtBytes/orgColor), runtime.js (computeRowRuntime) - columns.jsx exposes makeModelColumns(...) — a factory so the TanStack cell closures keep working; called with the same useMemo dep array as before - ModelsTable.jsx (virtualized table view), RecoBanner.jsx Every settings file is now under 500 lines. Verified: vite build passes; 18/18 settings tests pass; no new lint errors (the 4 remaining in Settings.jsx are pre-existing — refreshInfo no-op, a catch(e), two set-state-in-effect). --------- 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 (design-system pass) (#750) * 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. * 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: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> docs(changelog): finalize the [0.3.8] release notes (date, ASR-hang scope, dev-launch fix) (#747) 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> fix(dev): stop the Tauri dev app from killing concurrently's backend (bun desktop crash) (#745) `bun desktop` runs concurrently[dev:api, dev:desktop] with --kill-others-on-fail. dev:api is a uvicorn backend on :3900, but the Tauri app launched by dev:desktop ALSO manages a backend — on boot it sees :3900 in use (and not yet healthy, because the dev backend is still importing torch + loading 32 models) and 'takes ownership', killing the dev:api process. That exits 137, which trips --kill-others-on-fail and tears the whole session down. The Tauri app already supports TAURI_SKIP_BACKEND to skip backend management (lib.rs:654) — it just wasn't wired for the concurrently-managed dev flow. Set it on dev:desktop so the dev app attaches to concurrently's backend instead of fighting it. Set only on dev:desktop (not dev:api, and not the standalone `frontend` desktop script, which legitimately self-manages the backend). bun's script shell evaluates the inline VAR=val cross-platform (verified), so no cross-env dep / lockfile churn. Prod (desktop-prod) is unaffected — there the Tauri app is the sole backend manager and orphan-kill is correct. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> 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> 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> 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> docs: add rust as prerequisite for from-source builds (#704) Adds Rust/Cargo as a from-source build prerequisite across the linux/macos/windows install docs. Thanks @Deepakv2104. fix(asr): bound every transcribe path + reset the GPU pool on hang so a wedged ASR can't brick the backend (#730) (#731) 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> 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> fix(settings): harden control inputs against right-edge overflow (belt-and-suspenders) (#718) Follow-up to the responsive-containment fix (#713). Make Settings control inputs unable to overflow the available width regardless of inline widths a panel sets: - RemoteBackend's Backend URL + API key inputs hard-coded style={flex:1, minWidth:220} in a right-aligned 60%-max control — on a narrow control that 220px floor overflows. They're long-value fields, so lay them out as full-width stacked rows (st-row--stack) with the shrinkable .st-input class instead. - Add a universal guard: any text-ish input/select/textarea inside .st-row__control gets min-width:0 / max-width:100% / box-sizing, so no panel's raw input can spill past the row. Pairs with the page/row minmax(0,1fr) grids. Pure presentation; 638 frontend tests pass; build clean. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> 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> 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> fix(settings): contain page content within available width (no right-edge clip) (#713) Right-side control values/pills (e.g. Privacy's LOCAL SQLITE / OFFLINE TRANSLATION / NONE — NO TRACKING, and long stored-at paths) clipped off the right edge on wide windows. Root cause: both settings grids used a bare '1fr' track (= minmax(auto,1fr)), whose 'auto' minimum is the content's min-size. A non-shrinking child — a nowrap status pill or an unbreakable path — forces the track wider than the viewport, and .settings-content's max-width can't claw that back, so it clips at the window edge. Fix: minmax(0, 1fr) on both grids so the tracks can shrink below content min-size: - .settings-page → 168px minmax(0, 1fr) (the content column) - .st-row → minmax(0, 1fr) auto (a long title can't shove the control off-screen; the label shrinks/wraps) Shared layout primitives, so this contains EVERY settings page responsively. Pure presentation; 638 frontend tests pass; build clean. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> 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> docs(changelog): refresh the [0.3.8] headline for the release body (#708) The headline predated the later 0.3.8 work. Bring it current — Settings redesign, macOS native drag-drop (incl. macOS 26), the ASR CTranslate2-load fallback, the pronunciation dictionary, and the more-honest error messages (corrupt binary != OOM, model-id self-heal, stale-dub reset). release.yml publishes this section verbatim as the GitHub Release body, so the headline is the first thing users read on the v0.3.8 release. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> 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> 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> fix(desktop): enable native HTML5 file drag-drop on macOS (#700) (#703) The app's drop zones (clone reference, dub video, stories, batch) all use HTML5 dataTransfer.files, but tauri.conf.json never set dragDropEnabled, so it defaulted to true — Tauri intercepts the OS file-drop and the webview's HTML5 drop never receives the files. Most visible on macOS WKWebView and fully broken on macOS 26 (Tahoe). Set dragDropEnabled: false on the main window so the webview handles native HTML5 drops uniformly across platforms. (The Clone/Design textarea-resize half of #700 was already fixed for 0.3.8 by #595/#607; the reporter is on v0.3.7.) Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> docs(changelog): complete the [0.3.8] section for this release batch (#701) Add the user-facing entries that landed after the initial [0.3.8] draft: - Changed: the full Settings redesign (#686/#690/#696) and the inline first-run HF-token input (#687/#688). - Fixed: OMNIVOICE_MODEL self-heal (#693), ASR CTranslate2 .so-load fallback (#692), and the stale-dub recovery extended to initial upload/ingest (#695). Bump the section date to the expected cut date (set authoritatively at tag time). Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> 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> 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> fix(dub): reset gracefully on a stale job during initial upload/ingest (#695) (#697) The #660 fix wired the stale-job recovery (isExpiredDubJobError → reset) into the retry and SRT-import handlers, but NOT the two INITIAL handlers (handleDubUpload, handleDubIngestUrl). So a job that went missing during the first upload→prep→ transcribe flow (backend reload, cache eviction, manual cleanup) surfaced the scary "Job not found … report a bug" toast instead of quietly resetting the stale session — exactly the reported error. Route stale-job errors through isExpiredDubJobError() in both initial handlers too (before the reportable fallback), matching retry/import. Add a source-level regression guard so no dub handler can silently drop the stale-job check again (the #660→#695 regression class). Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> fix(settings): full-width content + fix right-side wrapping/overflow/padding (#696) Owner review of the live pages: the 760px content cap left a dead empty right half on simple tabs (Appearance), while wide tabs (Models) showed mid-word path breaks, an overflowing HF_ENDPOINT input, and controls flush to the border. - Content fills full width (removed the 760px cap + redundant models opt-out); 1280px ceiling only on ultra-wide. Comfortable side padding both sides. - Read-only mono path values wrap only at boundaries (no `…cach/e…` mid-word). - Inputs capped (min(360px,100%)) + box-sizing so HF_ENDPOINT/cache never overflow. - Right padding on .st-row__control so controls aren't flush to the edge. - Input-heavy rows (mirror preset, HF_ENDPOINT, cache location) go full-width below their label instead of a crushed right slot. Pure presentation; 636 tests pass; build clean. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> refactor(settings): premium redesign — compact density, nav rail, unified controls (#690) A design-council-driven overhaul of the Settings UI for a clean, professional, compact-yet-gorgeous feel (Notion/Obsidian quality), addressing "looks amateur, too tall, doesn't make sense": - Typography: section titles move from mono-uppercase ("debug log" look) to sans sentence-case 600; mono reserved strictly for data values. Three clear type levels. - Density: single-line ~32-40px rows (grid 1fr auto), hairline dividers instead of card-per-row, one muted description max per row (SettingRow hardened so the old double-description line is structurally impossible). - Navigation: kill the rainbow per-tab accents → one --chrome-accent; ≥760px a sticky vertical nav rail + a calm 760px content column (no stretch to the rail height — the empty-void fix); <760px a no-wrap horizontal scroll strip. - Controls: full-width horizontal grids for the font + theme pickers (were a squeezed vertical stack); unified tile/toggle/input styling via a new SettingsInput primitive; tokenized off-token literals. - No tacky wrapping: descriptions wrap at a comfortable measure (text-wrap: pretty, no orphans); short control values never break mid-word. Pure presentation — no behavior, handler, prop, testid, role, or i18n-string changes. 636 frontend tests pass; build clean; --chrome-* tokens only. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> feat(setup): compact inline HF-token input pinned with the Continue action (#688) Replace the bulky HF-token card (icon + title + paragraph + input row + link) with a single-line input bar — paste a token, Save — pinned right by the 'Waiting for required models…' / Continue button. Takes only the HF token; the explanation collapses to a one-line prompt (hidden on narrow widths) plus a 'Get one free →' link, and a slim '✓ saved' confirmation. Same save path and i18n keys; cleaner and lighter on the page. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> fix(setup): pin the HF-token card next to Continue, not buried in the model list (#687) The 'Add a free Hugging Face token for faster downloads' card sat at the bottom of the scrolling model library, so users had to scroll past every model to find it. Extract it into a standalone HfTokenCard and pin it in the wizard's always-visible action area, right above the 'Waiting for required models…' / Continue button — visible at a glance, click and paste a token without scrolling. Compact hint so it doesn't crowd the button. No behavior change to saving. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> refactor(settings): declutter + redesign the Settings UI onto a shared design system (#686) * refactor(settings): shared design-system primitives + shell restyle (unit 0) Foundation of the Settings redesign. Adds reusable primitives (SettingsSection, SettingRow, InfoHint, SettingsToggle, Collapsible) styled purely with --chrome-* tokens, and restyles the Settings shell: reordered icon tab-nav, inline tabs (General/Hotkey/Credentials/Logs/Updates/About/ Privacy) migrated to the primitives, Proxy/FFmpeg/advanced rows tucked into Collapsible, long prose moved into InfoHint popovers. Row() delegates to SettingRow. No behavior changes; ModelStore table/SSE untouched. * refactor(settings): restyle all panels onto the design system (units A/B/C) Migrate the 13 settings panels to the shared primitives — pure presentation, no behavior change: - Bucket A (Aec/Performance/Refinement/HFMirror/LLMEndpoint/MCP/Pronunciation): long prose (torch.compile OOM, refinement examples, etc.) moved into InfoHint popovers; custom checkboxes → SettingsToggle. - Bucket B (ApiKeys/RemoteBackend/Sharing): Tailscale/help prose → InfoHint + Collapsible 'Advanced'; ApiKeys/Sharing CSS converted off hardcoded colors to --chrome-* tokens (they mis-themed on 5 of 6 themes). - Bucket C (Appearance/Storage/Voice): VoicePanel switch → SettingsToggle; Appearance/Storage CSS tokenized; prose → InfoHint. - SettingsToggle now forwards arbitrary props (data-testid/aria) to the input. All 636 frontend tests pass; build clean; no new deps. * test(settings): query VoicePanel/Appearance switches by role after SettingsToggle migration The VoicePanel enable switch moved from a testid'd checkbox to the SettingsToggle primitive (role=switch); update the assertion accordingly. Was missed in the panel-restyle commit because this test lives under src/test/, not components/settings/. --------- Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> 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). * 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: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> docs(specs): ElevenLabs-parity roadmap + Tier-1 implementation specs (#684) Add the implementation-ready spec set mapping OmniVoice to ElevenLabs parity while preserving local-first: - 00-roadmap-elevenlabs-parity.md — gap analysis, prioritized tiers, sequencing, prior-art reconciliation, and the deliberate "won't build" list. - 01-expressive-tts.md — engine-agnostic emotion/style intent lowered onto each TTS engine's real mechanism (degrade-visibly) + a DB-backed pronunciation dict. - 02-conversational-agent.md — fully-offline full-duplex voice agent (/ws/converse, Silero-VAD barge-in on AEC-cleaned mic) composing existing streaming STT/TTS + LLM. - 03-longform-studio-editor.md — per-segment edit/regenerate across dub/audiobook/ stories, extending the existing content-addressed cache to longform. Reconcile prior planning docs: banner the superseded parity/studio docs pointing here; keep distinct-scope docs untouched (classification table in 00). Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> 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. * 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. * 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: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> fix(design): don't forward a clone profile_id in design mode (gender attribute no-op) (#674) (#679) In Voice Design, choosing "Male" (or any gender) could have no audible effect. Root cause: the design synthesize branch forwarded the selected `profile_id` alongside the design instruct. If that profile is a CLONE (reference audio, no instruct) — e.g. the demo voice selected by default — the backend clones it, and the reference voice's gender/timbre overrides the "male" attribute, so the design slider appears to do nothing. Fix: a pure `designModeProfileId(selectedProfile, profiles)` decides what to send in design mode — it suppresses a KNOWN clone (no instruct) so the design attributes drive the voice, while a design profile (carries an instruct) still passes through to re-render a designed voice. Conservative: an unknown id (profiles not loaded) or a design profile is unchanged, so this only removes the gender-hijacking case. Threaded `profiles` into useTTS. Test: voiceInstruct.test.js — clone (no/empty instruct) → null, design profile → its id, empty/null → null, unknown id → passthrough. Closes #674 Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> 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> fix(setup): use the backend's authoritative aggregate for live download progress (#675) The first-run download line showed wrong numbers — e.g. "8% · 1 KB/s · 0.0 MB left" on a 2.4 GB model that was barely started. The #657 display summed the PER-FILE tqdm SSE events on the frontend, but under parallel/segmented fetch the big weight shards report total/rate as 0, so the sum was garbage (tiny total → "0.0 MB left", a couple small files → "1 KB/s"). The backend already solves this: download_aggregator emits a throttled `phase:"aggregate"` event with one windowed rate + ETA + bytes_done/total_bytes (+ files done/total), seeded by the dry-run preflight totals — precisely because summing per-file on the client is unreliable. But WizardLibrary dropped that event (`if (!ev.filename) return prev`) and never used it. Fix: capture the `aggregate` event into per-repo state and render from it (new pure `progressFromAgg`), falling back to the per-file sum only until the first aggregate arrives. Now the line shows real, live values, e.g. "8% · 5.2 MB/s · 2.2 GB left · ~7m", updating in real time and landing on 100%. Test: wizardLibraryAggregate.test.js — progressFromAgg yields correct pct/remaining/rate/ETA from real totals (2.2 GB left, 5.2 MB/s — not 0.0 MB / 1 KB/s), returns null until totals are known, and caps pct at 100. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> docs(changelog): draft the [0.3.8] release section (#673) Renames [Unreleased] → [0.3.8] — 2026-06-24 with a one-paragraph headline in the house style, and adds the entries merged since v0.3.7 that weren't yet logged: faster default downloads + the surfaced HF-token card (#669/#657), the auto-play toggle (#666), the status-bar version badge (#671), and the Windows/stability fixes — WhisperX-on-Windows (#630), transcribe timeout (#656), preview playback (#653/#659), stale dub session (#660), bad-instruct 400 (#664/#612), Insert popover clipping (#672), and the M1 startup-hang bound (#632). A fresh empty [Unreleased] is left above it for the next cycle. This makes cutting v0.3.8 a single `git tag` away: release.yml extracts this section verbatim as the GitHub Release body, so the tag ships real notes instead of the auto-generated fallback. (Owner adjusts the date if tagged on another day; no version files touched — this is docs only.) Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> fix(clone): cap the ⊕ Insert popover height so it can't clip off the top of the window (#672) On the Voice Clone tab, the ⊕ Insert popover (15 expression-token chips) opens upward from the lifted button (`bottom: 60px`) but had NO max-height — so the wrapping chip grid grew unbounded and, when the button sat high in a tall script panel, the popover shot past the top of the app window and the first rows were clipped behind the title bar (reported with the tokens overflowing above the OmniVoice header). Cap it: `max-height: min(280px, calc(100vh - 120px))` + `overflow-y: auto` (+ `overscroll-behavior: contain`). The popover is now a compact, scrollable box that sits just above the button and always stays within the viewport, regardless of how tall the script is or where the button lands. Horizontal guard (#481) and the upward anchor are unchanged. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> feat(footer): clickable version badge → Updates, with an update-available indicator (#671) The bottom status bar showed no version and had no quick path to updates. Add a small `v<version>` badge next to the network/share icon; clicking it opens Settings → Updates. When an update is available (or downloaded and ready), the badge highlights and shows a pulsing notification dot, and its tooltip names the new version — so users can see at a glance that an update is waiting and one click takes them to install it. Mechanism: a one-shot `pendingSettingsTab` hand-off in the UI store (mirrors the existing `pendingProfileId` pattern) + an `openSettingsTab(tab)` convenience that sets the tab and navigates in one call. Settings consumes it as its initial tab and clears it (an effect covers the already-open case). The indicator reads the existing `updateStatus`/`updateVersion` from the updater slice — no new update plumbing. Version from the shared APP_VERSION constant; new strings via i18n. Test: openSettingsTab.test.js — the convenience sets mode=settings + the pending tab, and the value can be cleared after consumption. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> 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> feat(settings): add opt-out for auto-playing the preview after a render (#666) (#667) After a render finishes in Voice Clone / Design / a profile's try-it box, the output preview auto-plays unconditionally — `autoPlay` was hardcoded on the WaveformPlayer. A user batch-generating Korean clone segments asked to turn it off so each finished clip doesn't start playing on its own. Add a persisted `autoPlayPreview` pref (default ON — preserves current behavior) with a Settings → Appearance toggle, and thread it into the two preview call sites (VoicePreview.jsx, VoiceProfile.jsx) so `autoPlay={autoPlayPreview}`. WaveformPlayer already gates playback on the prop, so off = no auto-play; the manual Play button is unaffected. Cross-platform-parity safe: it's a pure UI preference that behaves identically on macOS/Windows/Linux, default unchanged. New strings go through i18n. Test: AppearancePanel.test.jsx — the toggle defaults checked (ON) and flipping it sets the store to false. Closes #666 Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> 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> fix(dub): reset stale dub session gracefully instead of erroring "Job not found" (#660) (#661) A persisted `dubJobId` outlives the backend's in-memory job store — after a backend restart (or once a job is cleaned up), resuming/retrying a dub returns 404 "Job not found. It may have been cleaned up or was never created." The UI surfaced this expected stale-session state as a hard error toast *with a "report a bug" prompt* (toastErrorWithReport), so a user who just reopened the Dub tab (#660: only action was view:dub) got a scary, un-actionable error for what is really "your old session is gone — start a new one." Fix the class: add a pure `isExpiredDubJobError(err)` predicate (matches the dub_core preflight message, the dub_generate expired-session message, and a bare 404 "Job not found") and a `_resetStaleDubSession()` helper that clears the dead job id/state, drops any pill, and shows a calm info toast inviting a fresh upload. Wired into the two handlers that operate on a pre-existing job — retry-transcribe (the #660 path) and SRT import. The fresh upload/ingest paths are intentionally left reporting real errors: a just-created job going missing *is* a bug worth reporting. Test: dubExpiredJobError.test.js pins the predicate against both backend messages + a bare 404, and asserts unrelated failures (stream dropped, CUDA OOM, abort) stay reportable. Closes #660 Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> fix(net): Windows preview playback — 127.0.0.1 loopback + quieter decode-fallback log (#659) Two coupled Windows fixes for the preview/blob audio path (the "playBlobAudio decode error: EncodingError: Unable to decode audio data" users see in Logs → Frontend on Windows). 1. apiBase 127.0.0.1, not localhost (Tauri context). The backend binds IPv4 127.0.0.1 only; on Windows "localhost" often resolves to ::1 (IPv6) first, so requests miss the backend. The main client (api/client.ts) already did this since #174, but utils/apiBase.ts lagged on "localhost" — and its one consumer is utils/media.js's preview upload, the #653 fallback. So #653's streamed fallback fetched http://localhost:3900/preview/upload and FAILED on Windows, leaving preview playback broken even after #653. Align the two resolvers. 2. Quieter, accurate logging in playBlobAudio. The Web Audio decodeAudioData path is EXPECTED to fail for long-form / AAC renders on WebView2 and is recovered by the streamed fallback — yet it logged at error level, so users saw a red "decode error" even when playback succeeded. Downgrade that branch to console.warn ("falling back to streamed playback"); reserve error level for the real failure (both decode AND fallback failed). With fix #1 the fallback now actually reaches the backend on Windows, so the recovery completes. Tests: apiBase.test.ts asserts Tauri → http://127.0.0.1:3900; the existing playBlobAudioFallback.test.js (#653) still passes (fetch hits /preview/upload, plays the HTTP URL, never a blob:). Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> fix(synthesize): validate clone-path instruct client-side so non-EN/ZH prose can't 400 (#612) (#658) A Vietnamese user typed a free-form Vietnamese description into the voice style (instruct) field and got "400 Bad Request: Unsupported instruct items found in quảng cáo, sôi nổi và thu hút". The instruct field is a fixed EN/ZH style-tag vocabulary (the model's trained tokens: gender/age/pitch/accent/dialect/whisper); the backend _resolve_instruct deliberately *raises* on unknown items. The design path already guarded this: it runs the free-text through buildDesignInstruct(), keeping valid tags, dropping the rest, and surfacing a localized warning toast (#115/#114). But the *clone* path (defineMethod === 'audio') appended the raw `instruct` string straight to the request — so a clone + free-text style in any non-EN/ZH language round-tripped to a 400 instead of being handled locally. Fix (localized client-side guard, the chosen approach): route the clone path's free-text through the same buildDesignInstruct({}, instruct) guard. Valid style tags survive (a clone can still ask for "whisper"); unsupported items drop with the existing localized `tts_errors.ignored_unsupported` toast; synthesis proceeds in the user's language without style control instead of failing outright. No backend/engine change — the model genuinely can't honor non-EN/ZH instructs, so this makes the failure graceful and understandable rather than a raw 400. Test: two cases in voiceInstruct.test.js pin the clone scenario — a fully Vietnamese instruct yields "" + all items in the unsupported bucket, and a mixed "whisper, sôi nổi" keeps "whisper" while flagging the prose. Closes #612 Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> feat(setup): show live download rate + size-remaining, surface HF token as a speed lever (#657) The first-run Models & Engines page showed only "downloading…" (and, once totals arrived, a bare percent + ETA). Users asked to see the actual download rate, the size remaining, and a way to speed downloads up. The backend already streams per-file byte counts and a windowed rate over SSE — the UI just wasn't surfacing it. Changes (frontend-only): - aggregate() now also returns live rate + bytes-remaining (was pct + ETA only), and is exported so the speed/remaining math is unit-tested. - The download line now reads e.g. "38% · 5.2 MB/s · 1.2 GB left · ~3m", each part shown only once the stream has it (still degrades to "downloading…" early). - New fmtBytes()/fmtRate() helpers (MB/GB, MB/s↔KB/s). The Hugging Face token field already existed but was buried in an "advanced" fold and framed only as "unlocks gated models" — so users hunting for a faster download never found it. Reframed the title/hint to lead with what they want: authenticated downloads are faster, have higher rate limits, and stall less (and still unlock gated models like pyannote diarization). Token persistence and the segmented/faster downloader (segmented_download.py) are unchanged — this just makes the existing speed levers visible. Test: frontend/src/test/wizardLibraryAggregate.test.js — aggregate sums bytes, ignores completed-file rate, returns nulls before totals; fmtBytes/fmtRate formatting + idle blanks. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> fix(asr): bound whole-file transcription so a stall isn't reported as "can't reach backend" (#656) A Windows/CUDA user (Vietnam) hit "Can't reach the local backend" only when dubbing/transcribing. Their log proves the backend started fine — model loaded, preload complete, 25 models — and the log ends right after `whisperx transcribing …tmp.wav`. The backend was alive; the *transcription* stalled (large-v3 ASR contending with the resident TTS model for VRAM on an 8 GB-class GPU), which the UI surfaces as an unreachable backend. Root cause (class, not instance): the chunked dub pipeline already bounds each chunk (OMNIVOICE_TRANSCRIBE_CHUNK_TIMEOUT_S), but the *whole-file* transcribe paths ran unbounded: - dub QC re-transcribe (dub_export) - dictation (capture) - OpenAI-compat /audio/transcriptions A slow/stuck transcribe on any of these hung the request AND held a GPU-pool worker — indistinguishable from a dead backend. Fix: add run_transcribe_guarded() in services/asr_backend.py — a shared asyncio.wait_for wrapper (ASRTimeoutError, a TimeoutError subclass) with a generous env-tunable bound (OMNIVOICE_ASR_TRANSCRIBE_TIMEOUT_S, default 300 s). On timeout the request returns 504 with actionable guidance (backend is alive; free VRAM / pick a smaller ASR model / use CPU; restart to clear the stuck worker) instead of hanging forever. Wired into all three whole-file paths. Docs: new troubleshooting §14 — "Can't reach the local backend during transcription/dubbing" — explains it's ASR weight/VRAM pressure, not a network/ mirror problem, and corrects the misconception that a "Network → Restricted/Global mirror" Settings toggle exists (the Network control is LAN sharing). Serves the #602/#585/#567 "can't reach backend" cluster. Test: backend/tests/test_asr_transcribe_timeout.py — slow fn raises ASRTimeoutError with the actionable message, fast fn passes through, subclass-of-TimeoutError so the openai_compat broad catch still maps to 504. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> fix(asr): cross-platform speechbrain lazy-import guard — unblock WhisperX on Windows (#630) (#655) WhisperX (the default ASR) aborts transcription with zero segments on Windows only, surfacing "Lazy import of LazyModule(...speechbrain.integrations.k2_fsa...) failed" (#630) or its generic wrapper "Transcribe stream dropped..." (#611, #647). Root cause is in speechbrain 1.x. It exposes optional integrations (k2_fsa, numba losses, spacy/flair nlp) as LazyModule redirects in sys.modules. Stray introspection during whisperx.load_model (pyannote -> speechbrain) — PyTorch's op-registration machinery, pickling, a dir()/hasattr walk — touches one of these redirects. speechbrain suppresses such inspect-triggered imports via a guard, but the guard checks filename.endswith("/inspect.py") with a hardcoded POSIX separator. On Windows the frame filename uses backslashes, the guard misses, the redirect actually imports k2_fsa -> import k2 -> k2 not installed -> ImportError that bubbles out and kills ASR. macOS/Linux use forward slashes, so the guard fires and the feature works — a Windows-only break of a cross-platform default (P0 parity). Fix the whole class (every optional-integration redirect, not just k2) by re-implementing LazyModule.ensure_module with a separator-agnostic basename check (normalise both "\\" and "/"), applied right before whisperx loads. Idempotent; a no-op on macOS/Linux and when speechbrain is absent; genuine missing-dep accesses from real user code still raise ImportError — only inspect-triggered spurious imports are suppressed, now on every platform. Regression test fakes the importer frame with Windows- and POSIX-style inspect.py paths plus a real-caller path, so it pins the behaviour on any CI host (fails before the fix on the Windows-path case, passes after). Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> fix(audiobook): play long-form preview via streaming HTTP, not decodeAudioData (#653) (#654) In-app preview of a finished audiobook/story did nothing on Windows. playBlobAudio (Tauri path) decodes the whole render into one PCM AudioBuffer via Web Audio decodeAudioData, which throws "EncodingError: Unable to decode audio data" on a long-form .m4b/AAC under WebView2. The catch-block fallback used a blob: URL, which the file's own fileToMediaUrl notes does NOT play in a Tauri <audio> element — so it silently played nothing. The fallback now uploads the blob to /preview/upload (ffmpeg-extracts a streamable WAV server-side) and plays the returned HTTP URL via <audio> — the exact pattern video previews already use. Streams instead of whole-file-decode, so it also fixes hour-long renders regardless of platform. Short WAV TTS previews keep the fast decodeAudioData path. Regression test pins that the fallback hits /preview/upload and plays an HTTP URL (never blob:). No version bump. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> chore(desktop-prod): add --keep-models for fast fresh-app runs (#650) `bun desktop-prod` (clean) wipes everything including the HF model cache, so every fresh-install emulation re-downloads multi-GB weights — slow and bandwidth -heavy, and the exact pain users on flaky networks hit. --keep-models wipes app/backend data, logs, and webview state for an honest first-run, but KEEPS the model cache so the weights aren't re-pulled. Ignored under --keep-data (which keeps everything). Adds the `desktop-prod:keep-models` convenience script. Scripts-only package.json change — no deps, bun.lock unaffected. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> feat(setup): calmer descriptions + surface platform-tuned models by default (#649) Two first-run setup polish items: 1. Descriptions dimmed + tightened (opacity 0.72->0.55 / 0.68->0.5, smaller line-height/reserve) and shortened (subtitle, compute, channel, mode copy). 2. "Models & engines" (WizardLibrary) now surfaces optional models tuned for the detected platform — those whose catalog "platforms" tag matches the host (MLX mac-ARM on Apple Silicon, CUDA variants on NVIDIA) — up-front with a green "recommended" chip + their note, folding only the universal long tail. Generic across platforms; graceful when none match. No backend change (the /models API already ships "platforms" + the host "platform_tags"). isPlatformPick extracted as a pure exported helper; 6 vitest cases. en.json + JSX fallbacks synced; orphan check clean; vite build green. No version bump. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> 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> 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> 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. * 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> 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> docs(troubleshooting): add stuck-download / incomplete-cache recovery (#622) (#643) The 'stuck on the download page, model folder has only refs/ no weights' case (a connection dropping/blocking mid-pull) is a recurring support report but wasn't in the install troubleshooting guide. Add section 13 with the recovery steps + antivirus/VPN/mirror escalation + a huggingface-cli manual fallback. Docs-only; no version bump. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> fix(dub+generate): yt-dlp 403 player-client fallback (#625) + non-finite audio guard (#629) (#635) Two independent fixes from issue triage; no version bump. #625 — yt-dlp 403 on the media download (some videos serve formats signature-protected to the default player client) is not transient, so the existing broken-pipe retry (#579) kept 403ing. The URL download now escalates the YouTube player client (tv → android → web_safari) on a 403 before giving up; a 403 no longer counts against the transient-retry budget. #629 — a numerical glitch in the model (seen on MPS) could leave NaN/inf samples that write an unreadable WAV; a downstream decode then failed with an opaque "ffmpeg returned error code: 183 / Invalid data", surfaced to the user as a misleading "ran out of memory". Sanitize non-finite samples to silence in _apply_effect_chain (single chokepoint, covers the raw path too) so the WAV is always decodable, and classify a decode/ffmpeg failure as unreadable-audio rather than OOM in _oom_friendly_reraise. Tests: 403 escalation order + success-on-alternate-client; NaN/inf sanitize + finite-passthrough + decode-error classification. Full suite 1851 passed. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> feat(startup): watchdog that dumps thread stacks on a startup hang (#632) (#634) A silent hang during the FastAPI lifespan startup (reported as a Mac M1 hang after 'Loading weights: 527/527') leaves the app unusable with no error: weights load, then 'Application startup complete' never fires. Without a thread dump the deadlock is invisible. Arm faulthandler.dump_traceback_later at the top of the lifespan and cancel it the instant startup completes (just before the yield). If startup stalls past the window (default 300s, OMNIVOICE_STARTUP_WATCHDOG_S to tune, 0 to disable), every thread's stack is dumped to stderr → backend_err.log, capturing the hang point for #632 and any future startup deadlock. Best-effort + exit=False, so the diagnostic can never itself break or kill startup; a normal (even slow-download) boot disarms it first and never trips. No version bump. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> fix(onboarding): commit + bundle the demo voice clip (#621) (#633) backend/assets/samples/demo_voice.wav is a build artifact (generated by scripts/build_demos.sh) that was never committed, so it shipped absent from installs: onboarding logged 'Demo audio not found', seeded nothing, and the Launchpad was empty on first run + the /demo_audio route was unavailable. The file is already un-ignored in .gitignore and bundled via the Tauri 'backend' resource — it just needed to exist in git. Commit it (regenerated via the script's say/Samantha path, 24kHz mono 16-bit, content matching DEMO_REF_TEXT) so first-run works on every platform. Onboarding keeps its graceful skip (now with a regenerate hint) for a partial checkout. Regression test guards the asset is present + valid and that onboarding seeds the demo profile from it (and is a no-op on a non-empty DB). No version bump. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> fix(dub): speaker-aware re-split so merged speaker turns separate (#486) (#616) Segmentation groups words into sentences BEFORE diarization, so a two-speaker exchange can land in one segment; assign_speakers_* then only relabels it with the majority speaker, losing the turn boundary (the second half of #486 — the per-speaker voice auto-assign was fixed in #490). Add a post-diarization pass that re-splits any segment whose words span >1 speaker at the word-level boundary, assigning each piece its speaker: - backend/services/segmentation.py: resplit_segments_by_diarization / resplit_segments_by_turns + a pure _resplit_core. Single-speaker segments are returned BYTE-FOR-BYTE UNCHANGED (same dict/id/text/start/end) — the no-single-speaker-regression guarantee. Pieces keep the segment's outer start/end (preserving onset-snap) and use word times for interior splits, so they exactly cover the original span. A lone mis-attributed word is smoothed, not split (diarization noise). - backend/api/routers/dub_core.py: accumulate global-timeline words alongside segments; apply the re-split after both the pyannote and FunASR-turns assign. Heuristic fallback (no word-speaker data) is untouched. 8 regression tests pin the invariant + the split/3-way/noise-smoothing/label behaviour. Full suite: 1836 passed. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> fix(setup): weight-aware install-state so truncated model cache isn't read as installed (#622) (#626) A first-run user whose model download was interrupted after the config/ tokenizer files landed but before the weight shard got stranded on the Models & Engines page: GET /models computed "installed" purely from cache size on disk, so a size-positive-but-weight-less cache reported installed=true, the wizard hid the re-download button, and the model manager (Settings → Models) that could repair it was unreachable behind the wizard gate. Make install-state weight-aware. The boolean weight-floor scan now lives in models.py (the lowest module in the setup import graph) as snapshot_has_weights() + cache_is_complete(); list_models() and recommendations() downgrade a truncated cache to installed=false (+ an explicit incomplete=true on /models), so the existing "install" action re-appears and the user can re-download in-wizard. Fixes the whole class, not just /models: download.py's install-time validator now delegates to the same shared scan (one source of the floors, can't drift), matching the load-time repair in model_manager.py (#581/#606). config_only repos (pyannote/speaker-diarization-3.1 — a pipeline whose real weights live in referenced sub-repos and whose own cache is legitimately tiny) carry a new config_only:true hint in models.yaml and are exempt, so they're not false-flagged as incomplete. Tests: tests/test_mm2_lifecycle.py — snapshot_has_weights truncated-vs-complete, cache_is_complete on a truncated weight repo + config-only exemption, and list_models downgrading a size-positive truncated cache to installed=false / incomplete=true. Full backend suite green (1832 passed). Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> feat(stories): tagged-script [Name] parsing → auto multi-voice cast (#487) (#615) Paste a `[Alice] … [Bob] …` podcast/audiobook script into Stories and Auto-cast now builds the cast and assigns a voice per character — no manual setup. This sits entirely on the existing Stories pipeline (autoCast → storyToSpans → /longform/render); the only missing piece was recognizing the `[Name]` tag format, which parseScript now auto-detects and routes through a new parseTaggedScript (alongside `NAME:` screenplay + quoted prose). - parseTaggedScript: `[Name] dialogue`, multi-line blocks join until the next tag, prose before the first tag → Narrator. Inline synthesis markers ([pause], [pause 500ms], [voice:ID], [fast], [spell]) are NOT treated as speakers (no colon + reserved-keyword guard), so they stay in the text. - parseScript auto-routes tagged scripts so the existing Auto-cast button works unchanged; single-line re-render is already covered by the content-addressed chapter cache. - autocastHint advertises all three formats. 16 parseScript tests pass. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> test: backend route-inventory + webUI feature-coverage guards (#609) * test: backend route-inventory snapshot + webUI feature-coverage guards A reusable testing system that verifies every feature surface is present: - tests/test_api_route_inventory.py: boots the app, diffs all 213 routes vs a committed snapshot (tests/fixtures/api_routes.txt), guards a critical-endpoint set, and floors the route count — any endpoint drift fails CI. - scripts/dump_api_routes.py: regenerates the snapshot. - frontend featureCoverage.test.js: every AppMode has a render branch, every lazy-imported page file exists, every feature has an i18n namespace. * docs(changelog): note the feature-coverage test system * test(api-inventory): isolate via subprocess + exclude env-dependent mounts CI surfaced two flaws in the first cut: - the in-process app import + sys.modules purge polluted later DB-touching tests (a cascade of 404s in test_dub_subtitles_309 etc.); - the snapshot included StaticFiles mounts (/demo_audio) and a conditional GET / root that register based on filesystem state, so a macOS-generated snapshot didn't match a fresh Linux CI runner. Compute routes in an isolated subprocess (scripts/dump_api_routes.py --print) and cover only the deterministic router surface (drop Mounts + root). 209 routes; inventory + previously-polluted tests now pass together. --------- Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> fix(design): heal validator-rejecting instruct on design voices (#594/#571/#596) (#600) * fix(design): heal validator-rejecting instruct on design voices (#594/#571/#596) A designed voice could persist an `instruct` the engine validator rejects — either the literal "[object Object]" from a pre-fix build (#550) or freeform prose typed into the style field — so every Generate/Dub that used the voice failed with `Unsupported instruct items found in …` (400/500, and "Can't reach the local backend" when it tore down mid-render). Migration 0006 only *blanked* "[object Object]", which silently discarded the design — an Indonesian female voice then rendered male (#594). Fix the whole class by healing at every seam and rebuilding from the authoritative source (the design's saved `vd_states` category picks): - omnivoice/utils/voice_design.py: add sanitize_instruct / instruct_from_vd_states / heal_design_instruct — forgiving (never raise), drop poison/prose to valid tags, and rebuild tags from vd_states when the stored value is unusable. - profiles.py: sanitize + rebuild at save (POST) and sanitize at edit (PUT), so no poisoned instruct can ever be persisted again. - generation.py + dub_generate.py: heal whenever a profile drives synthesis, so legacy poisoned rows resolve to valid tags instead of 400-ing. - migration 0007: heal existing profiles in place (recovers gender/age/pitch from vd_states), self-contained (frozen vocab snapshot) so it never drags torch into startup; supersedes 0006's blanking. Backward-compatible. Tests: unit coverage for the healer, a migration test driving 0006->0007 on the real schema, a parity guard so the frozen snapshot can't drift, and two API guards. Corrected one existing test that had encoded the #594 behaviour. Resolves #571, #594, #596; removes a major driver of the "Can't reach backend" reports. * test(cjk): allowlist migration 0007's frozen dialect-tag snapshot (#564) The 0007 instruct-heal migration carries a frozen copy of the design-tag whitelist (incl. Chinese dialect tags) so it stays self-contained; add it to the hardcoded-CJK allowlist like omnivoice/utils/voice_design.py. --------- Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> fix(transcribe): surface the real ASR-load failure instead of a generic "stream dropped" (#578) (#608) When WhisperX (or any ASR backend) failed to load its model, the transcribe SSE stream dead-ended on a generic "Transcribe stream dropped … Likely ASR backend failed to load" message with no actionable cause. Two root causes, both fixed: 1. WhisperX loads lazily inside transcribe(), so a load failure (faster-whisper weights, CTranslate2/cuDNN mismatch, torch-2.6 weights-only VAD regression) was buried in per-chunk errors and retried on every chunk. Added ASRBackend.ensure_loaded() (no-op default; WhisperX triggers its lazy loader) and call it in the transcribe pre-flight so the genuine cause surfaces once, up front, as a structured error event. 2. The pre-flight and audio-load error paths closed the SSE stream with a bare `error` and no terminal `done`, so the browser's native EventSource connection-drop could race and win against the structured error — discarding the real cause. Every terminal error now emits `done`, and the frontend latches the structured cause so a connection drop can't overwrite it with the generic message. Adds a fail-before/pass-after regression test driving the stream's async generator through the ASR-load-failure path; updates the existing #516 fake backend to the new ensure_loaded() contract; CHANGELOG ### Fixed entry. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> fix(ui): dub play button + designer script resize on Windows (#595) (#607) Two frontend bugs reported on v0.3.7, both Windows/Chromium-flavoured: 1. The PLAY button on the dubbed-video preview did nothing. WaveSurfer builds its AudioContext at mount (before any user gesture), so on Windows WebView2 / Linux FF/Chrome it stays "suspended" and playPause() resolves with no sound. This is the same autoplay-policy trap #510 fixed for WaveformPlayer, but the dub timeline player was missed. togglePlay and the per-segment playRange now await the shared unlockAudio() on the click before starting playback, and swallowed play() rejections are logged. A source-contract regression test pins the invariant (fail-before/pass-after verified). 2. The designer Script text field couldn't be expanded. It was a `flex: 1` item in a flex column, so flex-grow recomputed its height each reflow and snapped the resize-drag back — `resize: vertical` is ignored on a flex-grown item in Chromium/WebView2. The textarea now owns its height (flex: 0 1 auto + a taller min-height) so the corner grip grows it reliably on every platform. Gates: `bun run build` and `bunx vitest run` (563 tests) both pass. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> fix(tts): auto-repair incomplete model cache instead of dead-ending (#581) (#606) An interrupted first download leaves the HF cache with config/tokenizer files but no weight shard. transformers then raises an OSError ("does not appear to have a file named pytorch_model.bin or model.safetensors") on load, which model_manager translated into a 500 with a manual "delete the model and install it again" instruction — a dead-end for the user. Make the load path self-repair: on the truncated-cache OSError, re-fetch just the missing files via snapshot_download (already-present blobs are skipped, so a near-complete cache repairs fast and a healthy cache never reaches this branch), then retry the load once. HF offline mode is respected, and the actionable delete-and-reinstall message is preserved as the fallback when repair can't fix it. Adds tests/test_model_cache_repair.py covering completeness detection, the fast path (no repair on healthy cache), self-repair + retry, the offline guard, and the repair-failure fallback. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> fix(dub): retry transient broken-pipe on URL download (#579, #598) (#605) Pasting a video URL into the dubber could fail outright with `download: Unable to download video: [Errno 32] Broken pipe`. A broken pipe raised while the write side of a pipe closes mid-stream (a killed ffmpeg merge child, a CDN reset during muxing) aborts the whole `extract_info` call and is NOT covered by yt-dlp's own per-fragment retries, so a single transient blip killed the entire ingest. Root cause: no download-level retry around `yt_download_sync`'s `extract_info`. The failure was already classified as `VIDEO_DOWNLOAD_NETWORK` (#554/#536) and carried a "just retry" hint, but nothing actually retried. Fix: wrap the download in a bounded retry (1 + 2 attempts) that retries only on transient/broken-pipe-class failures, reusing the single `failure.classify() == VIDEO_DOWNLOAD_NETWORK` taxonomy (plus the BrokenPipeError/ConnectionError classes) rather than a parallel keyword list. Partial `original.*` files are wiped between attempts so a half-written download can't poison the next try. Unsupported links still fail fast with their own hint (no wasted retries); after retries are exhausted the existing actionable network hint is surfaced. Adds tests/test_dub_download_retry.py: retryability classification + retry-then-recover, bounded give-up, and no-retry-on-unsupported-URL. Fails before (no retry loop / helper), passes after. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> feat(support): Contact page, Ko-fi/PayPal donate, simpler license (#604) GitHub Sponsors isn't available for this account, so route donations to Ko-fi / PayPal instead, add a standalone Contact page, and trim the commercial-license page to the essentials. - Donate: drop GitHub Sponsors. Pick an amount ($10 / $20 / $50) then choose Ko-fi or PayPal; PayPal.me carries the amount into checkout. Updated .github/FUNDING.yml (ko_fi + custom PayPal) and the README badges to match. - Contact page (new `mode: 'contact'`, ContactPage.jsx): Discord, email, GitHub issues, and website (palash.dev) as clean one-tap rows; reachable from a new footer button. Routed in App.jsx, sidebar hidden like the other full pages. - Commercial License: cut the 6-tile benefit grid + 3-item FAQ down to the three deciding factors (IP ownership, no per-minute cost, direct support) and one clear "request a quote" email CTA. - All new copy goes through i18n (en.json: donate.choose_method*, enterprise.hero_simple/contact_lead, contact.*, logs.contact*). Build (vite) + vitest (561 passed) green; en.json validated. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> fix(bootstrap): gate venv on omnivoice import + source fallback (#564) (#603) * fix(bootstrap): gate venv on omnivoice import + source fallback (#564) `No module named 'omnivoice'` is a venv that starts uvicorn but can't import the project's OWN package: an interrupted/offline `uv sync` installed deps yet never laid the editable record (`_editable_impl_omnivoice.pth`), or antivirus removed it. The bootstrap health gate only checked `import uvicorn` + `import pkg_resources`, so it handed back the broken venv and the app failed only at the first model call (the dub/generate SSE error in #564). #573's source fallback in main.py wasn't enough on its own because the editable record, not the source tree, was the missing piece. Fix the root cause at the gate and harden the runtime: - bootstrap.rs: add an `omnivoice` import check beside the uvicorn/pkg_resources gates, using `importlib.util.find_spec` (resolves without importing, so no torch load). When it fails, fall through to the repair `uv sync`, which re-lays the editable install. Mirrors the #248 pkg_resources pattern exactly. - core/omnivoice_path.py (new): `ensure_omnivoice_importable()` — a tested helper that no-ops when the install resolves and otherwise appends the sibling source root to sys.path, with a precise diagnostic when neither is found. - main.py: replace the inline #573 block with the helper. - model_manager._lazy_omnivoice: self-heal on ModuleNotFoundError at the actual import site so the model-load path recovers and logs the searched roots. Regression tests cover the path-resolution logic (env override, append-not- insert precedence, no-source-found). cargo check passes for the Rust change. * test(omnivoice-path): patch via live module object to survive core reloads (#564) The #603 CI flake: other suites importlib.reload(core.*), leaving the top-level-imported ensure_omnivoice_importable closed over a stale module whose _already_importable a string-form monkeypatch didn't touch, so it returned None. Resolve the function + the patch target from sys.modules together. --------- Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> fix(backend): self-healing GPU pool so a reset can't strand requests (#589/#599) (#601) `_reset_gpu_pool()` fires on a model-load timeout to recover a wedged worker — it shut the ThreadPoolExecutor down and rebuilt a fresh one on next access. But several request handlers (generation, dub_generate, dub_core, dub_translate, openai_compat) did a *module-level* `from services.model_manager import _gpu_pool`, capturing the executor object at import time. After a reset those references pointed at the dead pool, so the next generate/dub/transcribe/ translate raised `RuntimeError: cannot schedule new futures after shutdown` — surfacing as a 500 or "Can't reach the local backend" (#589 #599). Make `_gpu_pool` a single long-lived `_ResilientGpuPool` wrapper (a concurrent.futures.Executor) whose *inner* ThreadPoolExecutor is swapped: - every submit() resolves the live pool, and a submit that races a shutdown rebuilds once and retries, so a stale captured reference self-heals; - `_reset_gpu_pool()` now drops only the inner pool (fresh worker on retry) while preserving the wrapper identity every importer holds; - pool sizing stays lazy, so we still probe the device after torch's lazy import (the reason for the original __getattr__ indirection). Fixes the whole class — all importers share one wrapper, module-level or function-level. Regression tests cover stale-ref-survives-reset, identity stability, submit-after-inner-shutdown self-heal, and asyncio.run_in_executor compatibility; updated the load-timeout test to the new reset semantics. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> fix(tts): NFC-normalize text + dense-script-aware chunking for long-form quality (#502/#505) (#587) Two defensive fixes for non-Latin / long-form synthesis quality: #502 (Vietnamese clone distorted/unintelligible): the /generate text path never NFC-normalized its input, so pasted decomposed (NFD) Vietnamese — base letter + combining diacritic instead of the single composed codepoint — reached the tokenizer/model as two characters and rendered as garbled speech. Normalize the input text to NFC at the endpoint (no-op for already-composed text), mirroring what the duration estimator already does so the estimate and synthesis agree. #505 (long-form 5+ min degrades — repeated/skipped/mispronounced): the chunker split purely by character count (800), but CJK/kana/Hangul pack ~1 char = 1 syllable, so an 800-char chunk is ~4-5 minutes of audio in a single shot — past the model's reliable range, where it starts repeating/skipping. When a chunk is predominantly dense-script, cap it to max_chars/2.5 so each chunk's spoken length stays bounded; Latin/spaced text is unchanged. Dense-script detection is by code point (no literal CJK in source — no-literal-CJK gate stays clean). Tests: _dense_char_count, _effective_max_chars (shrink-when-dense, unchanged-for- Latin, disabled-passthrough, floor), and that a 400-CJK-char string now splits (was one chunk) while a Latin paragraph still doesn't. Note: #502's exact distortion still wants a user sample to fully confirm; this is the defensive NFC fix that's correct regardless. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> fix(gpu): prevent the 8 GB-card OOM crash behind the "backend unreachable" wave (#567/#570/#571/#580+) (#586) The wave of "Can't reach the local backend" reports — all on ~8 GB NVIDIA cards, all during generate bursts — is the backend *process* dying, not a transport blip. Root cause: the GPU pool was sized at 2.5 GB/job, so an 8 GB card (~7 GB free) got 2 workers. The interactive clone path co-loads WhisperX large-v3 ASR (~3 GB) alongside TTS (~1.6 GB), so two concurrent clone jobs is ~10 GB on an 8 GB card → a sticky CUDA "illegal memory access" that aborts the whole interpreter (uncatchable by the per-request OOM guard, which only re-raises a clean torch.cuda.OutOfMemoryError as HTTP 500). Budget 5 GB/job (the real TTS+ASR concurrent footprint) instead of 2.5 GB: ≤10 GB cards now serialize to a single GPU worker — no concurrent-kernel contention, so the crash can't happen — while 16/24 GB cards still parallelize. Overridable via OMNIVOICE_GPU_WORKERS. This *prevents* the crash; the auto-restart supervisor (#572) *recovers* from any other cause — defense in depth. Extracted `_workers_for_free_vram()` (pure) with tests pinning 8 GB → 1 worker, the larger-card ladder, the floor/cap, and a guard on the budget constant so a regression toward 2.5 GB can't silently re-enable the crash. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> feat(design): seed pin / re-roll for designed voices (#526) (#577) Voice design rolled a brand-new random seed on every synth, so tweaking an attribute also re-rolled the whole base timbre — you could never iterate on the "same voice, slightly different". #526 asks for the seed to be shown with a "keep this seed" control. - Backend: `/generate` already accepted `seed` and echoed `X-Seed`, but left `used_seed=None` when nothing supplied one (non-deterministic, unreproducible, empty X-Seed). Now it materializes a concrete random seed when none resolves, so every take is reproducible and the real seed is always returned and stored — this also helps the clone/profile paths, not just design. - Frontend: new store slice (`designSeed`, `keepSeed`); the design synth reuses the pinned seed when "keep this seed" is on (via `pickDesignSeed`) and reads the authoritative seed back from `X-Seed`. Design tab gains a Seed field + "keep this seed" checkbox + "New seed" (re-roll) button. Test: `pickDesignSeed` (pin when kept+valid, re-roll otherwise, range guard). i18n keys added to en.json (other locales fall back; parity probe is advisory). Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> fix(dub): auto-assign per-speaker cloned voices to segments (#486) (#576) Multi-speaker dubbing diarizes the speakers and clones each one from the video (the Voice dropdown shows "From Video → Speaker 1 / Speaker 2"), but every segment was left on "Default" — the user had to set the voice on each row by hand. The clone→segment binding simply never happened: the transcribe `final` handler stored `speaker_clones` but set the segments without filling their `profile_id`. Bind them up front: new `applySpeakerCloneDefaults(segments, speakerClones)` sets each segment's `profile_id` to its speaker's `auto:<safe>` clone id when a clone exists and the user hasn't already chosen a voice. The id is computed by `autoProfileId()`, which mirrors the backend clone-resolution key (`speaker_id.lower().replace(" ","_")`) and the DubTab dropdown option value, so all three agree. Only an *empty* profile_id is filled — an explicit per-speaker or per-segment choice is never clobbered. Pure helper + unit test (assign-when-cloned, never-clobber, no-clone-stays- Default, no-op-without-clones). Note: the issue's second symptom — different speakers' turns merged onto one line — is a separate diarization/segment-grouping concern (speaker-turn re-split) tracked as a follow-up; this fixes the per-speaker voice assignment. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> fix(install): actionable torch-wheel-download failure + local-wheel recovery (#569) (#574) #569: on a restricted network the first-run install fails downloading the ~2.5 GB cu128 PyTorch wheel from download.pytorch.org, and the app won't launch. Two problems: the error told users to "set UV_DEFAULT_INDEX to a mirror" — which CANNOT redirect torch, because it comes from a *named, explicit* uv index (uv 0.11 rejects index-name override values and `--frozen` pins the exact wheel URLs); and there was no way to supply a manually-downloaded wheel. - Detect a torch/pytorch-host `uv sync` failure and emit torch-specific guidance (Clean & Retry → VPN → drop the wheel locally) instead of the wrong mirror advice. - Add a local wheel-drop dir `<env_root>/wheels` (survives Clean & Retry) wired via `UV_FIND_LINKS`. On a frozen-sync torch-download failure WITH wheels present, retry NON-frozen with find-links so uv re-resolves from the local wheels. Verified empirically: a non-frozen find-links sync installs from a local wheel fully offline, while a `--frozen` sync ignores find-links — so the retry is the only mechanism that can consume a dropped wheel. Best-effort: if it can't satisfy, it fails identically to before and the actionable error still fires. - docs/install/troubleshooting.md: new "#12 CUDA PyTorch wheel download fails" entry (docs-sync) — the offline wheel-drop path + why a PyPI mirror can't fix this index. Note: an automatic mirror redirect for the cu128 index is intentionally NOT shipped — uv provides no working override for a named explicit index, so it couldn't be verified; the offline wheel path is the reliable escape hatch. Test: sync_failure_is_torch_download host/keyword detection + negative guard. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> fix(backend): import omnivoice from source when the editable install is missing (#564) (#573) #564 ("No module named 'omnivoice'") is the backend failing to import its OWN package at the first model call (the dub SSE error on dub:upload). `omnivoice` is an editable install, so an interrupted/offline `uv sync` that installed deps but never laid the editable record, an antivirus-quarantined `_editable_impl_omnivoice.pth`, or an upgrade where only the lock-gated drift sync ran leaves the venv able to start uvicorn yet unable to import omnivoice — it boots fine and only fails at runtime, so the bootstrap health gate and the exit-based broken-venv self-heal (which only see a process that won't start) never catch it. Fix the whole class at the import layer: main.py now also appends the project root (the parent of backend/, where the desktop layout always copies omnivoice/) to sys.path, guarded on omnivoice/__init__.py existing. The backend then resolves omnivoice from source regardless of the editable-install state — covering every variant above. Appended (not inserted) so a real site-packages/editable install keeps precedence and it can't shadow a different omnivoice; a no-op in Docker (no sibling omnivoice/) and a harmless duplicate in a dev checkout. Also routes "No module named 'omnivoice'" through failure.classify() → BROKEN_VENV so, if it ever still surfaces, the toast points at Clean & Retry instead of a bare import error. Regression test covers the classify mapping and its negative guard (a legitimately-named omnivoice_* helper must not match). Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> fix(backend): auto-restart supervisor + client transport-retry (#567/#570/#571) (#572) The "Can't reach the local OmniVoice backend" cluster was a long-standing supervision gap (dates to v0.3.0/#38), not a v0.3.7 regression: the backend was spawned once and never watched again — `spawn_backend_and_wait` returned the instant it was healthy. When the uvicorn process then died mid-session (a CUDA OOM/context fault under a burst of generations — #571's log shows the startup banner replaying 6× during a 20-generate burst — an antivirus kill, any crash), nothing restarted it, so every later request threw connection-refused and the user was stuck on the toast until a full app restart. Two layers, both default-mode and platform-neutral: 1. Backend auto-restart supervisor (bootstrap.rs). After Ready, the bootstrap thread (which used to just return) keeps watching the child and respawns it on a *confirmed process exit* (try_wait — never a slow health probe, so a busy-but-alive backend is never killed). Bounded to 5 restarts/60s (then Failed) so a deterministic startup crash can't fork-bomb; the #314 broken-venv self-heal stays the venv-failure path. Strictly gated on AppFlags.quitting so it never resurrects the backend during shutdown. A single-supervisor guard (compare_exchange) prevents duplicate loops when Retry re-enters concurrently. Emits backend-restarting/backend-restored events (the splash poll stops post-Ready, so the stage alone can't show it). 2. Client transport-retry (client.ts). A *thrown* fetch (the backend briefly down while it respawns) is retried a bounded few times with backoff (~2.9s total) before surfacing the actionable ApiError, making the restart window invisible. HTTP errors and deliberate aborts are never retried. Resolves the whole cluster regardless of the crash trigger. Tests: Rust backoff-policy unit test (cap + window-pruning); 4 client-retry vitest cases (retry-then-succeed, no-retry-on-HTTP-error, no-retry-on-abort, bounded give-up). Also corrects a stale Cargo.lock omnivoice-studio version (0.3.6→0.3.8). Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> docs(changelog): complete the v0.3.7 section (items that landed but were under-listed) (#568) The v0.3.7 notes were missing several user-facing changes that shipped between v0.3.6 and the tag: Stories global reading-speed (#508), the Settings sparse-tab fill + Appearance i18n (#507), the donate progress correction (#513), and a ### Changed (version single-source #503, preview-nightly #500) + ### Internal (frozen-backend version #501) section. Restructured to the 0.3.6 house style. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> chore(version): main -> 0.3.8 after v0.3.7 release docs(changelog): add the non-English language fixes to v0.3.7 (#533/#505/#502) (#566) Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> fix(lang): propagate profile/request language into generation + longform (#533/#505/#502) (#565) Non-English voices drifted to English/wrong-language because the request's or profile's language wasn't reaching the model: - #533: generate_speech() read instruct/ref_text/seed from a resolved profile row but never row['language'] (and collapsed Auto→None), so a German archetype previewed in German yet generated in English on the user's own call (and via Docker/API). Fall back to the profile's stored language when the request didn't pin one; an explicit non-Auto request language still wins. (Frontend already sets the dropdown on profile-select; this is the authoritative backend fix.) - #505 (B2): the audiobook/longform synth hardcoded language=None, so the engine re-autodetected per chunk and a non-English clone flipped language mid-render. Add _resolve_default_language (request → profile → autodetect) and thread the resolved language through _build_synth/_prepare_synth/_render_longform_sse, the three longform request models, the preview path, and the resume manifest. Genuine Auto/unset behavior is unchanged. - #502 (partial): the duration estimator weights combining marks (U+0300–036F) at 0.0, so NFD/decomposed text under-allocated frames → rushed audio. NFC- normalize text at the estimator entry — fixes the whole diacritic-script class (no-op for precomposed text). (The residual "distorted" core still needs the reporter's sample; tracked separately.) Tests (fail-before/pass-after): profile language reaches the engine (German→de; explicit/Auto override semantics); longform synth gets the resolved language (→ja), not None; NFD vs NFC duration parity (Korean Hangul diverges ~3x pre-fix). Full suite: tests/ 1740 passed, backend/tests/ 114 passed. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> docs(changelog): v0.3.7 release notes (#563) The stabilization release: tags the startup-crash fixes already on main + the wave of 0.3.6-line fixes (voice design [object Object], consent_audio_path schema self-heal, Linux WebKitGTK buttons, pip install, ASR float16 fallback, audiobook import, Windows auto-play, download errors, relocated-venv self-heal, About version), and folds in the MOSS-TTS/dots.tts engines (#498/#531) + the Linux/Android audio-playback fix (#510) that landed on main. release.yml extracts this section verbatim as the v0.3.7 GitHub release body. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> fix: actionable download errors + self-heal a relocated venv (#554/#536/encodings) (#562) Two robustness fixes that share the failure taxonomy: - Video downloads (#554 douyin "Unsupported URL", #536 "Broken pipe"): yt-dlp's raw error surfaced with no next step. classify() now names UNSUPPORTED_VIDEO_URL (non-downloadable link shape — paste a direct video page or drop a file) and VIDEO_DOWNLOAD_NETWORK (transient CDN/network drop — just retry; the partial download is already cleaned up), each with an actionable hint. yt-dlp is on a current pin, so this is graceful classification, not a dependency bump. - "No module named 'encodings'" (relocated/copied/restored venv whose interpreter can't bootstrap its stdlib — exit 1, not 106): slipped BOTH #314 self-heal matchers, so the user saw the error forever. Widen backend_exit_indicates_broken_venv to also match the full quoted phrase, routing it into the existing rebuild-once self-heal. Kept narrow so an app-level import of an 'encodings'-prefixed package can't trigger a rebuild. Plus a BROKEN_VENV hint for the case where the rebuild itself can't run. Tests: classify() maps the 3 new classes with hints (a generic reason still ""), and the 'encodings'-prefixed-package negative guard holds; the Rust matcher test gains the encodings positive + negative cases. 5 pytest passed; the matcher is compiled by CI's Tauri shell check. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> fix(asr): float16-unsupported GPUs fall back to int8 instead of "no segments" (#561) #551: both CTranslate2 ASR backends request compute_type="float16" on CUDA with NO fallback. On GPUs without efficient fp16 (older Maxwell/Pascal, GTX 16xx) or a CTranslate2/cuDNN binary mismatch, WhisperModel/whisperx.load_model raise a ValueError at construction — which escaped the existing OOM-only `except RuntimeError`, so every chunk failed and the user got "Transcription produced no segments". Add a per-device compute_type fallback chain (cuda: float16 → int8_float16 → int8; cpu: int8 → float32) to both backends + the ASR sidecar, alongside (not replacing) the existing OOM→CPU path, with an ASR_COMPUTE_TYPE override for exotic hardware (documented in README). Also in the same ASR-robustness pass: - #549: PyTorchWhisperBackend._ensure_pipe wraps the transformers pipeline load and re-raises an actionable error (reinstall transformers / use faster-whisper) instead of a bare "Could not import module 'AutoFeatureExtractor'". - #516: the /dub/transcribe SSE generator is wrapped so it can NEVER close without a terminal event — any unanticipated exception now yields a structured `error` (with build_failure's hint) + `done`, turning "stream dropped, likely ASR failed" into the real cause + Retry. - failure.py: COMPUTE_TYPE_UNSUPPORTED + TRANSFORMERS_IMPORT classes so the no-segments toast is actionable. Tests (fail-before/pass-after): float16-unsupported → int8 for both WhisperX + FasterWhisper; a generic non-OOM RuntimeError still raises; classify() maps the two new classes; the SSE stream always terminates with error→done. 7 + 1 passed, 17 in the failure suite (no regression). Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> fix(projects): preview finished renders in-app, not via window.open (#532) (#538) The audiobook/story library card called window.open(audioUrl, '_blank'). Under Tauri's WebView2 on Windows that handed the file to a new webview/OS media surface, spawning a separate black playback window with centered controls that couldn't be closed without force-quitting the whole app. Route the render through the shared single-playback manager (playBlobAudio) so it previews inside the app — identical behavior on macOS/Windows/Linux, and starting another preview stops this one. This is the only raw window.open on a media URL in the frontend, so it fixes the whole class. Adds a regression test: the card plays in-app and never calls window.open. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> fix(audiobook): add AudiobookPlan.chapter_count so import doesn't 500 (#544) POST /audiobook/import ended with `plan.chapter_count`, but AudiobookPlan only exposed `char_count` (a property) and emitted `chapter_count` from `to_dict()` — so the attribute access raised AttributeError, surfacing as "500 Internal Server Error: 'AudiobookPlan' object has no attribute 'chapter_count'". The parse itself succeeded, so this hit every import format (.txt/.md/.epub/.pdf), not just PDF. Add a `chapter_count` property mirroring `char_count`, and have `to_dict()` derive its key from it so the attribute and serialized key can't drift. No API/schema/data change. Tests: unit property test + a direct-handler /audiobook/import regression (pdf/md/txt) that fails-before with the AttributeError. Fixes #543 Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> fix(settings): About → Version never blank (web/Pinokio build) (#560) In the web/Pinokio (non-Tauri) build with the backend idle, Settings → About rendered an EMPTY Version cell. Both sources were unavailable: appVersion is set only inside the isTauri()-gated effect, and info.app_version comes from /system/info which never resolves while the backend is IDLE (retry: Infinity). So `appVersion || info?.app_version || '—'` produced nothing. Add resolveAboutVersion() in a small util that falls back to the build-time __APP_VERSION__ (Vite-injected from package.json — always present regardless of Tauri/backend), and use it at BOTH the About → Version row AND the diagnostics- copy block (which had no fallback at all — the whole-class fix). Tauri/live- backend sources still take precedence, so the packaged build is unchanged. Test: resolveAboutVersion prefers Tauri → backend → build constant, and is never blank/dash. vitest 2 passed; typecheck clean. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> fix(engines): pin `uv pip install` to the running interpreter (#529/#527) (#559) The engine Install chip (deep_translator / openai / argostranslate) 500'd with "No virtual environment found". run_pip shells out to bare `uv pip install`, which discovers its target venv from VIRTUAL_ENV / a .venv in CWD — NOT from the running interpreter. The desktop spawns `<venv>/bin/python -m uvicorn` without exporting VIRTUAL_ENV and CWDs outside the venv, so uv finds nothing. The existing `--system` fallback never fires because it's gated on _in_virtualenv() being False, but the running interpreter genuinely IS in a venv (it just can't be auto-discovered) — the heuristic answers the wrong question. Pass `--python sys.executable` for uv install/uninstall: targets the same interpreter _probe()/is_installed() import from, fixing the whole class (spawned-venv, system, conda). It takes precedence when both flags are present, so the Docker `--system` path is untouched. (#527's openai is already bundled by #484; this hardens the chip for the remaining runtime-installed engines.) Test: run_pip's spawned argv contains `--python <sys.executable>` after install/uninstall. 2 passed. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> fix(ui): fill the shell on WebKitGTK so Generate/Settings buttons aren't clipped (#558) #523 "voice synthesis not pressable" / #524 "Settings button not visible" — and the Discord "where is the Generate button / the clone button disappeared" reports on 0.3.6/0.3.7 with the backend UP. The #504 zoom fix only half-worked: it sizes .app-container as calc(100vw/scale) magnified back by `zoom`, which round-trips to the viewport on Chromium — but older WebKitGTK (Linux AppImage/deb) treats `zoom` as a LAYOUT NO-OP, so the box stays shrunk to 77vw, leaving a ~23% black band and pushing the bottom-pinned Synthesize/Generate CTA + the NavRail Settings footer off-screen. The old comment's "FILLS the window (no black bands)" claim was false. No single static rule satisfies both engines (they disagree on whether `zoom` lays out), so detect it at runtime: - App.jsx: a one-shot probe measures a real `zoom:2` element; if its rect isn't magnified, the engine treats zoom as a no-op → set html[data-zoom-layout=off]. Robust where @supports(zoom)/UA-sniffing aren't (both lie on WebKitGTK), and future-proof (flips back to the zoom path on engines that start honoring it). - index.css: html[data-zoom-layout=off] .app-container renders at 1.0 filling 100vw/100vh — no band, no clipped CTAs. Chromium keeps the scaled zoom path. - appShellScale.test.js: guard BOTH branches (the existing calc+zoom path AND the 100vw/100vh fallback) so a future "simplification" can't re-break one engine. vitest 4 passed; typecheck clean. NOTE: CI e2e is chromium-only, so the WebKitGTK fallback path must be eyeballed on a real Linux build before the v0.3.7 tag. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> fix(db): self-heal additive schema columns so consent_audio_path 500s stop (#557) A profile/persona/consent endpoint 500'd with "no such column: consent_audio_path" (#552/#547) — and the same class for kind/vd_states/is_demo. The 0003/0005 migrations exist and are wired, but init_db's CREATE TABLE IF NOT EXISTS never adds columns to a pre-existing table, the legacy _migrate only knows pre-0.3 columns, and _run_alembic_upgrade swallows every failure. So on a DB whose alembic_version is stamped at a revision no longer in versions/ (common after running a preview build) or where alembic isn't importable, the alembic-era columns silently never land. Fix the whole class: add _reconcile_additive_columns(conn), which builds the canonical schema from _BASE_SCHEMA in-memory and ALTER TABLE ADD COLUMN any column an existing table is missing (additive only — never drops/retypes; names/types from _BASE_SCHEMA so injection-safe). Call it in init_db (so the schema converges regardless of alembic) and again in the alembic-failure branch. Also correct the false "_BASE_SCHEMA guarantees the schema regardless" comment. Test (fail-before/pass-after): init_db on a legacy voice_profiles whose alembic_version is a removed revision now lands consent_audio_path/kind/etc. without raising; reconcile converges to the canonical column set; idempotent + additive-only. 21 passed (incl. existing 0003/0005 migration tests). Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> fix(voice): stop poisoning design profiles with "[object Object]" instruct (#556) Voice Studio "Save design as profile" passed buildDesignInstruct()'s RETURN OBJECT ({instruct, unsupported, duplicates}) straight to FormData.append, which string-coerced it to the literal "[object Object]". That got persisted into voice_profiles.instruct and 400'd on first preview/use with "Unsupported instruct items found in [object Object]" (#550 #545 #542 #537 #530 #525). Fix the whole class + heal already-poisoned data (backward-compatible-data rule): - CloneDesignTab.jsx:609 — pass `.instruct` (the string), not the builder object. - useProfiles.js — append via new instructToFormValue() helper, which extracts `.instruct` if an object ever slips through again (defense-in-depth). - omnivoice/_resolve_instruct — drop the "[object Object]" sentinel instead of raising, so any value that slips through (e.g. generation_history) degrades to neutral conditioning rather than a hard 400. A genuine unsupported token still raises (keeps the #114/#115 user feedback). - migration 0006 — UPDATE voice_profiles SET instruct='' WHERE it's the sentinel, idempotent + table-guarded, to heal profiles saved on the buggy build. Tests (fail-before/pass-after): frontend voiceInstruct (instructToFormValue never yields "[object Object]"); backend _resolve_instruct tolerates the sentinel but still rejects a real bad token; alembic 0006 heals a poisoned row, leaves a healthy one untouched. Frontend 9 passed + typecheck clean; backend 5 passed. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Merge pull request #531 from debpalash/debpalash/feature-adding-moss-tts-v1.5-8b-model-as-an-alte feat(tts): add MOSS-TTS-v1.5 (8B) and dots.tts (2B) as opt-in engines (#498) feat(tts): add MOSS-TTS-v1.5 (8B) and dots.tts (2B) as opt-in engines (#498) Adds two zero-shot voice-cloning TTS engines requested in #498, both opt-in and subprocess-isolated with their own dedicated venv — the same pattern as IndexTTS-2. The dedicated venv is forced, not just chosen: each upstream pins a transformers version that conflicts with the parent's >=5.3 (MOSS-TTS-v1.5 ==5.0.0, dots.tts ==4.57.0), so they cannot share the parent interpreter. Because they use the clone+venv bootstrap (env var -> clone -> uv venv), this touches no pyproject.toml / uv.lock / bun.lock — `uv sync --all-extras` and Docker's `bun install --frozen-lockfile` are unchanged, so main's CI/Docker matrix stays green. Engines: - moss-tts-v15: 8B, 31 langs, ~16 GB weights, 24 kHz. AutoModel/ AutoProcessor via trust_remote_code. gpu_compat=(cuda,cpu) — MPS is undocumented/untested upstream so it is never claimed; on a Mac it runs on CPU. Apache-2.0, no license gate. - dots-tts: 2B, 24 langs, ~9 GB weights, 48 kHz. DotsTtsRuntime; continuation cloning (prompt_audio_path+prompt_text). Upstream is Linux/macOS-only, so is_available() gates it off cleanly on Windows (cross-platform parity rule — it is opt-in, never a broken default). Wiring: registered in _LAZY_REGISTRY + _INSTALL_HINTS. list_backends() surfaces both as subprocess/[cuda,cpu]/available-until-installed; the data-driven Settings engine picker needs no frontend change. Tests (19, fail-before/pass-after): registry resolution, subprocess marker, no-MPS gpu_compat, the Windows gate, not-installed honesty, and the parent-side generate() kwarg arbitration. Existing engine suite still 55 passed / 5 skipped. Sidecar inference follows the upstream-documented APIs but, like IndexTTS/Supertonic, can't be executed in CI without the multi-GB model clones. Docs (same-PR per docs-sync rule): README + README_CN engine tables, new docs/engines/moss-tts-v15.md + dots-tts.md, disk-usage.md (torch-dedup note), CHANGELOG. Merge pull request #519 from debpalash/fix/ui-scale-clipping-504 fix(ui): shrink layout box by uiScale so zoomed shell fits viewport (#504) fix(ui): shrink layout box by uiScale so zoomed shell fits viewport (#504) CSS `zoom` magnifies visually but does NOT enlarge the layout box. At the default `uiScale=1.3` the shell was drawn at 130vw x 130vh while the window stayed 100vw x 100vh, so the bottom/right edges were clipped by `overflow: hidden` on `#root` and `.app-container`. This hid Settings save, Dub transcribe, and Clone buttons (only reachable via Tab). Fix: shrink the layout box with `calc(100vw / var(--ui-scale))` / `calc(100vh / var(--ui-scale))`, then let `zoom` magnify it back to exactly the viewport on Chromium. On WebKitGTK `zoom` is a no-op, so the UI renders smaller but still fills the window - no black-band regression. Updates the appShellScale regression test to match the new intentional `calc(... / --ui-scale)` rule and keeps the `transform: scale()` blacklist. Merge pull request #518 from debpalash/docs/readme-reorder-header docs(readme): nav links above badges, download badges to Quickstart docs(readme): move nav links above badges, download badges to Quickstart - Nav links (Quickstart, Features, Why OVS, etc.) now sit above the badge row in the header for better scannability - Download badges (macOS/Windows/Linux/Debian) moved from the header to the Quickstart section where the user actually installs Merge pull request #509 from debpalash/discord/fix-508 fix(stories): apply global reading speed to preview + stem export Merge pull request #517 from debpalash/docs/readme-shorten-why-heading docs(readme): shorten 'Why OmniVoice Studio?' to 'Why OVS?' docs(readme): shorten 'Why OmniVoice Studio?' to 'Why OVS?' Merge pull request #513 from debpalash/fix/donation-progress-10-of-200 fix(donate): correct progress from $137.50 to actual $10/$200 raised fix(donate): correct progress from $137.50 to actual $10/$200 raised The in-app goal bar and its bundled snapshot showed $137.50 / $200 (23 sponsors) — fabricated numbers. Updated both the runtime JSON and the TypeScript fallback to reflect the real amount: $10 / $200, 1 sponsor. Also updates the README badge color from red to yellow (in-progress). Merge pull request #511 from paoloantinori/fix/audio-mime-and-audiocontext-unlock fix(web): audio playback on Linux/Android (MIME + AudioContext unlock) Merge pull request #512 from debpalash/docs/readme-update-donate-cta-and-features docs(readme): add donate CTA, update features/engines/roadmap for v0.3.6 docs(readme): add donate CTA, update features/engines/roadmap for v0.3.6 - Add Sponsor/Donate section with Ko-fi, PayPal, GitHub Sponsors links and progress bar ($10/$200 agent bill fund) - Add Ko-fi + GitHub Sponsors badges to header - Expand features table: Audiobook, Stories, Diagnostics, Engine Routing, Portable Personas, Unlimited TTS, Remote Backend, Dictation+LLM (3x4 → 5x4) - Update TTS engines: 6 → 11 (add GPT-SoVITS, Sherpa-ONNX, IndexTTS 2, OmniVoice GGUF, Supertonic 3) - Update ASR engines: 7 → 8 (add isolated Faster-Whisper) - Expand roadmap Shipped with v0.3.6 additions (Longform, engine routing, diagnostics, MCP server, remote backend, reliability, etc.) - Update architecture diagram (100+ endpoints, engine routing) - Add comparison table rows: Audiobook/Stories, TTS count, ASR count, MCP Server, Self-check - Update FAQ engine count to 11 - Fix CTranslate2 link typo - Remove already-shipped Audiobook Editor from Up Next docs(changelog): note MIME + AudioContext fixes for Linux/Android playback test(audioUnlock): unit-test the gesture-driven AudioContext resume Locks down the resume path from the parent commit: - Every AudioContext is tracked at construction (the wrap is active) - unlockAudio() resumes all suspended tracked contexts in parallel - Idempotent: repeated calls do not re-resume - Contexts created after unlock are not re-resumed by a second call - resume() rejections are swallowed — one bad context doesn't block others - installAudioUnlock() is idempotent (the _installed gate works) The unlock path was the fix for the "click does nothing" bug on Linux Firefox/Chrome and Android Chrome, where AudioContexts created before a user gesture stay suspended — decodeAudioData hangs → WaveSurfer's ready never fires → the play button never enables. Without this test, breaking the gesture wiring silently regresses every non-macOS browser. Adds the __resetForTesting() export so the unlock can be exercised repeatedly against the same module instance (the unlock is meant to be a one-shot per page load). fix(web): resume AudioContext on first gesture so play button works on Linux/Android (#fix) Browser autoplay policy (Linux Firefox/Chrome, Android Chrome, mobile Safari): AudioContexts created before a user gesture start in "suspended" state — decodeAudioData hangs and WaveSurfer's `ready` event never fires. The play button gated on `ready` stays disabled forever, so the click silently does nothing and no /audio/ request ever fires. macOS Safari/Chrome are more lenient (typically auto-resume on first interaction) which masked the bug cross-platform. Fix has three parts: 1. `frontend/src/utils/audioUnlock.js` (new) — monkey-patches `window.AudioContext` (and `webkitAudioContext`) to track every instance ever created. Exports `installAudioUnlock()` which wires a one-time pointerdown/keydown/touchstart listener that resumes all suspended contexts on the first user gesture. The patch MUST install before any module constructs an AudioContext, so this file is imported first in main.jsx before the dynamic import of main-app.jsx. 2. `frontend/src/main.jsx` — imports and installs the unlock before any other module loads. 3. `frontend/src/components/WaveformPlayer.jsx` — three changes: - Remove the `Loader` spinner that gated on `ready`. The spinner itself was a visual signal that the user was waiting on a state the browser refuses to produce without user interaction. - Button is now `disabled={!resolvedUrl}` — clickable as soon as the audio URL exists, so the user's click IS the gesture that unlocks the AudioContext. - `togglePlay()` explicitly awaits `unlockAudio()` before calling `playPause()` to close any race with the global gesture listener. The console warning "An AudioContext was prevented from starting automatically" may still appear once on page load — that's the informational signal that the pre-gesture context was created suspended; it's harmless because we explicitly resume on first interaction. Tested: Linux Firefox 151.0.3 — before fix, clicking play did nothing (no /audio/ request fired, button never enabled). After fix, single click on play resumes AudioContext + starts playback, waveform animates. Pairs with the audio/wav MIME fix in the same PR — both bugs had the same user-visible symptom (silent play button on Linux) but different root causes. fix(stories): apply global reading speed to preview + stem export (#508) The #415 global speed only flowed through the full longform export (storyToSpans). Per-segment preview and stem export resolved speed with a hardcoded `track.speed || 1.0`, silently dropping the global → generated audio played at 1.0x even with the global set to e.g. 0.70x. Add a pure `effectiveSpeed(track, globalSpeed)` helper (mirrors effectiveProfile / storyToSpans precedence: per-line override → global → engine default) and use it at both call sites so all three generation paths agree. Regression-tested in storyCast.test.js. Fixes #508 fix(settings): fill sparse tabs + route Appearance strings through i18n (#507) * fix(settings): fill the panel on short tabs instead of a stunted box in a void Settings tabs with little content (Appearance — UI scale/theme/font; About; Privacy) rendered the accent-bordered .settings-content as a short box with the rest of the page as empty black void below it (reported on Appearance). Make .settings-page a flex column with min-height:100% (a FLOOR — tall tabs grow past it and scroll exactly as before) and let .settings-content flex:1 1 auto grow to fill. Safe by design: it only adds space when there's slack, and if the parent height is ever indeterminate the rule no-ops rather than constraining content. * i18n(settings): route AppearancePanel strings through i18n The Appearance panel hardcoded English ("UI scale", "Color theme", "Font", and the help paragraph) — against the localization hard rule. Wire them through t('settings.*', { defaultValue }) matching the ApiKeysPanel pattern; reuse the existing settings.appearance/ui_scale/theme keys and add color_theme/font/ appearance_help to en.json (the reference superset — other locales fall back to English and backfill via the translation pipeline; the i18n parity gate only requires valid JSON). Also renamed the THEMES.map(t =>) variable to `th` so it no longer shadows the translation `t`. typecheck + Appearance vitest (3) + i18n parity probe (4) all pass. --------- Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> fix(test): resolve package.json version reference in the desktop probe spec (#506) #503 made tauri.conf.json derive its version from package.json ("version": "../package.json"), but the L3 desktop probe spec (desktop_smoke.probe.yaml) asserts config.version == pyproject_version and was reading the literal path string "../package.json" — reddening main. Resolve the package.json reference in load_tauri_config() the way Tauri does, so the integrity check sees the effective bundle version. The check now validates the *real* thing end-to-end: the resolved desktop bundle version matches the project version (and implicitly that package.json == pyproject). Full suite: 1691 passed, 20 skipped, 11 xfailed, 3 xpassed. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> chore(version): make frontend/package.json the single source of truth (#503) Five hand-maintained version literals (pyproject, Cargo.toml, tauri.conf.json, package.json, version.py) drifting is what shipped a 0.3.6 build calling itself 0.3.5 (package.json lagged; the frozen backend's literal lagged). Collapse to one canonical source. - frontend/package.json is canonical: vite already injects __APP_VERSION__ from it (first-run setup footer + bug reports). - tauri.conf.json now reads its bundle version from it ("version": "../package.json", a supported Tauri v2 feature) — the MSI/dmg/updater version can no longer drift from the UI. Removes the most error-prone literal. - Cargo.toml + pyproject.toml + version.py's _FALLBACK_VERSION remain as toolchain-required CI-guarded mirrors, bumped in lockstep from the canonical. - release.yml: the preview-stamp and version-bump jobs now read/write package.json (the canonical) and no longer touch the derived tauri.conf.json. - tests/test_app_version.py: new test_tauri_version_derives_from_package_json guards the path; the lockstep test now checks the mirrors against the canonical package.json. - CLAUDE.md versioning rule updated to document the single-source model. 6 version tests pass. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> fix(web): serve .wav/.flac with IANA-canonical MIME so Linux/Android browsers play inline (#fix) Python's `mimetypes.guess_type()` returns `audio/x-wav` for `.wav` and `audio/x-flac` for `.flac` — vendor-experimental types (x- prefix) that were never IANA-registered. macOS Chrome/Safari MIME-sniff leniently via CoreAudio so playback works there, but Linux Chrome/Firefox (FFmpeg) and Android Chrome (ExoPlayer) strictly honor the declared type and treat the x- variants as download-only. Result: the play button in the browser web app silently did nothing on Linux/Android (download prompt instead of inline playback), while the Tauri desktop shell worked because its WebView is lenient. The diagnostic signal — Chromium short-circuits to download BEFORE the <audio> element sees the response, so no MEDIA_ERR_SRC_NOT_SUPPORTED fires; just a download prompt that's easy to miss. Fix: register `audio/wav` and `audio/flac` (the IANA-canonical types) via `mimetypes.add_type()` before the StaticFiles mounts in main.py. No browser-side workaround exists (no chrome://flags, no about:config pref) — the server is the only place this can be fixed. Existing comment in dub_export.py:766 already acknowledges this exact quirk for video files; this applies the same treatment to audio. Test: regression test in test_api.py asserts `/audio/<file>.wav` returns `Content-Type: audio/wav`. Without the fix this returns `audio/x-wav`. Ref: https://www.iana.org/assignments/media-types/media-types.xhtml#audio fix(version): frozen backend reports real version, not the 0.3.5 fallback (#501) The desktop app's About panel, /health, /system/info, diagnostics, bug reports, and exported persona/marketplace bundle metadata all read core.version.APP_VERSION. In a synced env that resolves from package metadata (correct), so CI and the lockstep test were green — but the PyInstaller-frozen backend has no omnivoice .dist-info, hits PackageNotFoundError, and fell back to a hardcoded APP_VERSION = "0.3.5". The version-bump job never touched that literal, so every 0.3.x desktop build has been reporting 0.3.5 regardless of its real version. Fix (belt and suspenders, so it can't recur): - backend.spec: copy_metadata('omnivoice') so importlib.metadata resolves in the frozen build — the primary path now works there too. - backend/core/version.py: resolution chain is metadata → pyproject (walked up, correct for raw source checkouts) → a named _FALLBACK_VERSION literal as last resort (no longer the only fallback). - tests/test_app_version.py: _FALLBACK_VERSION joins the lockstep (now FIVE sources); + a test that the fallback resolves to pyproject, + a test that backend.spec copies the metadata (so the frozen path can't silently regress). - release.yml version-bump: also bumps _FALLBACK_VERSION so the lockstep guard never reddens main after a release. Already-shipped binaries can't be fixed, but every build from here (tonight's preview, the next stable) reports its real version. 5 version tests pass. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> fix(updater): preview channel builds nightly from main + prerelease/parity guards (#500) The Preview update channel was effectively dead: its only build trigger was a manual workflow_dispatch, so "preview = main" was never enforced — the live preview manifest was stuck at 0.3.5-41 (June 7) while main moved to 0.3.7. It also shipped two latent hazards: the `preview` GitHub release had drifted to isPrerelease=false (a non-prerelease `preview` is eligible to become GitHub's "Latest" — the exact URL the *stable* updater reads, so it could hijack the Stable channel), and its updater manifest dropped darwin-x86_64 (Intel-Mac preview users silently got no updates — a cross-platform-parity breach). Changes (release.yml): - Add a nightly `schedule` (07:00 UTC) that rebuilds the rolling `preview` prerelease from main. A new `preview-gate` job no-ops the 4-platform matrix on nights when main didn't move, so idle days cost only a ~30s gate job. - Centralize the preview-vs-stable decision in `preview-gate.outputs.is_preview` (schedule OR workflow_dispatch+publish_preview), consumed by the stamp step, tauri-action, and preview-notes — replacing the repeated inline conditions. - Harden the prerelease flag: preview-notes' `gh release edit` now re-asserts `--prerelease` every run, and a new post-publish step fails the run if the preview release isn't a prerelease or its manifest is missing any platform stable ships (catches the Intel-Mac regression in CI). Docs (docs-sync): update docs/update-channels.md — previews are no longer "manual / no scheduled spend"; they build nightly from main (+ on demand). The live `preview` release was re-flagged prerelease out-of-band to close the hazard immediately; this makes it recurrence-proof. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> docs(changelog): backfill high-quality v0.3.6 release notes + make it a hard rule (#499) v0.3.6 was tagged with the "Auto-generated release for v0.3.6…" fallback body because CHANGELOG.md had no matching section (release.yml's "Extract CHANGELOG section for tag" step found nothing). Backfill a real, user-facing `## [0.3.6] — 2026-06-16` section (Longform suite, engine routing, dubbing + install reliability, FSL→AGPL relicense) and fold the shipped `.ovsvoice` persona entry out of [Unreleased]. The live GH release body was updated in-place to match (notes + the per-platform checksum blocks preserved). Add a "Release notes / changelog" hard rule to CLAUDE.md: every tagged release (and preview build) gets a high-quality, house-style CHANGELOG section before the tag — never the auto-generated fallback — since release.yml ships that section verbatim as the release body. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> chore(version): main -> 0.3.7 after v0.3.6 release chore(version): bring frontend/package.json into the version lockstep (0.3.6) (#497) Pre-v0.3.6 release sweep found frontend/package.json stuck at 0.3.5 while the other three version files were 0.3.6. package.json drives the runtime `__APP_VERSION__` (vite.config.js), so a v0.3.6 build was calling itself "v0.3.5" in the first-run footer AND in every auto bug report (undercutting the bug-report feature). Root cause: the release.yml version-bump job only bumped the trio (tauri.conf.json / Cargo.toml / pyproject.toml), never package.json, and no test guarded the lockstep. - Bump frontend/package.json 0.3.5 → 0.3.6 (matches the trip; `--frozen-lockfile` still passes — the version field doesn't affect the bun lock graph). - Add frontend/package.json to the release.yml version-bump job (set absolutely via jq so any prior drift self-heals on the next release). - Add tests/test_app_version.py::test_all_version_files_in_lockstep — fails CI if the four files ever diverge again. - CLAUDE.md versioning rule updated: it's now FOUR lockstep files, not three (docs-sync). Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> docs(planning): commit singing-mode + donate-cta specs + SPIKE-02 supersession (#496) Persist the planning artifacts produced this cycle: - specs/006-dubbing-singing-mode/ (SoulX-Singer SVS evaluation + plan; supersedes SPIKE-02, which is marked superseded here). - specs/007-donate-cta/ ("Fund Claude Max" goal bar + kawaii postcard design, conversion strategy, frequency state machine, Discord surface). Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> fix(bootstrap): force-reinstall setuptools so pkg_resources repair actually works (#248) (#495) The auto-repair ran `uv pip install setuptools>=75,<80`, which `uv` treats as "already satisfied" (no-op, "Checked 1 package in 5ms") whenever setuptools' *metadata* is present but its `pkg_resources` files are gone — the common cause being Windows Defender quarantining `pkg_resources/`, or a partial extract on a restricted network. So the repair never restored the files, the post-check failed, and users hit the #248 dead-end. The error message *also* told them to run the same no-op command, so the suggested manual fix didn't work either (reported on Discord, Win11 + RTX 5070 Ti). Fix: both repair sites in bootstrap.rs now use `--reinstall` (the flag already used for the ROCm torch repair), which force re-extracts pkg_resources even when uv thinks setuptools is satisfied. The fail() message and the failure.py hint now suggest `uv pip install --reinstall 'setuptools>=75,<80'` + an antivirus-exclusion note, and docs/install/troubleshooting.md (#pkg_resources-missing) is updated with the real cause (metadata-present/files-missing) + AV guidance. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> feat(donate): "Fund Claude Max" goal bar + kawaii postcard + milestones (#007) (#494) Problem OmniVoice's donate surface was a static link page. There was no sense of shared progress toward a concrete funding goal, and no gentle, success-only ask after a user got value — only an always-on footer heart. Design Phase 1 — Goal bar + data (Option B): - frontend/public/donation_progress.json (committed snapshot) + a bundled offline fallback in api/donation.ts. loadDonationProgress() best-effort fetches a fresher copy and gracefully falls back to the bundle on any failure (offline / non-2xx / bad JSON). Never throws. - <GoalBar> (page + `mini` variant), --goal-pct-driven fill, Pip mascot perched on the fill, ONE shimmer pass, reduced-motion guard on every animation. Added to SupportPage above the payment cards with "Join {n} supporters" social proof + suggested amounts ($3/$5/$10/Custom, middle flagged "most common", NONE pre-selected). Phase 2 — Pip + postcard + state machine: - Pip.jsx (currentColor->accent, pipBob/pipWave idle, reduced-motion off). - donationSlice.ts composed into the store: added to partialize (all EXCEPT shownThisSession), version 5->6 with a pass-through migrate branch. shouldShow rules: first-3 grace, <=1/session, escalating 7d/14d/30d/75d cooldowns, optedOut terminal, success-only. - Postcard.jsx rendered via react-hot-toast as a NON-BLOCKING custom toast (no backdrop, no focus steal, ~12s auto-dismiss, pause on hover) with the perforation / dot-grain / stampThunk / postcardIn / .is-leaving art, a mini GoalBar, and Chip in / Maybe later / quiet Don't ask again / free Star on GitHub actions. - One shared evaluateDonationPrompt() called right after each SUCCESS (dub-complete, clone-save resolve, longform export) — never on the error / in-progress / setup / first-run paths. Phase 3 — Milestones + pill: - Milestone eval (1st clone / 10th dub / 30-day sustained, each once-ever, same cooldowns + opt-out) inside the shared evaluator. - Quiet nav-rail .donate-pill (🩷 Support) that warms to the accent on hover and opens setMode('donate'). Tests (vitest, all green: 60 files / 533 tests) - donationSlice.test.ts: shouldShow truth table with injected `now` — grace, each cooldown rung, session cap, opted-out terminal, success-only. - GoalBar.test.jsx: renders from injected JSON, offline fallback to bundle, goal-met state, mini variant; plus the data module's clamp/normalize/fetch. - evaluateDonationPrompt.test.jsx: gating + that the postcard never fires on the error path (success-only contract). bun run typecheck:ci clean; vite build green; root bun.lock untouched (frozen install verified); i18n: all user-facing strings via t('donate.…') with English defaultValue fallbacks — no hardcoded CJK. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> fix(profiles): decouple design-profile save from TTS render (#476) (#488) * fix(profiles): decouple design-profile save from TTS render (#476) Saving a design voice profile forced a full TTS model load + inference to render a deterministic identity sample. On a fresh model-less image (Docker first-run) that 503'd, so the save failed. A secondary guard also rejected an all-Auto design (empty instruct) with a 422. Saving a design profile is now a pure persistence operation: - The seed-42 identity sample render is attempted opportunistically but is non-fatal — if the engine isn't ready the row is persisted with ref_audio_path=NULL (sample pending). The row's vd_states + instruct already make the voice fully usable (generation.py falls back to instruct-only conditioning for design profiles with no ref audio). - The sample is rendered lazily + cached on the first GET /profiles/{id}/audio request; if the engine is still unavailable that path returns a precise "model not ready — finish setup / download a model" 503. - The all-Auto (empty-instruct) design is now saveable (vd_states still required). Adds tests/test_profile_design_save_decouple.py (top-level tests/, asyncio.run per test) covering: design save with model unavailable creates the row instead of 503-ing; all-Auto design is saveable; the pending sample materializes on first /audio request. Updates the unification spec (docs-sync). * fix(profiles): contain profile-audio paths under VOICES_DIR (CodeQL CWE-22) The lazy design-sample path was built as `os.path.join(VOICES_DIR, f"{profile_id}.wav")` / `os.path.join(VOICES_DIR, audio_file)` where profile_id is the request path param — CodeQL flagged 5 high-severity path-injection alerts (profiles.py + the taint flowing into archetypes.py's torchaudio save). Add `_safe_voice_path()` (basename + safe-char sanitise + realpath containment, mirroring core.config.dub_seg_path) and route both the read and lazy-render sites through it; a traversal id now 404s instead of escaping VOICES_DIR. Regression test covers the containment guard. * fix(profiles): use CodeQL-recognized path-injection guards (CWE-22) The previous `_safe_voice_path()` helper was correct (basename + realpath containment) but CodeQL's taint tracking didn't propagate the barrier through the function return, so the 5 path-injection alerts persisted. Switch to guards CodeQL recognizes, inline at each file-op site: - validate `profile_id` against the generated-id charset (`[A-Za-z0-9_-]{1,64}`) with `re.fullmatch` and 404 on mismatch (covers the `f"{profile_id}.wav"` render path); - read only `os.path.join(VOICES_DIR, os.path.basename(name))` so a stored/derived filename is always a direct child of VOICES_DIR (covers the read + the taint flowing into archetypes.py's torchaudio save). Drop the helper. Test now asserts a traversal/separator/NUL profile_id 404s at the guard. Same security property, recognized by CodeQL. * fix(profiles): inline realpath+commonpath containment for CodeQL (CWE-22) CodeQL didn't recognize the earlier sanitizers — neither the helper (barrier hidden behind a function return) nor os.path.basename / a cross-function regex guard cleared the 5 path-injection alerts. Use the canonical, CodeQL-recognized form INLINE at each file-op site: resolve the path with os.path.realpath (which collapses any `..`) and confirm os.path.commonpath((base, path)) == base before the read / the render, returning 404 / raising on escape. Same property the helper had, now in a shape CodeQL's taint tracking follows. Keeps the profile_id charset guard as defense-in-depth. * fix(profiles): route design-sample path through shared _voices_path guard (#476) The inline realpath+commonpath containment in get_profile_audio and _materialize_design_sample wasn't recognized by CodeQL as a path-injection sanitizer (5 new high-severity py/path-injection alerts at the file-op sites, incl. archetypes.py mkdir via the rendered Path). Both now reuse the existing _voices_path() helper, which applies the os.path.basename() barrier plus symlink-resolved containment — the same guard the consent endpoint uses and that CodeQL already accepts. Behavior is unchanged: the DB columns only ever hold bare {profile_id}.wav filenames, so basename() is a no-op here. Tests: tests/test_profile_design_save_decouple, test_profile_unification, test_profile_consent, test_archetype_blank_guard — 25 passed. --------- Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> docs(conventions): add "Fix quality" + "Keep main green" hard rules (#493) Owner-set 2026-06-16. (1) Fix issues properly/future-maintenance-proof — fix the whole class, add a regression test, harden against recurrence; extra effort, not extra verbosity. (2) A merge must never break main's CI — verify the full CI matrix (every .github/workflows/* AND deploy/Dockerfile) before landing, with explicit guidance that frontend/ is a bun workspace monorepo whose root bun.lock must be regenerated on any frontend/package.json change (Docker uses --frozen-lockfile; plain bun install in ci.yml tolerates drift). Motivated by the #485 bun.lock incident that reddened main's Docker workflow. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> fix(ui): clone popover/CTA clipping + textarea resize (#481, #476) (#489) Bug #481 — Clone "+Insert" popover was clipped offscreen and the script textarea couldn't be resized: - Apply the existing `.clone-panel--overflow-visible` helper to the script `.studio-panel` so the upward-opening popover escapes the panel's `overflow:auto` box instead of being shoved into its scroll region. - Cap the popover at `max-width: min(360px, calc(100vw - 16px))` so the 14-chip grid can never spill past the viewport edge. - Re-enable the textarea corner grip (`resize: vertical`, matching the base `textarea.input-base`) and lift the ⊕ Insert button off the bottom-right so it no longer physically covers the drag handle. Bug #476 — the design-mode "Synthesize Audio" CTA dropped below the fold on narrow shells: - Replace the raw `@media (max-width: 900px)` reflow rules with the app's shell-width classes (`.shell-narrow` / `.shell-mini`, set in App.jsx from `app-container.clientWidth`). The shell scales via `zoom`, so a viewport media query fired at the wrong threshold whenever `--ui-scale ≠ 1`. - When stacked, let `.studio-with-history__main` grow (drop its `overflow:hidden` clip) and pin the action bar `position: sticky; bottom: 0` so the Synthesize CTA stays on-screen. Pure CSS + one className; no component restructuring. Added a regression test guarding the shell-class reflow + sticky CTA against the viewport-`@media` anti-pattern. typecheck:ci clean; vitest 506/506 green. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> fix(dub): auto-assign per-speaker voices in multi-speaker dubbing (#486) (#490) Multi-speaker dubs detected speakers and built per-speaker clones (Voice dropdown showed "From Video → Speaker N"), but most segments stayed on "Default" voice and had to be set by hand — inconsistently across runs. Root cause: after diarization, dub_core stamped each long line (the default-on per-segment-ref path) with `auto-seg:{id}` as its profile_id. The dub editor's Voice <select> (and the Cast panel) only render `auto:` options, so an `auto-seg:` value matched no <option> and silently showed "Default". Short lines (<3s) fell through to `auto:{speaker}`, which DID render — hence "sometimes the cloned voice is picked". Fix: bind every segment to the UI-visible `auto:{speaker}` whenever its detected speaker has a clone; only fall back to `auto-seg:{id}` when the speaker has no per-speaker clone at all. The per-segment-ref quality win is preserved: dub_generate's `auto:` branch now transparently prefers THIS segment's own per-segment ref (segment_clones[seg_id]) when present, else the per-speaker clone. Manual overrides and the no-clone path are untouched; existing jobs that persisted `auto-seg:` ids still resolve. Tests: tests/test_dub_multispeaker_voice_486.py — assignment binds to auto:{speaker} (not auto-seg:), never clobbers manual overrides, falls back to auto-seg: only when the speaker has no clone; generate-time resolution prefers per-segment ref then per-speaker clone. Green alongside the existing dub generate/incremental/segmentation suites. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> fix(deps): re-sync root bun.lock after #485 (fixes main Docker red) (#492) * fix(deps): re-sync root bun.lock after #485 frontend floor bumps (main Docker red) #485 bumped ~25 dependency floors in `frontend/package.json` but didn't regenerate the repo-root `bun.lock` (this is a bun *workspace* monorepo — the lockfile lives at root and embeds the frontend member's ranges). The Docker workflow runs `bun install --frozen-lockfile`, which failed on the drift ("lockfile had changes, but lockfile is frozen") — turning main red on commit4bcbc74. `ci.yml` uses a plain `bun install`, so it tolerated the drift and went green, which is why only Docker caught it. Regenerate `bun.lock` so its embedded frontend snapshot matches the manifest; `bun install --frozen-lockfile` now passes (verified locally, bun 1.3.14, the same version Docker uses). Lockfile-only change. Follow-up (separate): switch `ci.yml`'s frontend `bun install` to `--frozen-lockfile` so this drift class fails fast in CI, not only in Docker. * ci: use --frozen-lockfile for frontend install so lockfile drift fails fast Recurrence-proofing for the #485 incident: ci.yml's plain `bun install` silently tolerated the root bun.lock drifting from frontend/package.json, so CI went green while only the Docker build (which already uses --frozen-lockfile) caught it and reddened main. Both frontend install steps now use --frozen-lockfile, so a package.json change that forgets to regenerate root bun.lock fails in CI fast. Verified `bun install --frozen-lockfile` passes from frontend/ against the re-synced lockfile (bun 1.3.14). --------- Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> chore(deps): conservative refresh — backend, UI, Tauri (#485) * chore(deps): refresh backend HTTP/cert/security leaf packages Conservative, targeted refresh (no blanket re-resolve). Bumps only low-risk leaf packages — yt-dlp 2026.3.17→2026.6.9 (extractor currency), aiohttp, requests, urllib3, idna, certifi, charset-normalizer, pillow (HTTP/cert/security). No major bumps, no downgrades, no transitive removals; the 91-package full `--upgrade` was rejected because it downgraded numpy/pandas/av and pulled a starlette 1.x major that broke WS route introspection. Full backend suite: 1674 passed. * chore(deps): bump UI deps to within-major latest `bun update --latest` floors raised to current within-major releases — react 19.2.7, react-dom 19.2.7, vite 8.0.16, tailwindcss/@tailwindcss/vite 4.3.1, @tanstack/react-query 5.101, @radix-ui/* minors, lucide-react 1.18, zustand 5.0.14, i18next 26.3.1, plus dev tooling (vitest 4.1.9, eslint 10.5, playwright 1.61, @tauri-apps/cli 2.11.2). Verified no major version crossings. typecheck:ci clean; vitest 503/503. * chore(deps): cargo update Tauri crates within range `cargo update` — 77 crates locked to latest semver-compatible versions (patch/ minor: bitflags, chrono, hyper, reqwest, regex, rustls-native-certs, etc.; one in-range 0.x bump global-hotkey 0.7→0.8). No Cargo.toml range changes. `cargo check` compiles clean (0 errors). --------- Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> fix(deps): bundle `openai` so Cinematic/LLM features work out of the box (#484) Cinematic dub refinement, glossary auto-extract, and LLM-based translation all `from openai import OpenAI` (services.llm_backend / translator / dub_translate / glossary), but `openai` was declared nowhere in pyproject — not in dependencies, not in any optional extra, and no setup script installed it. So a fresh `uv sync` never installed it, and these features were dead-on-arrival on every source install: picking Cinematic showed "Cinematic needs an LLM" even with Ollama running and correctly configured, because `OpenAICompatBackend.is_available()` returned "openai package missing". The UI's "pip install openai" hint is a trap on a managed venv — users (Discord report) installed it into system Python, not the app's `.venv`, so it still didn't take. Add `openai>=1.40` to dependencies (resolves to 2.41.1; verified the code's `OpenAI(...)` + `chat.completions.create(model=, messages=)` call shapes are unchanged in 2.x). Pure-Python, no native deps → identical on macOS/Windows/Linux (default-parity rule). Cinematic + any OpenAI-compatible endpoint (OpenAI, Ollama, LM Studio, vLLM) now work after `uv sync`, no manual package install. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> fix(settings): make in-app models dir authoritative over launcher-injected env (#480) (#483) Changing the model download location in Settings had no effect: after the prompted restart, new downloads still went to the old folder and "Effective location" stayed stuck on it. Two stores hold the models dir. The in-app Settings panel writes the new path to the durable per-user env file (`~/.config/omnivoice/env`, OMNIVOICE_CACHE_DIR), but the desktop launcher injects the OLD value from its own Tauri config into the backend's environment before startup — and main.py loaded the per-user file with `override=False`, so the launcher's stale value always won. main.py then maps OMNIVOICE_CACHE_DIR → HF_HOME/HF_HUB_CACHE/TORCH_HOME, pointing downloads at the old dir; `_effective_models_dir()` reads that live env, so the UI faithfully reported the old path as if the change had failed. Fix: load the per-user env file with override so it beats launcher-injected defaults — restoring this file's documented "values written here take effect on the next backend launch" contract. Centralized as `user_env.load_into_environ()` (the file is the in-app Settings source of truth) and called from main.py. Both keys this file can hold (OMNIVOICE_CACHE_DIR, HF_ENDPOINT) are the user's explicit Settings choice and should beat the launcher default, so the override is correct for both (this also fixes the same latent bug for a Settings-set HF mirror). HF_TOKEN isn't launcher-injected, so its behavior is unchanged. Follow-up (separate PR): add a Tauri `set_models_dir` command so the launcher's config.json stays in sync, covering the reset-to-default edge too. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> fix(asr): decode WhisperX audio via validated ffmpeg, not bare PATH lookup (#479) (#482) WhisperX transcription called `whisperx.load_audio()`, which shells out to a literal `"ffmpeg"` resolved against the OS PATH. On Windows that resolves to a WindowsApps alias stub or a corrupt/wrong-arch binary — passing `which` but exploding at spawn with `[WinError 193] %1 is not a valid Win32 application`. whisperx only catches `CalledProcessError`, so the spawn-time `OSError` escaped and the dub/batch path reported the opaque "Transcription produced no segments". #377 added ffmpeg validation but only for the dub-export path; the transcription path never went through the validated resolver. Since WhisperX is a default ASR engine, this is a P0 platform-parity break (works on mac/Linux, fails on Windows). Fix: decode the audio ourselves in `WhisperXBackend.transcribe` via `find_ffmpeg()` (which `-version`-probes each candidate and returns the bundled imageio-ffmpeg / Tauri sidecar) and hand WhisperX the array — bypassing the bare PATH lookup entirely. This is more robust than a PATH-prepend, which couldn't fix the imageio case (its binary is named `ffmpeg-<plat>-vN.exe`, not `ffmpeg`). If no runnable ffmpeg exists, raise a clear, locale-independent error instead of "no segments". Fixes both the dub and batch transcription paths. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> fix(translate): guess source language from text instead of defaulting to "en" (#478) When neither the request nor the job carries a detected source language, _resolve_source_lang() silently fell back to "en". For non-English audio (e.g. Korean) this produced en -> en, which has no Argos package and failed every segment — even though WhisperX had detected the language correctly (e.g. "Detected language: ko (0.98)"). Add a last-resort script-based guess (ko/ja/zh/ru/ar) from the segment text so the bare "en" fallback no longer breaks non-English dubbing. Co-authored-by: stronghamjji <289942360+stronghamjji@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> fix(bootstrap): stall watchdog so a stuck backend isn't a buttonless dead-end (#474) (#475) The entire main UI is gated behind `bootstrapStage === 'ready'` (App.jsx) — until the Python backend reports ready, only the BootstrapSplash shows. If the backend hangs in a non-terminal stage and never reaches ready (e.g. a failed from-source backend spawn on Windows: uv/Python not on PATH), useBootstrapStage polled forever, trapping the user on a splash with no Settings / Start / Clone / Extract buttons — which is exactly what #474 reports (verified: no backend-startup regression; every startup-imported router imports cleanly). - useBootstrapStage: add a per-stage stall watchdog. Track when (stage,message) last changed; if a non-terminal stage sits past its budget (installing_deps gets 20 min since it legitimately runs 5–10 min; everything else 120 s), flip to the existing `failed` state — which already surfaces actionable hints, the live log panel, and Retry / Clean-&-Retry. Any change resets the clock, so a live install never trips it. - detectHints + bootstrap.hint_stuck: a targeted hint for the stuck case (run `uv sync`, check uv/Python on PATH, read the log / Settings → Logs). - CONTRIBUTING.md: document `bun run desktop-prod` (the prod desktop command the reporter typo'd as `desktop=prod`), note both desktop scripts auto-run `uv sync` + start the backend, and add a "stuck on the setup splash" pointer. No backend code change. Frontend suite green (503); CJK guard green. feat(audiobook): durable crash-resume for interrupted longform renders (#470) * feat(audiobook): durable crash-resume for interrupted longform renders Chapter WAVs were already content-addressed (a re-run reused finished chapters), but resume only worked if the user could re-submit the EXACT script — impossible for Stories, whose plan is compiled from cast+lines. This persists the plan itself so an interrupted render is resumable without the original input. - New services/longform_resume.py (pure file/JSON): on render start, write a resume.json manifest (compiled plan + render params + title) into the job work dir, atomically; clear it on successful completion. read/has/clear/build helpers, schema-versioned (a foreign/corrupt manifest is ignored, never resumed). - _render_longform_sse: accepts an optional job_id + resume flag (resume reuses the original job row + cached chapters instead of creating a new one); writes the manifest at start, clears it on done. Both front doors (/audiobook, /longform/render) unchanged for callers. - GET /audiobook/jobs — lists interrupted renders (running/failed longform jobs that still have a manifest; a job left "running" across an app restart is interrupted by definition), with title + total/done chapter counts for the UI. - POST /audiobook/resume/{job_id} — rebuilds the plan from the manifest and replays _render_longform_sse under the original job_id; the content-addressed cache makes finished chapters instant, so only the unrendered ones synthesize. 404 on unknown id / missing manifest. Resume durability is best-effort — a manifest failure never blocks the render. The resume UI affordance is a follow-up (the endpoints are ready for it). Tests: tests/test_longform_resume.py (7, pure manifest round-trip / version & corrupt rejection / atomic write — monkeypatches OUTPUTS_DIR, no global core.config stub so the shared tests/ session isn't polluted) + backend/tests/test_audiobook_resume_api.py (6, config-stub: jobs-list with progress, failed-included, done/manifestless/non-longform excluded, resume 404s). 13 passed. CJK green. Stale module docstring updated. * fix(audiobook): confine resume paths — py/path-injection (CodeQL) + quality The default-setup CodeQL (security-and-quality suite) flagged the crash-resume work: longform_resume built filesystem paths from job_id, which on the POST /audiobook/resume/{job_id} endpoint is a request-supplied path param → py/path-injection (10 high-severity sinks: open/replace/remove/makedirs/isfile). - longform_resume.work_dir now confines like profiles._voices_path: reject an unknown job_type or an id that isn't a bare safe token (^[A-Za-z0-9_-]{1,64}$), then realpath + startswith(OUTPUTS_DIR + os.sep) — a crafted id (`../`, NUL, separators) can never escape OUTPUTS_DIR. Returns None on violation; all callers (manifest_path/read/write/clear/has) degrade gracefully. - The resume endpoint also gates the path-param id up front (404 on a bad token) — barrier at the source as well as the sink. Also cleared the quality alerts the same diff introduced: - py/repeated-import: the 4 inline `from services import longform_resume` calls collapse to one module-top import (it's pure, no torch). - py/empty-except: the best-effort manifest blocks now logger.debug instead of a bare `pass`. 13 resume tests still pass; all job ids in tests are safe tokens. * fix(audiobook): sanitize resume job_id at the source (path + log injection) The first CodeQL pass wasn't enough: resume made job_id request-controlled, so it tainted not just the manifest paths but the EXISTING work-dir join and the progress log lines too (py/path-injection + py/log-injection, ~14 alerts). Fix at the source so the whole dataflow is clean: - _render_longform_sse strips job_id to a safe token (`re.sub` removing anything but [A-Za-z0-9_-], capped 64) right after it's resolved — no path separator, no CR/LF can survive, whether the id came from the resume path param or a fresh uuid. - The work dir now routes through longform_resume.work_dir, which adds the proven os.path.basename(seg)==seg barrier (the shape CodeQL accepts in _voices_path) on top of the realpath+startswith confinement — so the join and every path derived from it (meta/concat/out) is sanitized. - The best-effort manifest-write log no longer interpolates the raw exception (uses exc_info); clear_manifest's OSError handler returns instead of bare pass (py/empty-except). 13 resume tests still pass. * fix(audiobook): launder resume job_id via trusted FS scan (CodeQL path/log-injection) The custom realpath/regex barriers weren't in CodeQL's recognized sanitizer set, so the request-supplied resume job_id kept tainting the work-dir/manifest paths and the progress logs. Switch to the pattern CodeQL does accept — launder the id through a trusted filesystem enumeration: - longform_resume.scan_resumable() lists resumable jobs by scanning OUTPUTS_DIR for <type>_<id>/resume.json; every id it returns is sourced from os.listdir (never request input). - POST /audiobook/resume/{job_id} now only resumes an id that scan_resumable() reports (membership match), and uses the (job_type, job_id) pair FROM that trusted list for everything downstream — so nothing request-controlled reaches a filesystem path or a log line. - GET /audiobook/jobs lists from scan_resumable() too (filesystem-sourced ids). work_dir keeps the realpath+startswith+basename confinement as genuine defense; the render path's job_id is now always either a fresh uuid or a laundered id. 13 resume tests still pass. * fix(audiobook): exact-match allowlist on the work-dir name (CodeQL path-injection) The remaining 4 path-injection alerts were inside work_dir: I validated job_id with an anchored regex but then joined a DIFFERENT f-string (`{job_type}_{job_id}`), so CodeQL didn't carry the sanitization to the joined value. Mirror the pattern the repo's _safe_cover_path uses (which CodeQL accepts): validate the WHOLE joined component against an exact-match allowlist regex (_SAFE_SEG_RE), then confine with os.path.commonpath containment (the recognized barrier) instead of startswith. 13 resume tests still pass. * fix(audiobook): basename-sanitize the work-dir name for CodeQL path-injection The exact-match regex alone wasn't credited; route the joined value through os.path.basename() first — the sanitizer CodeQL recognizes (mirrors _safe_cover_path) — then the regex + commonpath. Functionally identical (no separator in the name) but clears the 4 remaining alerts. 13 tests pass. * fix(audiobook): allow-list membership guard launders resume job_id (CodeQL) The next(... if pair[1]==job_id) comparison-select didn't sanitize for CodeQL. Build a dict of resumable ids from the trusted scan and gate with 'if job_id not in resumable' — the membership barrier CodeQL recognizes — then use job_id directly downstream. 13 tests pass. * fix(audiobook): eliminate request→path flow in resume (definitive CodeQL fix) Five rounds of recognized path-injection barriers (regex, basename, exact-match, commonpath, membership-guard) still left CodeQL flagging the resume job_id → work-dir/manifest/log flow. Remove the flow entirely instead of guarding it: - scan_resumable() now returns {job_type, job_id, manifest_path} where manifest_path is built from the os.listdir dir name (trusted), plus load_manifest_file(path) / discard_manifest_file(path) that operate on those trusted paths. The request job_id is used ONLY to *select* a scan entry, never to build a path. - POST /audiobook/resume/{job_id} reads the manifest via the trusted scan path and renders under a FRESH server uuid (job_id=None). The chapter cache is content-addressed (keyed by chapter content, not the job id), so finished chapters still hit instantly — resume works, but the request's id never names a work dir, output file, or log line. - The interrupted job's manifest is discarded (trusted path) once the fresh-id resume kicks off, so it stops showing as resumable. Net: no request-controlled value reaches any file operation or log on the render path (job_id there is always a server uuid). work_dir keeps its confinement barriers as defence-in-depth. 13 resume tests pass. perf(omnivoice): cache voice-clone prompt embeddings (#427) (#473) Every cloned generation re-encoded the reference audio from scratch — a fixed per-request latency that compounds on batch / long-form / dataset workloads that reuse one saved voice across many calls. The OmniVoice model already exposes the fast path (create_voice_clone_prompt → VoiceClonePrompt, generate(voice_clone_prompt=)); the Studio backend just wasn't using it. OmniVoiceBackend.generate now: - builds a VoiceClonePrompt once per reference and caches it (bounded LRU, max 8, keyed by ref path + mtime + ref_text; thread-safe — generation runs in a GPU thread pool), then passes voice_clone_prompt= to skip the re-encode; - falls back to the inline ref_audio/ref_text path on ANY cache miss or error, so output is identical either way (the model documents the two as equivalent) — this is purely a latency optimization, never a behaviour change; - the design/instruct path (no ref_audio) is untouched. - unload() clears the cache so a flush / engine-switch frees the prompt tensors. tests/test_clone_prompt_cache.py: 6 cases (encode-once-then-hit, ref_text + mtime invalidation, LRU eviction at the cap, encode-failure → None fallback, clear). 6 passed. Closes #427. feat(stories): global reading-speed control (#415) (#472) The Stories editor only had a per-track speed slider; long scripts had no way to set one speed for the whole thing. Add a global speed control that applies to every line WITHOUT its own per-track override (the per-track slider still wins). - storyToSpans(tracks, cast, globalSpeed): per-track speed wins, else the global speed, else engine default. 1.0× (and null) is treated as "no override" so a resting control never stamps an explicit speed on every span. Builds on the #27 default_speed plumbing already in the canonical parser. - StoriesEditor: a global speed slider in the toolbar (0.5–2.0×, with reset), persisted to localStorage (UI preference — no project-state/slice migration). - i18n: stories.global_speed / global_speed_hint in en.json. Tests: storyToSpans.test.js +2 (global applies to un-overridden lines, per-track wins; 1.0×/null/default-arg = no override). 17 file / 120 suite pass; CJK green. Closes #415. fix: actionable errors for non-executable engine binary (#437) + unreachable backend (#438/#454/#466) (#471) Two reliability bugs from open issues, both first-run papercuts where the error told the user the wrong thing. #437 — `[Errno 13] Permission denied: bin/omnivoice-tts-linux-x86_64`: a git clone / zip extract on POSIX can drop the bundled binary's execute bit. It only surfaced at spawn time, and the generic synth handler then mislabeled it as "ran out of memory" and told the user to flush the model. - omnivoice_gguf.is_available() now self-heals: after the SHA check confirms the binary is the right file, it adds +x (best-effort) on POSIX; if it can't, it returns a clear "isn't executable — run chmod +x <path>" message instead of a spawn-time crash. No-op on Windows. - generation.py classifies PermissionError / EACCES / "Permission denied" as its own case ("a bundled binary lost its execute bit — reinstall or chmod +x"), so it never again masquerades as OOM. #438/#454/#466 — bare "Failed to fetch" / "NetworkError": when the local backend is still starting, crashed, or the dev server dropped, fetch() throws a TypeError that propagated raw to the user. - client.ts apiFetch now catches the thrown fetch and raises an ApiError with an actionable message ("Can't reach the local OmniVoice backend — it may still be starting up… restart the app or check Settings → Logs"), status:0 to mark a transport failure vs an HTTP error. Tests: client.test.ts +1 (thrown fetch → ApiError status 0 + actionable text); 3 pass. CJK guard green. test(settings): de-flake the at-rest-encryption assertion (#469) test_stored_value_is_encrypted_not_plaintext asserted `"hf_" not in raw`, but the stored value is Fernet URL-safe base64 whose alphabet includes `_`, so a random ciphertext occasionally contains the substring `hf_` by chance — a false failure that bit unrelated PRs on CI (~1 in N runs). Replace the 3-char-prefix substring check (weak AND flaky) with stronger, deterministic guarantees: - the full token is absent from the raw column (kept), - a 16-char leading chunk is absent (no partial leak; 62^16 ≈ never collides), - and the value round-trips via get_hf_token() — proving it's genuinely encrypted, not merely absent/empty. Verified non-flaky: the target test passed 8/8 consecutive runs. feat(longform): JS canonical port + frontend convergence (#27 slice B) (#467) Mechanically-mirrored JS twin of the Python parser, verified byte-for-byte against the shared golden corpus. See PR body. docs(longform): retire the hand-sync comment now the corpus enforces parity (#27 slice C) (#468) The SSML-lite client port header said "keep in sync with backend/services/ssml_lite.py" — a manual contract with no test behind it. After #27 the canonical longform grammar (incl. SSML-lite via the longformParser.js → storyToSpans path) is asserted byte-for-byte against the Python parser through the shared golden corpus (tests/fixtures/longform_parser_cases.json), so drift between the two SSML impls now fails CI. Update the comment to point at that enforcement. No user-facing docs document the marker dialect (verified by grep: only the internal competitive-analysis planning doc references it), so no docs-sync update is required for the converged behaviour. feat(longform): canonical Python parser + golden corpus (#27 slice A) (#465) The longform marker dialect (# heading / [voice:] / [pause] / SSML-lite) was parsed by three independent code paths that already disagreed (client vs server on [pause] units, [voice:] empty, H1-only chapters). This lands the single canonical Python parser; the JS port + cross-impl test follow in slice B. - New backend/services/longform_parser.py — parse_script_to_spans(text, *, default_voice, default_speed) + _parse_chapter_body (the reusable voice→pause →SSML layering the JS twin mirrors). Moves the H1/voice regexes verbatim from audiobook.py (already CodeQL-cleared), reuses parse_pause_markers + ssml_lite unchanged. Coerces None→"" and normalizes CRLF/CR→LF at entry (cross-platform parity so Windows-authored scripts never carry a stray \r). Adds default_speed plumbing (inline SSML speed overrides the per-line default). - audiobook.py: parse_audiobook_script is now a thin wrapper that wraps the canonical span dicts in Span/Chapter/AudiobookPlan — public return type and .to_dict() shape unchanged, all four router call sites untouched. Deleted _parse_spans / _HEADING_RE / _VOICE_RE and the now-dead `import re` + parse_pause_markers import. - tests/fixtures/longform_parser_cases.json — 78-case golden corpus (≥40 required) covering §A–I: H1-only chapters (H2–H6 + `# ` no-title → body), the full pause dialect incl. the NO-MATCH boundary, banker's-rounding ties ([pause 0.5]→0, [pause 1.5]→2), [voice:] empty→default, [voice:[nested]] literal, SSML nesting/spell/unknown-tag, speed override, CRLF, combined precedence. Generated from actual parser output (the truth the JS port must match). - tests/test_longform_parser.py — parametrized over the corpus + None-input + ReDoS-linearity (5000× repeats < 1 s). 130 passed (corpus + test_audiobook + test_pause_markers + test_ssml_lite all green); CJK guard green. docs(persona): document the .ovsvoice portable format (#29 slice D) (#464) - docs/persona-format.md: export (privacy/include-reference, watermarked preview), import (consent/verification non-forgeability rule), the ZIP layout table, SPDX-license semantics (metadata only), and the local-first / zero- network guarantee. Notes legacy .omnivoice compatibility. - CHANGELOG.md: [Unreleased] → Added entry for portable personas. Satisfies the docs-sync hard rule for the new bundle format. feat(persona): export/import UI for .ovsvoice bundles (#29 slice C) (#462) * feat(persona): .ovsvoice build/parse core + embed_watermark(force=) (#29 slice A) Extends the merged persona-bundle nucleus (constants, normalize_spdx, build_manifest, build_consent_json) with the model-coupled core that the export/import router (next slice) will sit on: - `build_persona_bundle(profile, *, license_spdx, tags, include_reference, embed_fn, …)` → assembles the .ovsvoice ZIP in memory: a watermarked preview.wav (24 kHz mono 16-bit, downmixed + resampled + trimmed ≤8 s), manifest.json, a legacy-shaped metadata.json (so an older OmniVoice can still import the ref audio), optional consent.json, and the raw ref/locked/consent members unless include_reference=False (privacy / preview-only, A12). Raises NoPreviewSource (router → 503) when no source clip is readable (A2-A5). - `parse_persona_bundle(bytes)` → validates the ZIP, prefers manifest.json and falls back to legacy metadata.json, resolves audio members by prefix (last-wins, B9; member names never build paths — zip-slip safe), normalizes the SPDX id, flags preview-only / future-schema_version. Raises BundleError(400|413) for B1-B11. No DB, no file writes. - `ParsedPersona` dataclass with `extract_member(prefix, dest_path)` — the router derives dest_path from the server-generated id, never the member name. - `embed_watermark(..., *, force=False)`: keyword-only flag that bypasses the user's invisible-watermark preference for the mandatory persona preview, but still no-ops without AudioSeal. All existing positional call sites are unchanged (default force=False) — default cross-platform behaviour identical. All heavy imports (torch/torchaudio/watermark/audio_io) are lazy so the module stays model-free at collection (avoids the local torch/Triton segfault). tests/test_persona_bundle.py: +31 cases — parse validation (manifest/legacy selection, preview-only, future-schema, missing/malformed/no-audio → 400, oversize → 413, bad-SPDX normalize, last-wins dup, advisory consent), build round-trip (identity fields, metadata sibling, no-source → NoPreviewSource, include_reference=False, stereo/off-rate downmix+resample), and the force= unit (D1/D3). 25 pure cases pass locally; the 6 torchaudio-coupled cases run on CI (local torch+pytest segfault is pre-existing). CJK guard green. * feat(persona): /personas export·import·inspect router + wiring (#29 slice B) Thin HTTP layer over the persona_bundle service (slice A), registered in main.py next to the legacy marketplace router: - POST /personas/export/{id} → builds the .ovsvoice off the event loop (run_in_executor) and streams it (application/zip, .ovsvoice filename; empty name → persona_<id>). 404 when the profile is missing; NoPreviewSource → 503 (no readable source audio); any other build error → 503 with a generic message (no raw exception text in the body). - POST /personas/import → parse (BundleError → its HTTP status), extract audio members to server-named files ({id}{ext}/{id}_locked{ext}/{id}_consent{ext} — never the member name, zip-slip safe via profiles._voices_path), 17-column INSERT (legacy 13 + the 4 consent columns), event_bus emit after commit. Verified-own-voice is granted ONLY with a real recording ≥ floor AND non-empty consent_text AND consent.json present (forgery guard, B12-B16). Rollback: every written file is deleted on any extraction/INSERT failure; id-collision retries once (renaming the on-disk files to the new id). Accepts legacy .omnivoice too (case-insensitive extension guard). - POST /personas/inspect → manifest + consent summary with NO DB row and NO file extracted (import-preview UI). backend/tests/test_personas_api.py: 13 cases (config-stub pattern → mounts only the router, no main/torch import) — export 404; import bad-ext/non-zip/missing- manifest 400; round-trip row+file under server name; case-insensitive ext; forgery-unverified; verified-with-recording; short-recording-unverified; preview-only-as-ref; legacy .omnivoice; inspect no-write + consent summary. 13 passed locally. CJK guard green. * feat(persona): export/import UI for .ovsvoice bundles (#29 slice C) Wires the persona endpoints (slice B) into the voice UI: - api/profiles.ts: exportPersona (blob download, builds the license/tags/ include_reference query), importPersona, inspectPersona; PersonaImportResult + PersonaBundleMeta types in types.ts. - VoiceProfile.jsx: "Export persona" toolbar action + a privacy "Include voice clip" checkbox (default ON; off → preview-only bundle, no raw reference clip). Triggers a blob download named <voice>.ovsvoice; distinct toast for the 503 no-audio case vs a generic failure. - VoiceGallery.jsx (My Imports): an Import-persona button next to Upload, accept ".ovsvoice,.omnivoice", that POSTs to /personas/import and refreshes the voice list. Surfaces the 413 too-large case distinctly; flags an unverified import in the success message. - i18n: voice_profile.persona_* + gallery.persona_*/import_persona keys in en.json only (fallbackLng=en covers other locales). Tests: frontend/src/api/profiles.persona.test.ts (7 cases — export query construction incl. include_reference omitted-when-true, non-ok → throws status, blob passthrough; import/inspect post FormData to the right path). Full suite 408 passing; en.json valid; CJK guard green. No new tsc errors in the changed files (pre-existing errors elsewhere are unaffected). feat(persona): /personas export·import·inspect router + wiring (#29 slice B) (#461) * feat(persona): .ovsvoice build/parse core + embed_watermark(force=) (#29 slice A) Extends the merged persona-bundle nucleus (constants, normalize_spdx, build_manifest, build_consent_json) with the model-coupled core that the export/import router (next slice) will sit on: - `build_persona_bundle(profile, *, license_spdx, tags, include_reference, embed_fn, …)` → assembles the .ovsvoice ZIP in memory: a watermarked preview.wav (24 kHz mono 16-bit, downmixed + resampled + trimmed ≤8 s), manifest.json, a legacy-shaped metadata.json (so an older OmniVoice can still import the ref audio), optional consent.json, and the raw ref/locked/consent members unless include_reference=False (privacy / preview-only, A12). Raises NoPreviewSource (router → 503) when no source clip is readable (A2-A5). - `parse_persona_bundle(bytes)` → validates the ZIP, prefers manifest.json and falls back to legacy metadata.json, resolves audio members by prefix (last-wins, B9; member names never build paths — zip-slip safe), normalizes the SPDX id, flags preview-only / future-schema_version. Raises BundleError(400|413) for B1-B11. No DB, no file writes. - `ParsedPersona` dataclass with `extract_member(prefix, dest_path)` — the router derives dest_path from the server-generated id, never the member name. - `embed_watermark(..., *, force=False)`: keyword-only flag that bypasses the user's invisible-watermark preference for the mandatory persona preview, but still no-ops without AudioSeal. All existing positional call sites are unchanged (default force=False) — default cross-platform behaviour identical. All heavy imports (torch/torchaudio/watermark/audio_io) are lazy so the module stays model-free at collection (avoids the local torch/Triton segfault). tests/test_persona_bundle.py: +31 cases — parse validation (manifest/legacy selection, preview-only, future-schema, missing/malformed/no-audio → 400, oversize → 413, bad-SPDX normalize, last-wins dup, advisory consent), build round-trip (identity fields, metadata sibling, no-source → NoPreviewSource, include_reference=False, stereo/off-rate downmix+resample), and the force= unit (D1/D3). 25 pure cases pass locally; the 6 torchaudio-coupled cases run on CI (local torch+pytest segfault is pre-existing). CJK guard green. * feat(persona): /personas export·import·inspect router + wiring (#29 slice B) Thin HTTP layer over the persona_bundle service (slice A), registered in main.py next to the legacy marketplace router: - POST /personas/export/{id} → builds the .ovsvoice off the event loop (run_in_executor) and streams it (application/zip, .ovsvoice filename; empty name → persona_<id>). 404 when the profile is missing; NoPreviewSource → 503 (no readable source audio); any other build error → 503 with a generic message (no raw exception text in the body). - POST /personas/import → parse (BundleError → its HTTP status), extract audio members to server-named files ({id}{ext}/{id}_locked{ext}/{id}_consent{ext} — never the member name, zip-slip safe via profiles._voices_path), 17-column INSERT (legacy 13 + the 4 consent columns), event_bus emit after commit. Verified-own-voice is granted ONLY with a real recording ≥ floor AND non-empty consent_text AND consent.json present (forgery guard, B12-B16). Rollback: every written file is deleted on any extraction/INSERT failure; id-collision retries once (renaming the on-disk files to the new id). Accepts legacy .omnivoice too (case-insensitive extension guard). - POST /personas/inspect → manifest + consent summary with NO DB row and NO file extracted (import-preview UI). backend/tests/test_personas_api.py: 13 cases (config-stub pattern → mounts only the router, no main/torch import) — export 404; import bad-ext/non-zip/missing- manifest 400; round-trip row+file under server name; case-insensitive ext; forgery-unverified; verified-with-recording; short-recording-unverified; preview-only-as-ref; legacy .omnivoice; inspect no-write + consent summary. 13 passed locally. CJK guard green. feat(persona): .ovsvoice build/parse core + embed_watermark(force=) (#29 slice A) (#460) Extends the merged persona-bundle nucleus (constants, normalize_spdx, build_manifest, build_consent_json) with the model-coupled core that the export/import router (next slice) will sit on: - `build_persona_bundle(profile, *, license_spdx, tags, include_reference, embed_fn, …)` → assembles the .ovsvoice ZIP in memory: a watermarked preview.wav (24 kHz mono 16-bit, downmixed + resampled + trimmed ≤8 s), manifest.json, a legacy-shaped metadata.json (so an older OmniVoice can still import the ref audio), optional consent.json, and the raw ref/locked/consent members unless include_reference=False (privacy / preview-only, A12). Raises NoPreviewSource (router → 503) when no source clip is readable (A2-A5). - `parse_persona_bundle(bytes)` → validates the ZIP, prefers manifest.json and falls back to legacy metadata.json, resolves audio members by prefix (last-wins, B9; member names never build paths — zip-slip safe), normalizes the SPDX id, flags preview-only / future-schema_version. Raises BundleError(400|413) for B1-B11. No DB, no file writes. - `ParsedPersona` dataclass with `extract_member(prefix, dest_path)` — the router derives dest_path from the server-generated id, never the member name. - `embed_watermark(..., *, force=False)`: keyword-only flag that bypasses the user's invisible-watermark preference for the mandatory persona preview, but still no-ops without AudioSeal. All existing positional call sites are unchanged (default force=False) — default cross-platform behaviour identical. All heavy imports (torch/torchaudio/watermark/audio_io) are lazy so the module stays model-free at collection (avoids the local torch/Triton segfault). tests/test_persona_bundle.py: +31 cases — parse validation (manifest/legacy selection, preview-only, future-schema, missing/malformed/no-audio → 400, oversize → 413, bad-SPDX normalize, last-wins dup, advisory consent), build round-trip (identity fields, metadata sibling, no-source → NoPreviewSource, include_reference=False, stereo/off-rate downmix+resample), and the force= unit (D1/D3). 25 pure cases pass locally; the 6 torchaudio-coupled cases run on CI (local torch+pytest segfault is pre-existing). CJK guard green. feat(audiobook): PDF ingest for /audiobook/import (ebook-in core value) (#459) The audiobook importer accepted .txt/.md/.epub but not PDF — the single most common "ebook in" format. Add a pure `pdf_to_chapter_script(data)` that extracts the text layer page-by-page and runs it through the existing chapterizer, so PDFs land in the same `# Heading` + body grammar EPUB and plaintext already produce (one front door onto the unchanged render pipeline). - Dep: `pypdf>=4.0` — pure-Python, MIT, zero native deps, so PDF import behaves identically on macOS/Windows/Linux (default-feature cross-platform rule). EPUB + plaintext stay stdlib-only; only PDF needs a real parser. - Robustness, surfaced as actionable 400s rather than silent empty imports: corrupt file, password-protected (empty-password decrypt attempted first), scanned/image-only (no text layer → clear "scanned PDF" message), and a page-count ceiling. A single unparseable page is skipped, not fatal. - Route: `.pdf` branch in audiobook_import; frontend accept filter + api-client doc updated to `.txt,.md,.epub,.pdf`. tests/test_longform_import.py: 5 PDF cases (extract+chapterize, no-marker single chapter, corrupt, image-only, page-cap) using a hand-built in-memory PDF — no PDF-authoring test dep, mirroring the in-memory-EPUB approach. 16 passed; frontend suite 401; CJK guard green. feat(dub): wire second-pass timing QC into the dub editor UI (#458) The Wave 3.3 QC backend was complete but unreachable from the UI: the `POST /dub/qc/{job_id}` route (re-recognizes the dubbed audio, scores per-line drift vs the target text, annotates segments with qc_drift/qc_flagged/ qc_recognized/qc_measured_start-end), the `dubQc()` API client, and the DubSegmentRow "Verify" badge all existed — but nothing ever called the route, so the badge never lit and the measured timings were never surfaced. Add a "Verify dub timing" action to the dub editor header (shown once dubStep === 'done'): - Calls `dubQc(jobId, lang)` for the currently-previewed language. - Merges the returned per-segment scores back onto dubSegments by id, so flagged lines light their re-listen badge and carry the measured onsets. - Toast summary: "{flagged} of {total} lines may need a re-listen", or a clean-pass success when nothing drifted. Loading + error states handled; non-destructive (generated text untouched). i18n: dub.qc_btn / qc_running / qc_result / qc_clean / qc_failed in en.json (fallbackLng=en covers other locales). Frontend suite green (401). feat(capture): opt-in LLM refinement on REST /transcribe (parity with live dictation) (#457) The live-dictation socket (capture_ws) already runs the final transcript through the configured local LLM (disfluency/self-correction/punctuation cleanup, Wave 2.1). The REST /transcribe endpoint — the MCP / CLI / file-upload surface — only did the always-on hallucination-loop collapse, so agentic and batch callers couldn't get the same cleaned output. Add an opt-in `refine` form flag that runs the identical `maybe_refine` pipeline off-thread: - OFF by default → existing MCP/CLI callers keep raw-only output and pay no LLM latency (backward-compatible). - Honours the user's Settings → Dictation-refinement config and silently passes through when no LLM backend is configured (cross-platform default parity — identical no-op everywhere with no LLM). - Raw `text` is always returned; `refined_text` is added only when the LLM actually changed the text — same contract the socket emits. tests/test_capture_refine.py: 13 cases — flag-off no-call, refined_text on change, no-op/identical omission, and flag parsing. maybe_refine is patched at its source module since the handler imports it lazily. chore(issues): structured GitHub Issue Forms (bug / install / feature) + config (#456) Replace the two flat markdown templates with validated YAML Issue Forms and a chooser config, so reports arrive with the diagnostic fields triage actually needs and "how do I…" traffic routes to chat instead. - `bug_report.yml` — dup-search + latest-version checkboxes; required what/repro/expected; OS / install-method / version / compute-device dropdowns (incl. ROCm + XPU); active-engine; logs (render: text) with the diagnostic- bundle + `--diagnose` tip up top. - `install_problem.yml` — NEW, for the "first-run that just works" core value: a failure-stage dropdown (launch / uv-bootstrap / model-download / engine- install / first-synth), required error + OS/install/version, and a network-conditions dropdown (proxy / restricted-region / offline) since restricted networks are a known bootstrap failure mode. - `feature_request.yml` — problem/solution/alternatives + an Area dropdown, with a local-first/cross-platform constraints note so proposals fit. - `config.yml` — `blank_issues_enabled: false`; contact links to Discord, Discussions, and the private security policy. Removes bug_report.md / feature_request.md (superseded). Forms validated (yaml parse); SECURITY.md backs the security link; CJK guard green. feat(longform): two-pass loudnorm measure orchestrator + wiring (#28 slice 2) (#455) * feat(longform): two-pass loudnorm measure orchestrator + wiring (#28 slice 2) Completes accurate ACX/podcast mastering end-to-end (builds on the pure builders from #28 slice 1). - `services/loudness.py` — `measure_loudness(ffmpeg, concat, preset, *, job_id)`: runs ffmpeg's measure pass, parses the loudnorm JSON → MeasuredLoudness. **Never raises** — skip / non-zero rc / rc None / asyncio.TimeoutError / spawn OSError / empty or unparseable stderr / silent program all WARN + return None → single-pass fallback (a slow/broken measure degrades the master, never aborts the render). Logs rc + a static message only, never the raw stderr (path-safe / local-first). UTF-8 decode with replacement (Windows-cp safe). - `_render_longform_sse` (audiobook.py): between the concat write and the mux, when `loudness` is a known preset (acx/podcast; same `.lower()`/no-strip gate as the builders) → emit a `mastering` event, measure, and pass `measured` into `build_render_cmd` (two-pass apply; `None` → single-pass). `done` gains a `loudness` block {preset, target_i, target_tp, two_pass, measured_i} ONLY for a requested preset — off/None paths keep the byte-identical legacy `done` shape. Both front doors (/audiobook + /longform/render) get it via the shared generator. Chapter cache key is deliberately untouched (loudness-agnostic → acx/off reuse the same cached WAVs; no re-render, no cache-layout break). Tests: `test_loudness.py` (14 — happy fixture, skip-without-spawn for off/ unknown/whitespace/None, non-zero/None rc, timeout-not-propagated, OSError, empty/unparseable stderr, non-UTF-8 stderr, job_id+argv forwarding) + 2 e2e cases (mastering event + done.loudness present for acx; absent for off). Orch tests run locally (stubbed run_ffmpeg, no torch); e2e on CI. * fix(loudness): lazy-import run_ffmpeg so the measure stub survives sys.modules purges test_loudness monkeypatched services.loudness.run_ffmpeg, but the route-shape fresh_app fixture purges services.* from sys.modules, so under the full-suite ordering the patch missed the re-imported module → real ffmpeg ran → 3 failures. Lazy-import run_ffmpeg inside measure_loudness and patch it at its source (services.ffmpeg_utils.run_ffmpeg) so the stub is always picked up at call time. Verified by running the purging suite + test_loudness together (31 pass). --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> feat(persona): .ovsvoice manifest + SPDX + consent core (#29 / parity §R3 G1, pure) (#453) The model-free nucleus of the portable .ovsvoice persona-bundle format: format constants, SPDX normalization, and the manifest/consent builders — all pure (no torch, no I/O), fully locally testable. The audio preview + ZIP pack/unpack + watermark `force=` param + router + frontend are follow-on slices. - Constants: OVSVOICE_FORMAT/SCHEMA_VERSION, MAX_BUNDLE_BYTES (100 MB), DEFAULT_LICENSE (`LicenseRef-OmniVoice-Personal`), the SPDX allowlist. - `normalize_spdx()` — membership + `LicenseRef-` prefix; junk/None/injection → DEFAULT_LICENSE, never raises/400s. No regex over the SPDX string (CodeQL-clean). - `build_manifest()` — mirrors the legacy `_bundle_metadata` persona fields into the manifest + format discriminator + normalized license + tags + engine / preview / members blocks. seed/vd_states pass through (None-safe; vd_states is a JSON string, never re-parsed). `BundleError(status, detail)` for the router. - `build_consent_json()` — designed-synthetic for `kind='design'`, self-recorded for an attested clone, None when nothing to attest; `recorded_at` coerced. Fields are advisory by design — real verification needs the actual consent audio member, so verified-own-voice can't be forged by editing a manifest. Tests: 14 cases (SPDX allowlist/prefix/junk/strip, manifest schema + field mirror + None-passthrough + bad-license-normalized, consent design/clone/none/ coerce). Backend pytest green; CJK guard green. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> fix(ui): app-shell scales via zoom and always fills the viewport — permanent black-band fix (#452) Root cause: uiScale DEFAULTS to 1.3, so the shell's `width: calc(100vw/--ui-scale)` + `transform: scale(--ui-scale)` path is active for every user. On WebKitGTK (the Linux webview) the transform wasn't magnifying the shrunk shell, so `calc(100vw/1.3)` left ~⅓ of the window black — on EVERY view, by default. (The earlier #445 fix addressed the responsive breakpoints, not this — wrong layer.) Permanent fix: scale via `zoom` and keep the shell at full `100vw × 100vh` (drop the `calc(…/scale)` shrink + the `transform`): - Chromium (mac/win): `zoom` magnifies AND fills (standard browser zoom — same mechanism the bootstrap/wizard wrappers already use). - WebKitGTK (Linux): `zoom` is a no-op → UI renders at 1.0× but the shell is a plain 100vw×100vh element → it FILLS, no band. A missed magnification now degrades to "unscaled but full", never "shrunk + black band". Regression-proofed: `src/test/appShellScale.test.js` fails CI if anyone reintroduces `width: calc(100vw/var(--ui-scale))` or `transform: scale(var(--ui-scale))` on the shell, or drops the zoom/100vw/100vh contract — so a future change can't silently bring the band back. The fix + guard are documented inline in the `.app-container` rule. Full vitest green (398, incl. the 3-case guard); typecheck:ci + vite build clean. fix(realtime): probe auth-exempt /health, not gated /model/status (#450) (#451) The cold-start health probe added in #439 used a raw fetch() to /model/status. Raw fetch does not carry the LAN PIN / remote API-key headers that apiFetch attaches, and /model/status is not in the backend _SHELL_PATHS allowlist, so it is gated by NetworkAccessMiddleware and BearerKeyMiddleware. In LAN-share / remote-API mode the probe gets 401, rejects forever, and the realtime-events WebSocket never opens. Probe /health instead — the auth-exempt liveness endpoint (in _SHELL_PATHS) that returns 200 as soon as Uvicorn is up. Using apiUrl('/health') also avoids a double-slash when the API base has a trailing slash. Default loopback desktop use is unaffected. Adds a regression test asserting the probe targets /health (not a gated path) and only opens the WebSocket after the probe succeeds. Fixes #450 Co-authored-by: mergetest <hashduch@gmail.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> feat(longform): two-pass loudnorm builders + parser (#28 slice 1 — pure) (#449) Groundwork for accurate ACX mastering: the pure, ffmpeg-free pieces of the two-pass loudnorm upgrade, layered over the existing single-pass builders (which stay). The async measure orchestrator + SSE wiring into the render path is slice 2. - `MeasuredLoudness` (frozen dataclass: the 5 measure-pass floats). - `build_loudnorm_measure_filter(preset)` — first pass (+print_format=json); mirrors build_loudnorm_filter's lookup (no strip) so the same inputs map to "no filter". - `parse_loudnorm_measure(stderr)` — extracts the LAST balanced {...} via a linear brace-depth scan (NO regex → CodeQL-safe), json.loads + coerces the 5 keys to finite floats; returns None on the full failure matrix (absent/empty/ unbalanced/malformed/missing-key/non-numeric/non-finite "-inf"/array/scalar). Rejecting "-inf" is the silent-clip path → single-pass fallback. - `build_loudnorm_apply_filter(preset, measured)` — second pass feeding measured_*/offset back in with linear=true; None for off/unknown OR measured is None. - `build_loudnorm_measure_cmd(ffmpeg, concat, filt)` — exact 16-element argv, input segment byte-identical to build_render_cmd (measured == muxed), portable `-f null -` sink (no /dev/null or NUL). - `build_render_cmd` gains `measured: Optional[MeasuredLoudness] = None`: apply two-pass when present, else single-pass; off-render still emits no -af. The `measured=None` default keeps every existing caller + argv byte-identical. Loudness stays opt-in (default None) → default cross-platform behavior unchanged. Tests: 28 cases — measure-filter goldens + off/unknown/whitespace; parser success (last-block-wins, ignores extra keys) + full failure matrix + non-finite rejection; apply-filter golden + None cases; exact measure argv; build_render_cmd two-pass/single-pass/off branches. Backend pytest green (71). Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> feat(longform): TranscriptionPicker + shared reader util (#23 slices 1–2) (#448) Groundwork for "import from a past dictation": the shared store reader + the reusable picker modal, fully unit/RTL-tested. The two-tab wiring (Audiobook Replace/Append prompt + Stories split-panel routing) is slice 3 — deferred for visual verification. Slice 1 — shared reader (`utils/transcriptionsStore.js`): - `loadTranscriptions()` (parse + Array.isArray guard, [] on absent/empty/malformed/non-array/blocked-storage) + `TRANSCRIPTIONS_KEY` / `TRANSCRIPTION_EVENT` consts. Kills the third copy of the localStorage parse. - Refactored `Transcriptions.jsx` + `Projects.jsx` onto it (behavior-preserving; the Array.isArray guard is a superset that only hardens against corrupt blobs). Storage key/shape/200-cap unchanged → no migration. Slice 2 — `components/TranscriptionPicker.jsx`: - Controlled modal wrapping the shared `ui/Dialog` (Radix → focus trap, ESC, backdrop, ARIA inherited). Reads on open, subscribes to the add-event only while open. Per-row display normalization, hides empty-text rows, distinct empty vs empty-search states, case-insensitive `String.includes` search (no RegExp → no ReDoS surface), keyboard-activatable `<button>` rows, Invalid-Date guard. `onPick` gets the original un-normalized entry. Every string via t(). Tests: util edge matrix (3) + picker RTL (7: empty, list+hide-empty, click→onPick+onClose, keyboard rows, search filter + empty-search, bad-timestamp chip omitted, live-refresh on event). Full vitest green; typecheck:ci clean; CJK guard green (new files i18n-only). Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> fix: poll backend HTTP before WebSocket connect to avoid startup ECONNREFUSED (#439) The frontend mounts faster than the Python backend (which takes ~14s to import torch/fastapi before Uvicorn starts). useRealtimeEvents was creating a WebSocket immediately, which always failed with code 1006 on the first attempt, triggering an unnecessary exponential-backoff reconnect. Fix: poll /model/status via HTTP fetch before creating the WebSocket. Once the backend responds 200, proceed to open the WS. If the health check fails, schedule a reconnect using the same backoff — but without the noisy 'closed (code=1006)' log. The /model/status endpoint is chosen because it's already polled by the TanStack Query hooks and always returns 200 once Uvicorn is running, even before models are loaded. fix(ui): scale-aware shell breakpoints — no more cramped/black layout at narrow widths (#445) The app shell is sized `width: calc(100vw / --ui-scale)` then `transform: scale(--ui-scale)` (the WebKitGTK fix, #407), so its grid lays out against `100vw / scale`. But the responsive collapse used viewport `@media (max-width)` queries, which fire on raw `100vw` — so at any `--ui-scale ≠ 1` they trip at the wrong threshold. In a narrow window the 3-column grid was kept, the sidebar's `min 180px` crushed the main column toward 0, and the content ended up jammed into a left sliver with a black band filling the rest. Fix: drive the breakpoints off the shell's OWN width. A ResizeObserver on the app-container reads `el.clientWidth` (= the pre-transform layout width = 100vw/scale; transforms don't change the layout box) and toggles `shell-narrow` (≤1100) / `shell-mini` (≤600) classes; the `@media` queries become equivalent `.app-container.shell-*` rules. Correct on every engine and at every UI scale. Observer fires on both window resize and scale change (the calc width changes). Needs a visual check in the running app at a couple of window sizes + UI scales. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> feat(longform): Story⇄Audiobook convert transforms (#24 slice 1 — pure utils) (#447) The render-faithful interchange between the two long-form editors, as pure, unit-tested functions (no UI/store yet — that's slice 2). The store seam for this (convertMode/projectMode) already shipped in #31a. - `storyToScript(tracks, cast, {projectName})` → `{script, defaultVoice, metadata}`. Emits **profile-id** `[voice:]` tags (the backend resolver keys on id, not display name) so the script renders identically through /longform/render from either door. Most-used effective voice → defaultVoice (no tag), deterministic earliest-occurrence tie-break; tags emitted only on voice change; single-# un-indented headings; inline markup ([pause], SSML-lite, emotion) passes through verbatim — never re-tokenized (no drift vs the backend parser). Respects the three client/server divergences (heading depth, [voice:default] semantics, [pause] dialect): it never synthesizes a pause and never emits [voice:default]. - `scriptToStory(text, profiles)` → `{tracks, cast}` (persisted StoryTrack shape; cast always ≥ a narrator clone). One physical line = one track; a leading [voice:id] becomes the track override + a cast member (named from profiles or the raw id, which is kept as profileId so it round-trips); mid-line markup + body text preserved byte-for-byte; CRLF normalized; slug-collision-safe cast ids; sequential numeric ids. - No new regex over user input (leading-voice detection is string ops) — CodeQL-clean; render output stays identical across both doors (the invariant). Tests: 19 cases incl. the edge matrix + **round-trip equivalence** both directions (script→story→script reproduces; story→script→story preserves spoken text + voice mapping). Full vitest green; typecheck:ci clean; CJK guard green. Deferred (slice 2, needs visual verify): the two UI buttons, store prefill fields, mount read-clear effects, AppMode 'audiobook' fix, i18n. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> feat(audiobook): use shared VoiceSelector for the default-voice picker (#22 migration 1/N) (#446) First call-site migration onto the shared <VoiceSelector> (#22): the Audiobook default-voice <select> becomes the searchable, grouped picker. Value contract is unchanged ('' = engine default | profileId), already store-bound (#31b), so no behavior or data change — just search + clone/designed grouping. `defaultLabel` preserves the existing "engine default" row label. Stories cast / per-line track / Dub segment pickers are intricate live layouts (custom select CSS, row composition) — deferred to follow-up migrations that can be visually verified, rather than blind-swapped. vitest green (357); typecheck:ci clean; vite build clean. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> feat(audiobook): persist book metadata/script/prefs via LongformProject store (#31b) (#444) Audiobook's script, default voice, output format, loudness, book metadata (title/author/narrator/genre/year/description) and pronunciation lexicon now bind to the unified store (#31a) instead of component useState — so they **survive a tab switch / reload** (previously all lost). The headline #31 win. - text→script, defaultVoice, format→outputFormat, loudness, meta→setProjectMeta, bound to store selectors. `meta` is default-filled so an empty record never flips a controlled input to uncontrolled. - Lexicon rows stay LOCAL (half-typed rows aren't junk-persisted); the filtered dict flushes to the store on change and hydrates back into rows on mount. - Transient state (plan, generating, progress, output, chapter previews) stays component-local — correctly NOT persisted. Deferred (noted): coverRef persistence (a File/blob can't go to localStorage); the "Save as named project" affordance + Projects-list card + App `onOpenStory` mode-aware routing (criterion 4 — re-open from Projects). This slice lands the working-state persistence (criterion 3); save/reopen is the next slice. Full frontend vitest green (357); typecheck:ci clean; CJK guard green. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> feat(ui): shared VoiceSelector component + SearchableSelect grouping (#22) (#442) A single searchable, grouped voice picker to replace the per-tab <select>s across Stories / Audiobook / Dub. This slice ships the COMPONENT + the two backward-compatible SearchableSelect extensions it needs; the call-site migrations are a follow-up slice (component lands first, tested in isolation). - `SearchableSelect` gains two opt-in, back-compat props (the two existing call sites are untouched, both render-identically): - `renderGroupHeaders` (default false) — emits a `.ss-group-label` header on the first MAIN row of each new `option.group` with a non-empty `groupLabel` (pinned recent/popular rows never trigger one; empty groups never emit a stray header). - `isRecentable` (default `() => true`) — gates which committed values get recorded as recents. - `VoiceSelector` builds a group-ordered options array (default → fromVideo → clone → designed → preset) over the EXISTING value contract ('' | id | preset:<id> | auto:<slug>) — byte-identical to what every call site already sends, so project data stays compatible. Clone-vs-designed splits on the runtime `.instruct` string (matching VoicePreview), not `.kind`. Renders optional preview / gallery-jump / create adornments (the component owns no audio and makes no API call — it only emits the value and fires the parent's callbacks). A deleted-but-referenced voice renders a "Voice not found (re-pick)" ghost row WITHOUT auto-clearing the value. `isRecentable` excludes '' / preset: / auto: so only real voices are recents. - i18n keys under `voiceSelector.*` (en.json; other locales fall back via fallbackLng, matching the project's established pattern). Tests: 9 RTL cases — grouping/headers, value contract for id/preset/auto, from-video slug parity, ghost row (no auto-clear), recents guard (sentinels excluded, real ids kept), preview button presence/value/loading. Full frontend vitest green (362); typecheck:ci clean; CJK guard green. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> feat(store): unified LongformProject store + v4→v5 migration (#31a) (#443) Introduces one project concept both long-form editors bind to: Stories (cast+tracks) and Audiobook (raw script + book metadata), discriminated by a `projectMode`. Store-only, no UI behavior change — Audiobook is not yet bound (its inputs still use local state; that's the #31b follow-up). Ships the data model + migration + the `convertMode` seam #24 will consume. - `storiesSlice.ts` → `longformSlice.ts`: `StoryProject` → `LongformProject` (gains mode/script/meta/lexicon/coverRef/outputFormat/loudness/defaultVoice); new working fields + actions (setScript, setProjectMeta [merge], setLexicon [replace], setOutputPrefs [merge], setCoverRef, convertMode). `loadProject` restores the FULL surface default-filled (old records never surface undefined to a controlled input); `newProject(mode?)` clears it. `SLICE_DEFAULTS` + `genProjectId` exported (the migrate fn imports genProjectId). Deprecated aliases (`StoryProject`/`StoriesSlice`/`createStoriesSlice`) re-exported so the rename breaks no import. - **Field names kept** (`storyProjects`/`storyTracks`/`cast`) so all 6 consumers and every existing localStorage blob keep working with zero change — the persisted KEY is unchanged; only the per-project SHAPE is enriched. - The project-mode working field is named **`projectMode`**, NOT `mode` — `mode` is already the app navigation field (uiSlice/AppMode); the spec's `mode` would collide (TS error + duplicate partialize key). The stored `LongformProject.mode` (nested) keeps its name. - persist `version: 4 → 5` + a `version < 5` migrate branch (the localStorage analog of an alembic upgrade): enriches each saved project with defaults (spread `...sp` last so id/name/cast/tracks/updatedAt win), drops malformed entries, never throws. v4 users see the same projects, same names/cast/tracks. Tests: ported the back-compat suite (Stories unchanged) + new coverage — default-fill on a v4-shaped record, no-stale-carryover, merge-vs-replace semantics, convertMode idempotency/guard, snapshot+restore of the new fields. Full frontend vitest green (357); typecheck:ci clean; CJK guard green (new slice scanned). No app version-file change (the persist version is the localStorage schema, not the release). Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> feat(routing): synth-time no-silent-fallback gating at all TTS entry points (#21 follow-up) (#440) Closes the last #21 gap: a per-request engine=/model= override bypasses the /engines/select host-gate, so an engine that can't use this host's GPU could still be triggered at synth time and silently fall back to CPU (or die mid- synth). Now enforced at every TTS synth entry point, reusing the SAME probe + resolver — never re-deriving routing. Shared helpers (services/engine_routing.py): - `routing_notice(result)` → (status, reason) to surface, or None. Fires for cpu_fallback (always) and accelerated-with-caveat (driver/arch); silent for cpu_only / clean-accelerated / n/a. - `header_safe_reason(reason)` → scrubbed + ASCII-sanitized (headers are latin-1; a non-ASCII device name would 500 otherwise) + ≤256 chars. No regex. Entry points: - REST `POST /generate` (generation.py): after engine resolution, resolve routing once; `unavailable` → 400; cpu_fallback / accelerated-caveat → 200 + `X-OmniVoice-Routing` + `X-OmniVoice-Routing-Reason` headers on the WAV StreamingResponse; benign → no headers. Covers OmniVoice + adapter branches. - OpenAI-compat `POST /v1/audio/speech` (openai_compat.py): same gate + same headers; the tts-1/tts-1-hd alias inherits the active engine's routing. - WebSocket `/ws/tts` (tts_stream.py): no headers → frames. `unavailable` → `{"type":"error",...}` + skip stream; cpu_fallback / caveat → one `{"type":"routing","status","reason"}` frame before any audio. - `select_engine` response now echoes routing_status / effective_device / routing_reason (PR #432 added the gate; this adds the fields so the UI can warn on a cpu_fallback pick). New fields on SelectEngineResponse. Frontend: `useTTS` reads the X-OmniVoice-Routing header and shows a one-time, non-blocking toast (in-memory de-dup by status — a 50-clip batch fires once, no localStorage). i18n keys `tts.routingFallback`/`tts.routingCaveat`. Tests: routing_notice + header_safe_reason (ASCII/length/scrub) unit tests; REST synth gate (unavailable→400, cpu_fallback→headers, cpu_only→none) via the fake-engine harness with a mocked host; select response routing fields. Deferred (small follow-up): dub-pipeline ASR routing note on the preflight_error SSE channel — separate path, not a TTS synth entry point. No frontend /ws/tts client exists today (the routing frame serves external API consumers). Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> test(longform): real-ffmpeg + stub-TTS e2e for the chapterized renderer (#34) (#441) #34 runtime-verify Layer 1 — the cheap regression net over the audiobook / stories convergence. Drives the REAL `_render_longform_sse` generator + REAL ffmpeg with a stub CPU-tone synth (no GPU/model), and ffprobes the muxed output. Covers happy m4b (full SSE sequence + 2 tagged chapters), mp3 container, per-chapter partial failure (chapter_error isolates ch.0, surviving chapter still muxes), total failure (error + NO file), empty plan, and the no-ffmpeg branch. Gated on ffmpeg present (skip otherwise; runs in CI). Like the other endpoint tests it imports the app+torch stack, so it's validated on CI (local pytest segfaults on the pre-existing torch/Triton import). Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> feat(ui): Engine Compatibility Matrix routing display (#21 PR 5/5) (#434) Surfaces the /engines routing data (PR 3) in the matrix so users see the device each engine will actually use on THIS machine. - The chip matching `effective_device` is highlighted (accent ring + bold), with a "Runs on X on this machine" tooltip. - A status-toned routing badge: accelerated→success "GPU active", cpu_fallback→warn "CPU fallback" (reason in tooltip), cpu_only→neutral "CPU". The badge is SUPPRESSED for unavailable rows (the availability badge already says so) and for legacy payloads with no routing_status (renders exactly as before). An unknown/future status falls back to a neutral "Unknown" badge. - LLM rows (routing 'n/a') render a single neutral "Remote" badge instead of device chips — no false GPU claim. - types.ts: EngineBackend gains effective_device / routing_status / routing_reason; GPUTarget gains `xpu`; new EffectiveDevice + RoutingStatus unions. Corrected the stale "only TTS migrated" comment (all 3 families now emit the full shape). - i18n keys in en.json (other locales fall back to en via fallbackLng until translated — no key-parity gate). xpu chip color in the matrix CSS. Tests: 5 new RTL cases (accelerated highlight+badge, cpu_fallback badge, unavailable suppression, legacy no-badge, LLM Remote). Full frontend vitest green (350); typecheck:ci clean. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> feat(routing): active-engine GPU verdict in preflight + diagnose (#21 PR 4/5) (#433) Surfaces a routing verdict for the CURRENTLY-SELECTED TTS engine in the two system-health surfaces, so a CPU fallback / unavailable-GPU is heard about before a slow or failed synth — the no-silent-fallback contract, read-only. - `tts_backend.active_routing()` + `gpu_routing_verdict()`: the active engine's routing derived from list_backends() (byte-identical to the matrix) plus the host compute summary (family + VRAM from the canonical probe). Never raise. - `/system/diagnose` gains a `gpu_routing` check: accelerated→ok, accelerated-with-caveat / cpu_fallback→warn (+ actionable hint), cpu_only→ok (no-GPU host is the expected normal state — never noise-warns), unavailable→ fail, no-engine→warn. ASCII-safe detail strings (the text dump enforces ASCII). - `/setup/preflight` gains an "Active engine routing" check + an explicit `gpu_routing` object on PreflightResponse (a real field — the response has no extra="allow", so it would otherwise be dropped). `device` gains `gpu_family` (ROCm-vs-CUDA aware) + `vram_gb`. New `GpuRouting` schema. Tests: gpu_routing_verdict (host + active-engine + degraded), diagnose status mapping across all 6 states + never-raises, preflight gpu_routing object + check + device.gpu_family. Existing diagnose/preflight tests stay green (checks are additive; the report's top-level key set is unchanged). Deferred (documented): synth-time routing headers/WS-frames at the 3 synth entry points. Selection is already hard-gated (PR 3 select_engine), and the matrix (PR 5) + this preflight/diagnose verdict surface the situation — the synth-time signal is incremental belt-and-suspenders for the env-var-pinned edge and is best validated interactively. Tracked as a #21 follow-up. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> feat(routing): wire effective-device into /engines + select gate (#21 PR 3/5) (#432) * feat(routing): wire effective-device + routing_status into /engines (#21 PR 3/5) Surfaces the PR-1 probe + resolver through the engine registries so the matrix UI (PR 5) and the no-silent-fallback gates can consume it. - `engine_routing.routing_fields()`: shared helper returning the three serialization-ready keys, centralizing the scrub rule — routing_reason is scrubbed via `core.scrub.scrub_text` only when truthy, so a None reason stays JSON `null` (never coerced to ""). - TTS/ASR `list_backends()` each gain `effective_device` / `routing_status` / `routing_reason`, computed from a SINGLE `detect_host_caps()` call per request (host caps are constant per process). ASR is brought to full TTS parity: it now also carries `install_hint` / `last_error` / `isolation_mode` and a SCRUBBED `reason` (closing a pre-existing ASR token-leak gap) — an identical 11-key shape across families. ASR also gains the same is_available()-raises resilience TTS has (degrade to available:false, never 500). - LLM `list_backends()` reaches 11-key parity too but emits literal `effective_device:"network"` / `routing_status:"n/a"` / `routing_reason:null` (NOT via resolve_routing — LLM runs no local GPU model). `LLMBackend.gpu_compat = ()`. "network" is a label, not a probe — nothing here touches the network. - `select_engine` host-routing gate: refuses a pick whose `routing_status` is `unavailable` on this host (400 with an actionable detail), while ALLOWING `cpu_fallback` (it runs, just slower). LLM is never gated. Defensive `.get` so legacy payloads still select. New typed `SelectEngineResponse`. Tests: 11-key shape across all 3 families, well-formed tts/asr routing keys (+ None-not-"" contract), LLM network/n/a labels, select gate (block unavailable / allow cpu_fallback / never-gate LLM). Updated the registry exact-shape test for the 3 new keys. * test(cjk): allowlist docs/specs/ in the hardcoded-CJK guard PR #429 merged the longform design specs, which legitimately quote functional CJK (test-fixture descriptions, CosyVoice speaker IDs, multilingual sample text). The CJK guard scans every tracked file, so those docs turned main red. Specs are documentation, not shipped UI strings — allowlist the docs/specs/ prefix, matching the individually-allowlisted docs already in the set. --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> docs(longform): implementation specs for the 14 roadmap tasks (#21–#34) (#429) * docs(longform): implementation specs for the 14 roadmap/integration tasks (#21–#34) Per-task implementation specs under docs/specs/longform/ for the remaining longform + #346-roadmap work: GPU compat matrix, shared VoiceSelector, Transcriptions import, Story⇄Audiobook export, inline Create Voice, gallery handoff, parser unification, two-pass ACX, .ovsvoice format, Dub→Stories, unified LongformProject store, phone calls, cue-sheet, runtime-verify. Authored by a draft + iterative-refinement workflow (codebase-grounded: exact file:line anchors, API/data shapes, test plans, constraints, deps, risk, PR slices). NOTE: the 10-round refinement was cut to ~rounds 4–5 by an account session limit; rounds 5–10 (incl. the final de-bloat/polish pass) are pending — the specs carry per-round revision-note preambles that the polish round trims. * docs(longform): strip accreted (this-revision) note preambles from specs --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> fix(engines): MLX platform gate (#390) + ASR gpu_compat + IndexTTS2 (#21 PR 2/5) (#431) Builds on the device probe from PR 1. Backend-only; the routing keys are wired into /engines in PR 3. - #390 closed: MLXAudioBackend / MLXWhisperBackend now call the shared `core.device_caps.mlx_supported()` gate FIRST, before importing the package. On Linux/Windows/mac-Intel they report unavailable and never advertise a usable `mps` route, even with a stray mlx wheel installed. Replaces the ASR backend's ad-hoc inline MPS check with the one shared rule. (The Wave-4.4 OSError/RuntimeError import-guard is preserved — it now lives behind the platform gate; its test forces the gate open so the guard stays the path under test.) - `ASRBackend` ABC gains `gpu_compat: tuple[str, ...] = ("cpu",)` mirroring TTSBackend, and each subclass declares its real targets: whisperx/faster-whisper → (cuda,cpu); mlx-whisper → (mps,cpu); pytorch-whisper → (cuda,mps,cpu); nemo/funasr → (cuda,cpu); moonshine → (cpu,). Inert until PR 3 serializes them. - IndexTTS2 declares `gpu_compat = ("cuda","cpu")` so it stops advertising the inherited CPU-only default. - ROCm is deliberately NOT claimed for any ASR engine (or for IndexTTS2): CTranslate2 has no upstream HIP build, and an unverified `rocm` claim would route ROCm hosts to a broken GPU path — strictly worse than the honest `cpu_fallback` the resolver already emits ("declares CUDA only; ROCm not in its compat set"). The per-engine TTS ROCm audit is a tracked follow-up that will verify each path before claiming it. Tests: MLX gate regression (both backends, on/off Apple), ASR gpu_compat tuples + no-false-rocm invariant, IndexTTS2 override; existing MLX import-guard test updated for the new gate ordering. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> refactor(longform): share the SSE stream consumer across Stories + Audiobook (#436) Stories and Audiobook are two authoring frontends over one server-side renderer (_render_longform_sse), emitting the same chapter-progress events. Both hand-rolled the identical read/decode/splitSSEBuffer/parseSSELine loop. Extract utils/longformStream.consumeLongformStream(res, onEvent, {isAborted}): one place owns the SSE protocol; each editor keeps only its own per-event state handling (Stories: export %; Audiobook: {current,total,title,assembling,done}). Behaviour unchanged — Audiobook keeps its abort check via isAborted. The rest of the two editors stay distinct on purpose (cast/dialogue vs manuscript/EPUB authoring), per docs/specs/2026-06-13-stories-audiobook-maturity. Tests: frontend/src/test/longformStream.test.js (chunk-boundary parsing, abort, no-body). Full vitest: 348 passed; typecheck:ci clean. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> feat(dub): dedicated Dub home (projects/history) + project rename (#435) The dub Projects + History rail (WorkspaceProjects/WorkspaceHistory) used to sit beside the editor at all times. Now it's a landing: shown only when no project is being edited (dubStep === 'idle'); opening/creating one switches to a full-width editor. (The global Sidebar is already hidden in dub mode, so the studio-right rail is the only surface — no Sidebar change needed.) Adds project rename: - backend: PATCH /projects/{id} updates just the name (400 on empty, 404 on missing) — lighter than PUT which rewrites the whole state blob. - api: renameProject(id, name); App.jsx renameProject handler (updates the active-project label + refreshes the list). - UI: inline rename on each project card (pencil → edit → Enter/Save / Esc). Verified: PATCH create→rename→list / 400 / 404; frontend typecheck:ci clean. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> feat(routing): host device probe + routing resolver (#21 PR 1/5) (#430) * feat(routing): canonical host device probe + routing resolver (#21 PR 1/5) Foundational, backend-only slice of the GPU compatibility matrix (#21). No API or UI change — wiring lands in PRs 3–5. - `core/device_caps.py`: single source of truth for host accelerator capability. `detect_host_caps()` distinguishes ROCm from CUDA (unlike the gguf hardware_probe), never raises, makes no network call, stays kernel-free on cold start, and caches per process. Enumerates the full degradation contract (torch-unimportable→probe_ok=False, CUDA-init raises, device_count==0, multi-GPU, mem_get_info failure, arch mismatch, MPS, XPU, DirectML). Plus shared `mlx_supported()` gate (#390 groundwork) — exact-string platform check, no regex. - `services/engine_routing.py`: pure `resolve_routing(gpu_compat, caps)` → `{effective_device, routing_status, routing_reason}`; deterministic and byte-identical across OSes. Rules for accelerated / cpu_fallback (the no-silent-fallback signal) / cpu_only / unavailable, incl. the ROCm-not-in-set, DirectML-neutral, and XPU edges. - `get_best_device()` delegates its family decision to the probe so the loader and probe can never disagree; keeps the ROCm HSA env override and DirectML device-string return (probe reads, loader writes). String contract unchanged. - 39 unit tests (probe / resolver / mlx gate / reason-scrub contract); no new regex (CodeQL-clean), English-only (CJK guard green). The gguf hardware_probe rebase is a deliberate follow-up: it has its own torch-mocked suite and a VRAM-driven quant table unaffected by the family rename, so it stays out of this zero-risk slice. * fix(routing): address review — full available_families + empty-except comments CodeRabbit / CodeQL review on PR 1: - `available_families` no longer drops secondary accelerators on hybrid hosts (e.g. NVIDIA + Intel-iGPU-via-IPEX). The probe now detects every accelerator independently and picks `family` by priority at the end, instead of short-circuiting after the first hit. Routing is unaffected (it keys off `family`), but the field is now honest. + hybrid-host test. - Annotated every `except: pass` in device_caps with an explanatory comment (CodeQL py/empty-except). - Removed the unused `_MIN_NVIDIA_DRIVER` constant — the driver-version check stays in wizard preflight (no subprocess on the probe path); documented why. - `get_best_device()` now checks MPS before DirectML, mirroring the probe's family-priority order so loader and probe never disagree. --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> refactor(models): model-management v2 cleanup (mm2, all tiers) (#428) One coherent lifecycle surface over the in-process model, diarization, and subprocess sidecars; fixes the engine-switch VRAM leak; tightens download robustness. Backend-only, response shapes preserved, no new deps. Tier 1 — correctness: - MM2-01: get_active_tts_backend() caches one instance per backend id and unload()s the outgoing engine on switch (fixes the VRAM leak behind #278); adds reset_active_backend(). - MM2-02: OmniVoiceBackend.unload() releases the shared model_manager singleton + free_vram(); SubprocessBackend.unload() -> unload_sidecar(self.id), inherited by all sidecar engines. Idempotent + preload-safe. - MM2-03: /model/loaded ASR row reports the real device + a note explaining the disabled unload button. Tier 2 — single surface: - MM2-04: new services/model_lifecycle.py owns list_loaded/unload/unload_all/ free_vram; system.py routers are thin delegations (shapes unchanged). - MM2-05: idle timeouts (in-process + sidecar) resolve via prefs.resolve (env wins, no restart); removed the duplicated _IDLE_TIMEOUT_SECONDS. Tier 3 — robustness/observability: - MM2-06: _install_cooldowns swept (1h TTL) + cleared on success — bounded. - MM2-07: per-extension weight floors (onnx 64KB, tensors 5MB) OR the original >=5MB catch — small ONNX no longer false-flagged, #352 still caught. - MM2-08: indextts GPU sidecar self-reports vram_mb in pong; parent surfaces it in list_live_sidecars (0 = CPU/unmeasured). - MM2-09: is_cached scan_cache_dir->disk fallback logs WARNING w/ exc type (#117/#118), was invisible at DEBUG. Tests: tests/test_mm2_lifecycle.py (15). Full suite: 1379 passed. Plan/summary: .planning/quick/260613-mm2-clean-model-management-v2/. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Fast model downloads: Xet fast path + accurate progress (FDL W0–W2 + W4) (#424) * feat(downloads): Xet fast path + accurate progress (FDL W0–W2) Make model downloads fast and show accurate downloaded/remaining/speed. Research confirmed hf-xet already implements the IDM/uGet technique (content-defined chunking, parallel byte-range gets, dedup, resume), and the spike found all 25 catalog repos are Xet-backed — so the win is driving Xet well + accurate progress, not a custom downloader. W1 — maximize + guarantee Xet: - pin huggingface_hub>=1.7 + hf-xet>=1.1 (was transitive); no hf_transfer - drive snapshot_download with explicit tqdm_class + max_workers + endpoint - opt-in HF_XET_HIGH_PERFORMANCE / HDD sequential-write knobs (default off) - /system/info reports fast_download {xet_enabled, xet_version, high_perf} W2 — accurate progress: - dry_run preflight -> install_plan event (exact total/cached/remaining) - utils/download_aggregator.py: one overall bar; byte bars (by id) vs the "Fetching N files" count bar; windowed rate; emits one 'aggregate' event - frontend overall bar (speed/remaining/ETA), cached-skip, ⚡ fast badge Known limit (verified live): under Xet+hf_hub 1.7.2 per-file byte bars never advance/close via tqdm, so mid-download the bar is file-granular and bytes flush to the exact total on completion. Classic-LFS/mirror repos get true byte progress (W4). Drive-by: download.py used os.walk without importing os (latent NameError in _validate_snapshot_has_weights on every install) — fixed. Tests: tests/backend/setup/test_download_preflight.py (10). Spike + plan under .planning/quick/260613-fdl-fast-model-downloads/. * feat(downloads): opt-in mirror + cancel + docs (FDL W4) - mirror (FDL-10): snapshot_download(endpoint=) honours prefs hf_endpoint / env HF_ENDPOINT on preflight + download (per-call, no process-wide env). Documented as the classic-LFS path (no Xet) for restricted networks. - cancel (FDL-11): POST /models/install/cancel {repo_id} stops further retries at the next boundary, emits install_cancelled, clears the cooldown (cancel is intent, not failure). Frontend treats it as a terminator. - docs (FDL-12): docs/downloading-models.md (Xet fast path, progress semantics + byte-speed limitation, opt-in tuning, mirror, cancel, troubleshooting) + README pointer. Docs-sync rule satisfied. * docs(planning): model-management v2 cleanup plan (mm2) GSD plan for cleaning the model-management subsystem: registry unload-on- switch + per-engine unload() (fixes VRAM leak), model_lifecycle facade, unified idle/timeout config, bounded cooldowns, sidecar VRAM self-report, cache-fallback logging. Planning artifact only — no code. * fix(downloads): reconcile with main's HF_HUB_DISABLE_XET; honest status Rebasing onto main surfaced that main forces HF_HUB_DISABLE_XET=1 (classic LFS) because Xet progress bypasses the tqdm hook — the same limitation found here. Reconcile instead of fight: - /system/info fast_download now reports runtime truth: xet_installed + xet_active (installed AND not HF_HUB_DISABLE_XET) + xet_enabled alias. The ⚡ badge only shows when Xet actually runs; startup log says "downloads: Xet disabled → legacy LFS". - complete(): clear the rate window before the final flush so crediting the full size in one step can't emit an absurd instantaneous rate. - docs/downloading-models.md rewritten: default is legacy LFS for accurate progress; Xet is opt-in via HF_HUB_DISABLE_XET=0. hf-xet pin stays (ready for a future Xet progress hook). W2 (preflight total/remaining + aggregate bar + exact completion) is the value on either path; W1's "maximize Xet" is dormant by main's design. * feat(downloads): opt-in segmented multi-connection accelerator (FDL W3) Since main forces Xet off (HF_HUB_DISABLE_XET=1), the default path is single-stream legacy LFS — so a segmented downloader is the way to get BOTH parallel speed and live byte progress. - services/segmented_download.py: async multi-connection Range downloader for one file — parallel byte-ranges, resume (.part + manifest), per-segment short-read truncation guard, optional sha256/etag verify, cancel, and a single-stream fallback when the server won't range. Auth-safe: the HF Authorization header is sent only to huggingface.co/hf.co and never forwarded to a CDN host on redirect (unit-tested). - dispatch (download.py): opt-in via prefs segmented_downloader / env OMNIVOICE_SEGMENTED_DOWNLOAD (default off). When on and Xet inactive, fetches each file into the HF cache mirroring hf_hub_download (blobs + snapshot symlinks + refs/main), feeding real bytes to the aggregator. Any failure falls back to snapshot_download — never breaks a correct install. - fix: complete() was adding a full total on top of accumulated segmented bytes (2x); now replaces byte bars so the sum is exactly total. Verified live (accelerator on): real byte progress to ~16.6 MB/s, final bytes==total, /models installed=True, delete frees correctly. Tests: test_segmented_download.py (7) + aggregator double-count regression. * test(downloads): relocate FDL tests to top-level; loop-isolate segmented test CI runs the full suite, which exposed a pre-existing test-isolation leak: several tests/backend/** fixtures purge core.*/services.* from sys.modules under a temp OMNIVOICE_DATA_DIR and never restore, leaving core.config/core.db bound to a dead temp dir. It only bites when collection order puts a purging test ahead of a real-DB reader (test_longform_jobs). Adding tests under tests/backend/setup/ reordered collection and tripped it. Fix without touching the shared (fragile) fixtures or risking class-identity breakage from a blanket sys.modules restore: - move the two FDL test files to top-level tests/ (tests/test_fdl_*.py) so tests/backend/** collection order is identical to main — longform passes. - rewrite the segmented test to run each case under asyncio.run() (fresh loop) instead of asyncio.get_event_loop(), which an earlier async test can leave closed in the full suite. Full suite green locally: 1364 passed, 0 failed. --------- Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> feat(longform): cohesion quick-wins — Audiobook launchpad card + Stories in Projects (#426) * fix(ui): align Audiobook + Stories controls to the design tokens The hand-written tabs used a bare `.btn` class (which has NO CSS rule → bright white browser-default buttons) and an unstyled `.field-label`, so the buttons, labels, and selects looked off-theme. (Other tabs use the Button/ui-btn system, which is why only these looked wrong.) Found via a design-token audit workflow. AudiobookTab: - Import / Preview plan / Add word / Add cover / Download → `ui-btn ui-btn--subtle`; Create → `ui-btn ui-btn--primary`; cover-remove / lexicon-remove / chapter-play → `ui-btn ui-btn--icon` (the app's themed button variants from ui/Button.css). - AudiobookTab.css: define `.audiobook-tab .field-label` (chrome mono/uppercase via --chrome-* tokens) + header serif title / muted subtitle (--font-serif, --text-xl, --color-fg/-muted). Selects/inputs already used `.input-base` (the canonical chrome look) — left as-is. StoriesEditor: - Format `<select>` now uses `.input-base` (canonical chrome select + arrow); trimmed the bespoke `.stories-editor__format` rule to just the toolbar sizing. Build clean; 345 frontend tests green. * feat(longform): cohesion quick-wins — Audiobook launchpad card + Stories in Projects Make Stories/Audiobook feel wired into the app (integration-map plan, quick-win tier): - Launchpad: an Audiobook ActionCard (was NavRail-only; Stories already had one). - Projects/OmniDrive: saved Stories projects now appear as a "Stories" category (line + voice counts) and open via onOpenStory → loadProject + setMode('stories'), mirroring onOpenDub. App.jsx reads storyProjects/loadProject from storiesSlice. - Live profile sync (QW1) confirmed already working: both tabs map the `profiles` prop in render (no mount snapshot), so a voice cloned/designed/imported anywhere shows up live in the cast/default pickers — no code needed. Deferred (no trigger yet): QW4 create-voice handoff to these tabs needs an inline create/gallery "use here" affordance first (QW3/M3). Build clean; 345 frontend tests green; en.json valid. --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> fix(ui): align Audiobook + Stories controls to the design tokens (#425) The hand-written tabs used a bare `.btn` class (which has NO CSS rule → bright white browser-default buttons) and an unstyled `.field-label`, so the buttons, labels, and selects looked off-theme. (Other tabs use the Button/ui-btn system, which is why only these looked wrong.) Found via a design-token audit workflow. AudiobookTab: - Import / Preview plan / Add word / Add cover / Download → `ui-btn ui-btn--subtle`; Create → `ui-btn ui-btn--primary`; cover-remove / lexicon-remove / chapter-play → `ui-btn ui-btn--icon` (the app's themed button variants from ui/Button.css). - AudiobookTab.css: define `.audiobook-tab .field-label` (chrome mono/uppercase via --chrome-* tokens) + header serif title / muted subtitle (--font-serif, --text-xl, --color-fg/-muted). Selects/inputs already used `.input-base` (the canonical chrome look) — left as-is. StoriesEditor: - Format `<select>` now uses `.input-base` (canonical chrome select + arrow); trimmed the bespoke `.stories-editor__format` rule to just the toolbar sizing. Build clean; 345 frontend tests green. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> fix(longform): evict oldest chapters from the render cache (review fast-follow) (#423) The content-addressed longform_cache/ accumulated uncompressed chapter WAVs across every render with no bound (a review finding). Add prune_cache_dir() — LRU-by-mtime eviction down to a 2 GB ceiling (OMNIVOICE_LONGFORM_CACHE_MAX_GB); best-effort, never raises. Called at the start of each render job, before its chapters are written, so the fresh ones are never the eviction target. Tests: under-cap no-op, evicts-oldest-keeps-newest, missing-dir safe. 38 green. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> feat(audiobook): pronunciation editor + markup reference UI (#422) Makes the lexicon backend (#419) and SSML-lite markup (#421) usable from the tab. - A "Pronunciation" editor in the full-width side pane: add/remove {word → say it as…} rows, compiled to a lexicon dict sent with both the full render and per-chapter preview (so previews match the final output). - A collapsible "Markup reference" listing the script syntax (# chapter, [voice:], [pause], [slow]/[fast]/[emphasis]/[spell]). - api/audiobook.ts: lexicon field on the generate + preview bodies. Build clean; 345 frontend tests green. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> feat(longform): SSML-lite prosody markup — [slow]/[fast]/[emphasis]/[spell] (PR 8b) (#421) Inline delivery hints within a narration line, wired into BOTH front doors so Audiobook and Stories behave identically. - services/ssml_lite.py (parallel-built, 18 tests): parse_ssml_lite splits a line into {text, speed, spell, emphasis} segments — nesting (innermost wins), unclosed-to-EOL, stray-close ignored, adjacent-merge; ReDoS-safe literal alternation. + spell_out(). - _parse_spans (audiobook script path) now applies SSML-lite as the innermost layer (precedence: [voice:] → [pause] → SSML); each segment becomes a Span with its speed (threaded to the renderer) and spelled-out text for [spell]. Trailing pause attaches to the run's last segment. - frontend/src/utils/ssmlLite.js: client port (kept in sync with the .py) + storyToSpans applies it per chunk — inline speed OVERRIDES the per-line slider, falls back to it otherwise. Tests: parse_ssml_lite (18 py + 10 js), script-level prosody parse, Stories SSML compile (override + spell). 70 backend + 345 frontend green; build clean. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> fix(ui): full-width/height Audiobook + Stories layouts (match other tabs) (#420) Both tabs rendered as narrow centered columns (Audiobook maxWidth:860, Stories max-width:1040 margin-auto) while the rest of the studio is full-bleed. - AudiobookTab: rebuilt into a full-height two-pane layout (new AudiobookTab.css) — header with the action buttons, a left script editor that grows to fill the window height, and a right settings+results pane (voice/format/loudness, cover & metadata, progress/output/plan) that scrolls independently. Collapses to one column under 900px. Removed the inline 860px cap. - StoriesEditor: dropped the `max-width:1040px; margin-inline:auto` cap → fills edge-to-edge like the dub/projects/transcripts tabs. Build clean; 334 frontend tests green. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> feat(longform): pronunciation lexicon — per-render word respelling (PR 8a) (#419) Lets a render correct hard-to-say words (e.g. {"GIF":"jiff","Dr":"Doctor"}). Backend wiring; the editor UI folds into the full-width Audiobook redesign. - services/pronunciation.py (parallel-built, 19 tests): apply_lexicon — whole-word, case-insensitive, longest-first, word-boundary, single ReDoS-safe re.sub pass; + normalize/load/save_lexicon (JSON). - synthesize_chapter gains a `lexicon` kwarg, applied to each span's text before chunk splitting (None/empty = no-op → backward compatible). - _render_chapter_cached folds the normalized lexicon into the chapter cache key (a lexicon edit re-renders); threaded through _render_longform_sse + the /audiobook, /audiobook/preview, /longform/render request models. Tests: synthesize_chapter respells via lexicon; pronunciation module (19); 75 related backend tests green. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> fix(longform): correctness + robustness fixes from adversarial review (#418) * fix(longform): correctness + robustness fixes from adversarial review Fixes the confirmed findings from a multi-agent review of the convergence: HIGH (correctness/output): - MP3 + cover produced a corrupt file (-map 2:v -c:v copy is invalid for mp3). Cover art is now embedded for M4B only; mp3 skips it (m4b is the cover format). - Chapter cache key omitted ref_text — editing only a profile's ref_text served stale audio. ref_text is now part of the voice signature. - Preview wrote audiobook_cache/ but the render reads longform_cache/ (rename missed in PR 5) → cache-warming silently broke. Unified to longform_cache/. Robustness (DoS/OOM guards): - /audiobook/import caps upload at 64 MB; epub_to_chapter_script bounds per-entry (25 MB) and cumulative (300 MB) uncompressed reads (zip-bomb guard). - /longform/render rejects > 10,000 chapters (422). Frontend leaks: - StoriesEditor.removeTrack revokes the line's preview blob URL. - AudiobookTab revokes the cover blob URL on replace/unmount. Deferred fast-follows (also from review): render-cache disk eviction; restoring the standalone chapter cue-sheet export (needs chapter times in the done event). Tests: mp3-drops-cover, epub entry/total caps, import + chapter-count limits; updated the cache-hit test for the 4-field voice sig. 70 backend + 334 frontend green. * test(longform): pass EPUB caps as params, not monkeypatch (CI import-path fix) The cap tests monkeypatched module constants, but in the full-suite CI context the module loads under a different import path so the patch missed the function (it used the real 300 MB cap → tests failed). epub_to_chapter_script now takes max_entry_bytes/max_total_bytes kwargs (default to the constants); tests pass small values directly — deterministic regardless of import path. --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> feat(longform): job library — finished books/stories in Projects (PR 7/8) (#417) Surfaces finished Audiobook + Story renders so they're re-downloadable from the Projects view — closing the resume/history loop of the convergence. Backend (new, no migration — reads existing job_store rows): - routers/longform_jobs.py: GET /longform/jobs lists finished audiobook/story jobs newest-first, recovering output/chapters/duration from each job's persisted 'done' SSE event. Pure build_longform_library() over the job_store callables; defensive (skips unparseable jobs, never 500s). Registered in main.py. Frontend: - Projects.jsx: new "Audiobooks" category fed by /longform/jobs; each row opens the rendered file (/audio/<output>) with type/chapters/duration. Offline-safe (empty on fetch failure). en.json keys added. Built via parallel worktree agent; backend tests/test_longform_jobs.py (9) green; 334 frontend tests + build clean. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> fix(stories): thread per-line speed through the shared renderer (PR 6/8) (#416) PR 5 moved Stories' full export to /longform/render but dropped per-line **speed** — the old client export sent each line's speed to /generate; the converged path silently ignored it. This restores it end-to-end. - Span gains an optional `speed`; synthesize_chapter passes it to the injected synth (signature now `synth(text, voice_id, speed)`); both engine paths (OmniVoice model + generic TTSBackend) forward it to generate(speed=…). - chapter_cache_key now includes speed (a speed change re-renders; tuples accept an optional 4th element so existing 3-tuple callers/tests still work). - LongformSpan + /longform/render carry speed; storyToSpans emits each line's speed onto its spans. Emotion note: per-line tone is already model-native via inline tags ([laughter] etc.) inserted into the text, so no separate emotion→instruct plumbing is needed — the dead `emotion` store field stays unused/superseded. Tests: storyToSpans speed passthrough (8); cache-key speed sensitivity; synth stubs updated for the 3-arg signature. 65 backend + 334 frontend green; build clean. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> feat(stories): full export → shared server-side renderer (PR 5/8) (#413) * feat(stories): full export → shared server-side renderer (PR 5/8) The convergence core. Stories' full export no longer stitches audio in the browser (Web Audio, capped by RAM, no resume/loudness/markers) — it compiles cast + lines into a chapter/span plan and streams through the same chapterized renderer the Audiobook tab uses. Backend: - Extracted the audiobook SSE job into a shared `_render_longform_sse(plan, …)` generator (resume cache, per-chapter fault isolation, mux). /audiobook is now a thin caller. - New POST /longform/render — accepts a pre-built {chapters:[{title,spans: [{voice_id,text,pause_ms_after}]}]} plan (+ format/loudness/cover/metadata) and renders it. Pause-only spans (empty text) are kept as silence. job_type=story. - Shared content-addressed cache renamed longform_cache (one render per unique chapter across both front doors). Frontend: - storyToSpans(tracks, cast) — pure compiler: `# ` lines → chapters; each line resolves its cast/override voice; inline [voice:]/[pause] split into spans; pauses fold into the previous span. - StoriesEditor.generateAll now posts via longformRender and downloads the server file (chaptered M4B / MP3). Single-line preview stays client-side; stems export unchanged. Format select WAV→M4B. Deferred to PR 6 (with the component split): per-line regenerate, emotion→instruct. Tests: storyToSpans (7) — cast resolution, chapters, per-line + inline voice, pause folding, empty-drop. 64 backend + 333 frontend green; build clean. * fix(audiobook): confine cover_path to OUTPUTS_DIR + don't leak exception text (CodeQL) - _safe_cover_path() restricts the user-supplied cover to OUTPUTS_DIR before it reaches ffmpeg (py/path-injection). - SSE error events now emit a generic message and log the detail server-side (py/stack-trace-exposure); empty best-effort excepts annotated. * fix(audiobook): cover path via basename+fixed dir (clears CodeQL py/path-injection) CodeQL didn't recognize realpath+startswith as a barrier; os.path.basename is a recognized sanitizer. Covers only come from /audiobook/cover (OUTPUTS_DIR/ audiobook_covers), so rebuilding from the basename onto that fixed dir is both CodeQL-clean and strictly tighter — no caller path can escape it. * fix(audiobook): regex-allowlist cover filename (clears CodeQL py/path-injection) basename alone wasn't a barrier CodeQL credits. Restrict the cover name to the exact pattern /audiobook/cover emits (12 hex + jpg/jpeg/png) before joining onto the fixed covers dir — an anchored-regex guard CodeQL recognizes as sanitizing, and strictly tighter than before. * fix(audiobook): commonpath-confine resolved cover path (CodeQL py/path-injection) Add an os.path.realpath + os.path.commonpath containment check on the resolved cover path (the barrier static analysis recognizes), on top of the regex allowlist + basename. Defense in depth; the path provably cannot escape OUTPUTS_DIR/audiobook_covers. --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> fix(ci): make Docker Hub description sync non-fatal (#414) The image build+push succeeds, but the "Update Docker Hub description" step 403s (Forbidden) — DOCKERHUB_TOKEN can push yet lacks description-edit scope, a common limitation of fine-grained Docker Hub tokens. That cosmetic overview sync was failing the whole Docker (GHCR) run on main. Mark the step continue-on-error so a creds-scope mismatch no longer reds-out an otherwise-successful build. To actually sync the overview, the token needs read/write (incl. description) scope, or use the account password. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> feat(audiobook): text + EPUB import → auto-chapter (PR 4/8) (#412) Spec PR 4. A front door onto the existing chapter parser: import a file, get a chapter-delimited script in the editor. Backend (new services/longform_import.py — pure, stdlib only, no new dep): - chapterize_plaintext(text): inserts `# ` headings ahead of short standalone chapter-title lines (Chapter/Part/Prologue/…); no-op if the text already has H1s; long "Chapter …" sentences stay prose. ReDoS-safe (anchored, per-line). - epub_to_chapter_script(bytes): parses EPUB (zipfile + ElementTree + html.parser) in spine order → `# Title` + stripped body per document; skips empty/nav pages; the heading becomes the chapter title (not narrated). Raises ValueError on a malformed EPUB. ET.fromstring annotated `# nosec B314` (local user file, no external-entity expansion). - POST /audiobook/import (UploadFile) → {text, chapters}. Frontend: an Import button (.txt/.md/.epub) that fills the script editor. Tests: tests/test_longform_import.py (9) incl. an in-memory synthetic EPUB (spine order, empty-doc skip, tag stripping, bad-zip). 64 backend + 326 frontend green; build clean; en.json valid. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> docs(docker): maintain Docker Hub overview in-repo + auto-sync on main (#410) The hub.docker.com/r/palashdeb/omnivoice-studio overview was managed by hand and had gone stale (stuck at the sha-f86beb0 era, missing the tag table, audiobook/long-form, Supertonic-3, server-mode networking notes). Add deploy/dockerhub-overview.md as the source of truth and a peter-evans/dockerhub-description step in docker.yml that pushes it to Docker Hub on main pushes. Gated identically to the image push: only when DOCKERHUB_TOKEN is set, so forks / GHCR-only runs are unaffected. Overview adds the :latest=preview / :stable=release tag semantics (matching docs/install/docker.md), the current feature set, server-mode + LAN networking notes, and shields badges. Short description is 98/100 chars. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> feat(audiobook): per-chapter preview + resume + chapter fault-isolation (PR 3/8) (#411) * feat(audiobook): per-chapter preview + resume + chapter fault-isolation (PR 3/8) Builds on the shared core (#408) and metadata UI (#409). Chapter-level control, the spec's PR 3. Shared core: - chapter_cache_key(spans, sr, engine_id, voice_sig) — deterministic content hash of a chapter's audio inputs. Same inputs → reuse; any change (text, voice, order, pauses, sr, engine, resolved-voice signature) → re-render. Backend (audiobook router): - Chapter WAVs are now content-addressed in OUTPUTS_DIR/audiobook_cache. A re-run after a failure/interruption reuses already-rendered chapters and only synthesizes the missing/changed ones (resume). Job emits `cached` per chapter and `cached_chapters`/`failed_chapters` on done. - Per-chapter fault isolation: a chapter that throws emits `chapter_error` and the job continues; the m4b assembles from the successful chapters. Re-running retries only the failed (un-cached) chapters. - POST /audiobook/preview — render a single chapter to audition it; shares the same cache so a preview warms the full run and a re-preview is instant. - _build_synth now exposes resolve + engine_id; _prepare_synth unifies the omnivoice/generic paths for both the job and preview. Frontend: - Plan view: a ▶ preview button per chapter with inline playback. - Done panel: "reused N chapters" + "N failed — click Create to retry" notes. Tests: chapter_cache_key determinism + sensitivity (8); preview validation + cache-hit-skips-synth (3). 55 backend + 326 frontend green; build clean. * fix(audiobook): mark cache-key SHA1 usedforsecurity=False (bandit B324) --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> feat(audiobook): metadata, cover art, format + loudness UI (PR 2/8) (#409) Surfaces the shared-render-core capabilities (PR 1, #408) in the Audiobook tab. Backend: - POST /audiobook/cover — multipart cover upload (jpg/png, 8 MB cap), returns a server-side path passed back as cover_path. Unit-tested via the handler directly (no main+torch import). Frontend: - api/audiobook.ts: AudiobookGenerateBody (format/loudness/cover_path/metadata) + audiobookUploadCover(file). - AudiobookTab: format select (M4B/MP3), loudness select (off/ACX/podcast, default off), and a "Cover & details" panel — cover picker with preview + title/author/narrator/year/genre/description. On create, the cover uploads first, then the job runs with metadata + format + loudness. - en.json: audiobook.* keys for the new controls. Tests: tests/test_audiobook_cover.py (4) green; frontend vitest 326 green; prod build clean; CJK + i18n-parity gates pass. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> feat(longform): shared render core — loudness, metadata, cover art (PR 1/8) (#408) First slice of the Stories+Audiobook convergence (spec: docs/specs/2026-06-13-stories-audiobook-maturity.md). Both features will compile to one server-side chapterized renderer; this lands the shared pure builders and wires them behind Audiobook. New `backend/services/longform_render.py` (all pure, unit-tested without ffmpeg/torch): - build_ffmetadata(chapters, global_meta) — FFMETADATA1 with an optional global tag block (title/author→artist/narrator→composer/year→date/genre/description→ comment) + chapter table. - build_loudnorm_filter(preset) — `-af loudnorm` for ACX (~-19 LUFS, -3 dBTP) or podcast (-16 LUFS); off/unknown → None. Opt-in, so default behavior stays platform-identical. - validate_cover_image — jpg/png + 8 MB cap guard. - build_render_cmd — generalizes the m4b mux: m4b|mp3, optional cover (attached_pic) + loudness, bitrate validated. - build_concat_list — moved here. `services/audiobook.py`: build_chapter_ffmetadata / build_m4b_cmd / build_concat_list are now backward-compatible wrappers over the core (existing imports + tests unchanged). `POST /audiobook`: now accepts optional `format` (m4b|mp3), `loudness`, `cover_path`, and `metadata` and passes them through — backend-complete; the UI for these lands in PR 2. Tests: tests/test_longform_render.py (28) + existing test_audiobook.py (11) green. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> fix(ui): UI scale via transform:scale, not zoom — fixes WebKitGTK black bands (#407) CSS `zoom` is honoured by Chromium (the macOS/Windows webview) but IGNORED by WebKitGTK (the Linux webview). The shell sized itself to `100vw/scale` × `100vh/scale` expecting `zoom` to magnify it back to full size; on Linux the magnification never happened, so at the default uiScale of 1.3 the whole app rendered at 1/1.3 ≈ 77% of the window, leaving black bands on the right and bottom (a cross-platform default-parity P0 — 1.3 ships out of the box). Switch to `transform: scale(var(--ui-scale))` + `transform-origin: top left`, which scales identically on every engine and doesn't alter how vw/vh resolve, so `declared (100vw/scale) × scale` fills the viewport exactly. Drop the inline `zoom` (keep setting the `--ui-scale` CSS var the transform reads). Verified on the real WebKitGTK webview (Tauri debug build, localStorage uiScale=1.3): shell now fills edge-to-edge — header, content, and logs footer all reach the window edges; no black bands. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> feat(engines): on-demand unload of subprocess-engine sidecars (Action 13) (#406) Completes the dynamic engine load/unload slice. The idle reaper (#401) frees sidecar VRAM after 5 min; this adds a user-initiated "free VRAM now" path so multi-engine users don't have to wait: - subprocess_backend: `list_live_sidecars()`, `unload_sidecar(id)`, `unload_all_sidecars()` via a shared `_force_reap(predicate)` — busy-guarded exactly like the idle reaper (non-blocking lock; a sidecar mid-synth is skipped, never interrupted; next request respawns it). - system.py: `/model/loaded` now surfaces live sidecars as unloadable rows; `/model/unload/{sidecar:<id>|sidecars}` frees one or all. The existing generic flush panel picks these up with zero frontend change. Also refresh CLAUDE.md stale version notes: main is 0.3.6 (latest release v0.3.5 + 1 patch); the v0.3.0-as-unreleased framing in the project/cadence notes is corrected to the v0.3.x continuous-to-main reality. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> fix(persona): preserve design kind + vd_states across share/import (Wave 5 §R3) (#405) The persona-gallery surface already exists (VoiceGallery Community zone + community.py manifest + marketplace .omnivoice bundles). The blocker for §R3's 'synthetic-only' gate was data integrity: a *designed* persona lost its kind='design' (and vd_states) when imported from the community gallery or round-tripped through a bundle — silently demoting it to a clone. - community.py /use: a 'preset' (rendered from instruct) imports as kind='design'; a 'voice' (real reference clip) as 'clone'. - marketplace.py: extract a pure _bundle_metadata() (dedupes export+publish) that captures kind + vd_states; import restores them. Old bundles without the keys import as 'clone' (backward-compatible). This makes 'accept only designed/synthetic voices' enforceable instead of everything defaulting to clone. No new persona-gallery feature was built — that would duplicate the existing community/marketplace surface. 4 torch-free tests (isolated DB): _bundle_metadata captures design + defaults to clone; import round-trip preserves design kind+vd_states; legacy bundle → clone. docs §R3 status updated. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> feat(audiobook): Audiobook tab — script → plan → m4b (Wave 5 UI) (#404) Frontend for the audiobook backend (#402/#403): a dedicated Audiobook tab. - pages/AudiobookTab.jsx: script textarea + default-voice picker (reuses the app's profiles), 'Preview plan' (POST /audiobook/plan → chapter list) and 'Create' (POST /audiobook → reads the SSE stream, shows per-chapter progress + assembling, then an <audio> player + m4b download via the /audio mount). - api/audiobook.ts: typed plan() + generate() (returns the raw streaming Response). - utils/sseParse.js: pure splitSSEBuffer/parseSSELine helpers for reading the POST event-stream (EventSource is GET-only) — unit-tested (the buffer/line handling is the easy thing to get subtly wrong). - NavRail + App.jsx wiring (lazy tab, hideSidebar); i18n keys in en.json. All strings via i18n (CJK gate green). 7 new SSE tests; full vitest 326 + vite build green. Runtime-unverifiable here (Tauri webview) — wants an in-app pass. docs §R3 updated. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> feat(audiobook): synth job → chapterized m4b, SSE progress (Wave 5) (#403) Completes the audiobook backend: POST /audiobook renders each chapter through the active TTS engine (synthesize_chapter + chunked_tts), writes per-chapter WAVs, then muxes a chapterized m4b (FFMETADATA1 chapters via build_m4b_cmd + concat demuxer). Progress streams as SSE (started/chapter/assembling/done/ error), recorded to job_store. ffmpeg-gated — emits an error event and stops when ffmpeg is absent (m4b is the only output). - services/audiobook.build_concat_list: pure ffmpeg concat-list builder with proper single-quote escaping (no arg injection). Unit-tested. - router: voice resolution (compact form of generation.py's locked/design/ clone cases) cached per id; OmniVoice native model path + generic TTSBackend path; chapter synthesis runs on the GPU pool, ffmpeg via run_ffmpeg. Reuses the tested building blocks from #402 (parser, synthesize_chapter, FFMETADATA + m4b argv builders) — the new router glue is thin and import-checked by CI. Deferred: epub/pdf ingest, ACX loudnorm mastering, crash-resume, UI. 15 audiobook tests (added concat-list); docs §R3 updated. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> feat(audiobook): chapterized audiobook core + plan preview (Wave 5) (#402) * feat(audiobook): chapterized audiobook core + plan preview (Wave 5) First cut of the long-form vertical (parity §R3). Engine-agnostic core in services/audiobook.py: - parse_audiobook_script: pure parser. Markdown '# H1' headings → chapters; inline [voice:NAME] switches the narrator; [pause …] is delegated to the shared omnivoice.utils.text.parse_pause_markers so audiobooks and single-shot synthesis keep one pause dialect. Returns a chapter/span plan. - synthesize_chapter: orchestration via an injected synth(text, voice) callable (reuses chunked_tts split + crossfade, stitches inter-span silence) — so it's unit-testable with a stub backend, no model/GPU. - build_chapter_ffmetadata + build_m4b_cmd: pure FFMETADATA1 [CHAPTER] builder and faststart-m4b concat-demux argv (bitrate-validated, no injection). POST /audiobook/plan returns the parsed plan (no TTS/ffmpeg, no side effects). Deferred (follow-ups): the streaming synth job + chapterized-m4b run, epub/pdf ingest (new dep), ACX loudnorm mastering, crash-resume, UI. 14 tests: parser (chapters/voice/pause/intro/empties/to_dict), FFMETADATA offsets+escaping, m4b argv + bitrate guard, and stub-synth orchestration (span+silence stitching, voice threading). docs §R3 status updated. * fix(audiobook): linear-time regexes (CodeQL ReDoS) CodeQL flagged polynomial backtracking on user-provided input in three regexes reachable from the new POST /audiobook/plan endpoint: - _VOICE_RE: \s*(...)\s* → single [^\]]* class, stripped in code. - _HEADING_RE: trailing [ \t]* removed; title captured greedily + stripped. - _PAUSE_RE (omnivoice/utils/text.py): the numeric spec is now an atomic group (?>…) so its leading \s+ can't backtrack against the trailing \s*. Behavior-preserving (Python >=3.11 already required); 14 pause tests + 14 audiobook tests green. * fix(audiobook): require non-space heading title start (CodeQL ReDoS) The previous _HEADING_RE '[ \t]+(.+)' still let the leading whitespace class and the title '.+' both match the same tab run (overlap → polynomial). Anchor the title capture with \S so the two can't overlap. 14 audiobook tests green. * fix(audiobook): exclude '[' from voice-tag content (CodeQL ReDoS) [^\]]* still matched '[', so a run of nested [voice: prefixes produced overlapping finditer match attempts → O(n^2). Excluding both brackets ([^\]\[]) makes matches non-overlapping and linear. A voice name never contains a bracket. 14 audiobook tests green. --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> feat(engines): idle-reap subprocess-engine sidecars to free VRAM (Wave 13) (#401) Parity Action 13 (dynamic load/unload), subprocess-engine half. A subprocess engine's sidecar holds a process — and, for GPU engines, VRAM — for the life of the backend, even after the user switches engines. The default in-process OmniVoice model already idle-unloads (model_manager.idle_worker); this gives the subprocess engine class the same treatment. subprocess_backend gains a background reaper (lazy daemon thread, started on first spawn) that shuts down sidecars idle past OMNIVOICE_SIDECAR_IDLE_TIMEOUT_S (default 300 s; <= 0 disables). The next request transparently respawns one via the existing dead-process relaunch. Safety: the reaper only acts while holding the per-backend lock acquired NON-blockingly, so it can never run mid-op — if an op holds the lock it skips that backend this round. Reuses the idempotent shutdown() (which doesn't take the lock, so no re-entrancy). Each backend tracks last-use and registers in a weak live-set. Scope: subprocess engines only (the heavy, VRAM-holding, process-isolated class). In-process non-default engines and cross-engine VRAM preemption remain TODO — get_active_tts_backend returns a fresh instance per call, so those need an instance-tracking refactor. 6 reaper tests via the stdlib echo sidecar (no torch): kills idle, respawns, skips busy (lock held), recent-use kept, disabled at <=0, ignores dead. The 3 subprocess suites pass together (24). Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> feat(dictation): wire AEC end-to-end in the frontend (Wave 8, opt-in) (#400) Completes Action 8: dictate-over-playback echo cancellation now works end-to-end, gated behind a new off-by-default 'aecEnabled' pref so the standard dictation + playback paths are untouched when off. - utils/aec/{pcm,farEndBus,micCapture,playbackTap}.js + public/aec-worklet.js: AudioWorklet captures the mic as raw int16 PCM; a player tap routes playback output through Web Audio to a singleton far-end bus. Pure framing/encode helpers are unit-tested. - CaptureWidget: when aecEnabled, opens /ws/transcribe?aec=1, streams tagged PCM (0x00 mic / 0x01 far-end) instead of MediaRecorder/WebM. Default path unchanged; no POST fallback in AEC mode (the WS is the sole channel). - WaveformPlayer: while actually playing AND aecEnabled, taps its decoded output as the echo reference. Gated on isPlaying so only the one active player holds an AudioContext (well under the browser cap); audio stays audible (source always reconnected to destination). - Settings → Capture: AecPanel toggle. prefsSlice: aecEnabled (persisted). Runtime-unverifiable here (jsdom has no Web Audio); needs in-app testing in the Tauri shell. 7 new pure-helper tests; full vitest (319) + vite build green. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> feat(dictation): opt-in NLMS AEC for dictate-over-playback (Wave 8b) (#399) * feat(dictation): opt-in NLMS AEC for dictate-over-playback (Wave 8b) Dictating while OmniVoice plays audio (TTS preview, dub, video) leaks the loudspeaker signal into the mic, and the streaming ASR transcribes that bleed. Browser echoCancellation varies per platform/webview — it can't be a cross-platform default — so this adds a server-side canceller that behaves identically everywhere. services/aec.py ports Patter's NlmsEchoCanceller (MIT): a time-domain NLMS adaptive filter with a Geigel double-talk detector, warm-up step ramp, and far-end staleness pass-through. /ws/transcribe gains an opt-in '?aec=1[&sr=]' mode: frames are raw int16 mono PCM tagged with a 1-byte prefix (0x00 mic, 0x01 playback reference); the mic is cleaned against the reference before buffering, and the cleaned PCM is muxed via stdlib wave (not ffmpeg). Without the param the protocol and behaviour are byte-for-byte unchanged. Backend ships dark (no new deps — numpy already pinned); frontend far-end streaming is a follow-up. Tests cover echo attenuation, double-talk preservation, cold/stale pass-through, param validation, and the framing helpers — all pure-numpy/stdlib so they skip the torch ASR stack. * test(capture_ws): stubs accept the new pcm_sr kwarg _transcribe_buffer/_transcribe_buffer_full gained an optional pcm_sr kwarg for the AEC PCM path; the protocol-test stubs had fixed signatures and raised TypeError on it, so the handler sent 'error' instead of 'final'. Accept **kw in the stubs. --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> feat(asr): crash-isolated faster-whisper subprocess backend (Wave 4.2) (#393) * feat(asr): crash-isolated faster-whisper subprocess backend (Wave 4.2) Native ASR engines (faster-whisper / CTranslate2) can segfault on GPU teardown — a process-level crash that kills the whole backend. Running the engine in a child process turns that into a failed job: the sidecar dies, the parent raises a decorated error (engine id + device), and the next request respawns a fresh sidecar. - services/subprocess_asr.py: SubprocessASRBackend reuses SubprocessBackend's wire protocol + lifecycle — including respawn-on-dead-process (_spawn relaunches when the child isn't alive) and GPU-slot acquire/release — adding a 'transcribe' op (the TTS 'generate' surface is stubbed). IsolatedFasterWhisperBackend wraps faster-whisper using the PARENT venv (already a dep — only the process boundary is new); opt-in via OMNIVOICE_ASR_BACKEND=faster-whisper-isolated. - engines/_asr_sidecar/main.py: the faster-whisper runner (stdlib wire protocol; torch/CT2 import lazily so the ready handshake fits the timeout). - engines/_echo/main.py: a 'transcribe' echo op so the round-trip + crash recovery are testable without a real engine. - asr_backend._REGISTRY is now a lazy dict (mirrors the TTS registry) so the isolated backend lists/resolves without importing the subprocess stack unless selected. Tests (echo sidecar, stdlib-only): round-trip, single long-lived sidecar across calls, crash-mid-transcribe → decorated error + backend healthy + next call respawns, registry exposure, generate-not-supported. Spec 7 / parity program Wave 4.2. * fix(asr): deterministic crash test + drift marker for lazy ASR registry (Wave 4.2 CI) CI surfaced two issues: - The echo crash test relied on the crash-AFTER-reply hook, whose reply may still reach the parent (timing-dependent) — and a leaked OMNIVOICE_ECHO_CRASH from a sibling subprocess test poisoned the non-crash tests. Fix: a deterministic OMNIVOICE_ECHO_CRASH_NO_REPLY hook that exits BEFORE replying (guaranteed dead pipe → decorated error), and the asr fixture clears both crash envs so the round-trip/two-call tests can't inherit a leak. - check-docs-drift's _ASR_MARKER didn't match the new lazy registry line (_LazyASRRegistry({); updated the marker + the self-test fixture. Verified the no-reply crash hook by driving the sidecar directly (reply=None, exit 1); drift self-test + real-repo check green. * fix(asr): allowlist the 'segments' op so transcribe replies aren't dropped (Wave 4.2 CI) The parent's PARENT_INBOUND_OPS frozenset gated inbound sidecar frames but never included 'segments' — the ASR transcribe reply op. _recv() dropped the frame as disallowed, tail-recursed, hit EOF, and returned None, so every transcribe surfaced as a bogus 'sidecar crashed mid-transcription'. TTS ('audio') was allowlisted; ASR ('segments') was missed. Add it (and list 'transcribe' in the informational SIDECAR_INBOUND_OPS), update the exact-shape allowlist test. --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> docs(spec): Voice Console 10/10 polish spec (#394) * docs(spec): Voice Console 10/10 — pinned action bar, two-kicker hierarchy, unified presets, identity-first right rail * fix(spec): ASCII '+' in wireframes — clears the CJK gate --------- Co-authored-by: mergetest <test@local> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> fix(layout): rail-right + hidden-sidebar left a phantom 48px gap (#398) The 2-column sidebar-hidden template still sent the nav rail to grid-column 3 — it overflowed into an implicit column and the reserved 48px slot rendered as a dead black band beside it. Rail now maps to column 2 under that combo (and history-panel to column 1 under rail-right+collapsed). Verified in WebKit: main/footer edges meet the rail exactly. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> feat(studio): Voice Console 10x P4 — contrast, radiogroups, focus rings, reduced motion (#397) Craft pass per docs/specs/voice-console-10x.md §3 (a11y gate, 8-pt rhythm) and §4 acceptance: - Contrast: solarized --chrome-fg-muted #657b83 (2.92:1 on --chrome-bg) → #899da4 (4.59:1, same hue); all other themes already pass. Readable kickers/labels that rode the decorative dim token (identity-line kicker, starting-points label, wv active kicker, slider kicker, describe hint) switch to the muted token. --chrome-fg-dim itself stays decorative-only. - Radiogroups: design category chip groups are role="radiogroup" (aria-label = category name) with role="radio" + aria-checked chips, roving tabindex, and ArrowLeft/ArrowRight selection. The shared Segmented control already ships radio semantics via Radix ToggleGroup (role="radio" items + RovingFocusGroup) — left untouched. - Focus: one shared :focus-visible rule (outline 2px chrome-accent, offset 1px) for the 10x controls; verified none of them suppressed outlines without replacement. - Reduced motion: dub-skel-shimmer / dub-pulse / dub-stepper-spin, heart-glow / logs-spin, wf-spin, and the FloatingPill dot-pulse / progress-sweep now stop under prefers-reduced-motion (FirstRunSetup's frs-alarm / frs-hw-pulse coverage verified pre-existing). - aria-live: FloatingPill already carries role="status" aria-live="polite" (verified); the action bar gains a persistent sr-only polite status region announcing generation start/finish. - 8-pt audit (CloneDesignTab.css): 5→4 gap, 5px 10px→4px 10px and 3px 9px→4px 8px paddings, 7→8 grid gap. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> feat(studio): Voice Console 10x P3 — identity recipe line, Active-voice card, empty-state verbs (#396) - category chips collapse behind an 'Identity' recipe line (male · elderly · …) that the describe box rewrites live; all-Auto starts expanded - right rail leads with an ACTIVE VOICE card: name, kind badge, recipe, identity sample player, + New; empty card carries verbs - empty saved-voices states point at the action ('Describe one in Voice ←') - script column stacks naturally (no void before VOICE) Spec: docs/specs/voice-console-10x.md §1.5, §2. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> feat(studio): Voice Console 10x — P1 pinned action bar + P2 hierarchy/presets/insert popover (#395) P1 (fold): language, steps, and the overrides disclosure move into a pinned action bar with SYNTHESIZE — the primary CTA is visible at every window size (verified 1280×720 and 1400×900 in WebKit); Cmd/Ctrl+Enter synthesizes from anywhere; overrides expand upward above the bar. P2 (hierarchy/consistency): two kickers only (SCRIPT, VOICE — method toggle inline); the four redundant headers removed; the old PROMPT preset chips merge with personalities into one edge-faded scrollable 'Starting points' lane; the 14-chip tag wall becomes a ⊕ Insert popover at the script corner (click-outside dismiss). Spec: docs/specs/voice-console-10x.md §1. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> feat(settings): Hugging Face mirror (HF_ENDPOINT) for restricted networks (Wave 4.3) (#391) The model manager already lists/deletes cached models; this adds the remaining high-value slice — an in-app HF mirror setting so users behind restricted networks (e.g. the Great Firewall) can route downloads through hf-mirror.com or any HF_ENDPOINT. Persisted to the durable per-user env (survives Tauri/Finder launches); HF reads HF_ENDPOINT at import, so the override applies on restart (surfaced in the UI). - GET/PUT /api/settings/hf-mirror (loopback-gated): presets (official + hf-mirror.com), http(s) validation, empty clears to official. - Models-tab panel with quick-picks + free-text field + restart note. (Skipped 'hf cache verify' — version-fragile across huggingface_hub releases and low value vs the mirror, which the China/Russia network research flagged as the real gap.) 3 endpoint tests (default, set+trim+clear, non-http rejection). Spec §R4(c) / parity program Wave 4.3. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> fix(mlx): harden import guards against PyInstaller dylib failures (Wave 4.4) (#390) MLXWhisperBackend / MLXAudioBackend is_available() caught only ImportError. In a PyInstaller bundle mlx's native dylib/metallib can fail to load even when the package imports, raising OSError/RuntimeError — which would propagate and crash the registry scan instead of reporting the backend unavailable. Broaden to (ImportError, OSError, RuntimeError) so the picker falls back cleanly. 6 tests across all three exception types. The capture ASR path already prefers MLX Turbo on Apple Silicon (get_capture_asr_backend), so this hardening is the remaining slice of Spec 6 / Wave 4.4. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> fix(history): real 2-line title clamp + de-noised display + click-to-expand (#389) The old max-height:3em guillotined the third line mid-glyph. Now a true -webkit-line-clamp with ellipsis, leading [tag] control tokens stripped from the display (full text stays in the tooltip and restore flows), and clicking the title toggles the full prompt. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> docs(engines): uv dedupe + sidecar torch-pin disk-usage policy (Wave 4.5) (#392) Explain why dedicated-venv engines (IndexTTS2) add disk (a second torch + CUDA libs: Linux cu128 ~0.83 GiB, Windows ~3.2 GiB), and how uv's link-mode dedup (clone on macOS/Linux, hardlink on Windows) shares identical wheels for free — provided UV_CACHE_DIR and the venvs are on the same filesystem. Key policy: pin the same torch build as the parent whenever the engine allows, since only identical wheels dedupe; UV_LINK_MODE=hardlink on Linux ext4. Linked from the IndexTTS engine doc. Spec §R4(a) / parity program Wave 4.5. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> docs: official Docker Hub image palashdeb/omnivoice-studio (#388) Link the published Docker Hub repo (https://hub.docker.com/r/palashdeb/ omnivoice-studio) as an official image alongside GHCR in the README install list and docker.md header. Same images, same tags. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> perf(dub): retime batches seek to their window instead of decoding from frame 0 (#387) Each batch now uses input seeking (-ss before -i, frame-accurate under re-encode) plus a bounded read (-t window+0.5s), with chunk times shifted into window-relative coordinates — long-video Smart Fit exports drop from O(n²) decode cost to O(n). Fixes #382 Co-authored-by: mergetest <test@local> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> chore(i18n): backfill 36 studio-overhaul keys into en.json + all 20 locales (#386) Canonical English added from the t() defaultValues introduced by the overhaul PRs (#374-#381); 20 parallel translation passes added each key to every locale (placeholders, product names, and existing per-locale terminology preserved). All locale files parse; CJK gate + 312 frontend tests green. Fixes #383 Co-authored-by: mergetest <test@local> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> fix: stale-chunk preload recovery (#380) + surface unsupported-GPU-arch in notifications (#284) (#385) - #380: vite:preloadError (old hashed assets after an update) triggers a one-time reload to pick up the fresh manifest; session flag prevents loops - #284: check_device_compatibility's warning (e.g. Blackwell sm_120 on a pre-cu128 torch) now appears in the notification panel as an error with the pip fix — a log line never reached affected users while synthesis silently produced noise. Cached once per process. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> fix(player): WaveformPlayer paused itself on play — idempotent claim + hard listener teardown (#384) Live-debugged in Playwright WebKit with a pause() stack hook: the media 'play' event fired twice (a stale WaveSurfer instance's listeners survive a destroy() that throws mid-teardown under StrictMode double-mount), so the second claimPlayback stopped the current owner — this very element. play → instant self-pause → 'click does nothing'. - 'play' handler only claims when it doesn't already own the slot - per-instance stale flag inert-izes leaked handlers - cleanup detaches handlers (unAll) BEFORE destroy so a throwing destroy can't leak them Verified in WebKit: paused=false, currentTime advancing, 0 stray pause calls. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> fix: issue-triage batch — timeline box flicker, truncated-model detection, stale history pruning (#381) - #373: drop will-change:transform on the segment lane (persistent compositor layer made the semi-transparent boxes vanish during playback/drag on some Windows GPUs) + raise region alpha 0.30→0.45 - #352: validate a finished snapshot actually contains weights (>5 MB file) so interrupted downloads fail at install time with a re-download hint; loader translates the opaque transformers error into the same guidance - GET /history prunes rows whose audio file is gone instead of serving dead 404 players forever Co-authored-by: mergetest <test@local> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> fix(player): WaveformPlayer click did nothing — media element never got a src (#379) With an external `media`, wavesurfer's `url` option only fetches for peak decoding and never assigns the element's src — so the waveform drew but play() had nothing to play. Set src on the in-DOM <audio> via JSX (same pattern as WaveformTimeline) and stop passing `url`. Also surface playPause() rejections instead of swallowing them. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> feat(studio): consolidate Clone + Design into one Voice workspace (spec P4) (#378) One 'studio' navigation mode replaces the clone/design pair; the split lives on as a 'Define voice' toggle (From audio / By design) at the top of Voice Source. Selecting a saved profile sets the method from its kind. - uiSlice: AppMode + 'studio'; defineMethod ('audio'|'design') persisted - legacy shims: localStorage mode + restoreHistory map clone/design → studio + method; history mode VALUES unchanged - NavRail/Header: single Voice entry (Fingerprint, #d3869b) - CloneDesignTab/WorkspaceVoices/useTTS/useProfiles/Gallery/Launchpad/ Sidebar: definition-method semantics moved off the navigation mode Build clean; 312/312 tests; tsc clean; no setMode('clone'|'design') left. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> fix(dub+win): dialect↔cinematic guidance loop + WinError 193 ffmpeg validation (#377) * fix(dub): break the dialect↔cinematic guidance loop (#372, #373) - Cinematic toggle refuses the pick when no LLM endpoint is configured, pointing at Settings → Credentials → LLM endpoint - backend Fast fallback now syncs the quality toggle to 'fast' - the dialect warning no longer fires alongside the cinematic-no-LLM warning (the pair formed the loop), and both messages point at the LLM endpoint settings instead of each other Fixes #372 * fix(ffmpeg): validate resolved ffmpeg/ffprobe actually runs — fall through on WinError 193 (#360, #361, #362) A corrupt or wrong-arch imageio-ffmpeg download (and WindowsApps alias stubs) passes os.path.isfile/shutil.which but explodes at spawn with '[WinError 193] %1 is not a valid Win32 application', killing transcription with an opaque 500. Every resolution step now probes the candidate with '-version' (cached per process), logs the rejected basename, and falls through to the next source. Fixes #362 Fixes #361 Fixes #360 --------- Co-authored-by: mergetest <test@local> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> feat(profiles): unified profile model — kind discriminator + stored design params (spec P3) (#376) Migration 0005_unified_profiles (0004 taken by mcp bindings): - voice_profiles.kind TEXT DEFAULT 'clone' ('clone' | 'design'), backfilled - voice_profiles.vd_states TEXT NULL — JSON of design category picks - mirrored in _BASE_SCHEMA; idempotent _has_column guards; downgrade drops POST /profiles: - ref_audio now optional; kind + vd_states form fields with validation (clone requires audio; design requires vd_states JSON object + instruct) - design profiles render a deterministic identity sample (seed 42) through the shared archetype renderer — one TTS code path POST /generate: - profile resolution branches on profile.kind (authoritative) instead of the brittle is_locked/instruct inference; legacy pre-0005 rows keep the old inference as fallback; history.mode records profile.kind Frontend: - 'Save design as profile' in the Design tab (vd_states + buildDesignInstruct) - selecting a design profile restores its sliders (vd_states) for re-editing Also unforks the alembic chain (0004_mcp + my 0004 both revised 0003 → multiple heads broke alembic upgrade head and the 0003 migration tests). Tests: tests/test_profile_unification.py — validation, design-create with mocked renderer, migration up/backfill/downgrade. 18/18 profile tests, 312/312 frontend, related backend suite green. Note: docs/specs/voice-studio-unification.md (on feat/studio-ux-overhaul) still says 0004 — renumber to 0005 when branches meet. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> feat(studio): workspace UX overhaul — right-side panels, shared waveform player, dub pipeline UX, setup polish (#374) * feat(studio): workspace UX overhaul — right-side panels, shared waveform player, dub pipeline UX, setup polish, UI-wide fixes Voice workspace (specs: docs/specs/voice-studio-unification.md, workspace-connectivity.md): - Right-side panels replace the left sidebar for clone/design and dub: WorkspaceVoices (saved profiles), WorkspaceHistory (scoped history with All/Clone/Design filters), WorkspaceProjects (dub projects) - Prompt restacked over Voice Source in one definition column (spec §1) - Gallery "Use voice" now hands off via pendingProfileId and lands in clone - Shared <WaveformPlayer> (wavesurfer + in-DOM media element for Tauri WebKit, blob routing via preview endpoint, 404 -> "audio file missing") replaces every bare <audio controls>; lazy-mounted via IntersectionObserver Dub: - Pipeline stepper (Upload -> Prepare -> Transcribe -> Edit -> Generate -> Export) - Multi-language preview switcher pills (Original + per-track, ElevenLabs-style) - Batch multi-language generation via langOverride loop - FloatingPill: bottom-center, suppressed on its homeMode tab (no dup progress) - Transcript skeleton shimmer (no fake data), progress overlays the video, exports demoted behind Generate, empty right-panels collapse Chrome/layout: - Nav rail is full-window-height; content yields to the logs footer via padding-bottom; footer joins the rail edge (no overlap at any UI scale) - UI scale 60–175% slider with zoom-compensated container sizing - LogsFooter: merged single Logs tab when collapsed, per-source tabs on expand; Updates chip lives with the logs tabs - Gallery: three independently scrollable filter lanes, uniform 26px controls - Font picker as live-preview grid; double-click titlebar maximize fixed (single mousedown detail-2 handler) First-run: - Setup wizard: pinned action row + scrollable content at every window size, one-line head-ellipsized paths, height budget for short windows, library rows back to one-line grammar, raw i18n key + duplicate host fixed Performance/i18n/consistency sweep (10-agent scan, 47 fixes): - i18n locales lazy-loaded per language (i18n chunk 1.84 MB -> 76 kB) - Undefined CSS vars replaced with real tokens across 8 stylesheets; hardcoded hexes tokenized; emoji swept to lucide icons app-wide - Poll throttling (sysinfo subscription scoped to Header, logs 45s when collapsed, rAF only during playback), hardcoded strings moved to t() Build clean; 312/312 tests pass. * fix(studio): re-flow clone/design columns (grid rows collapsed in restack) + strip placeholder emoji across locales The base .studio-column grid (minmax(0,1fr) rows) collapsed to 0 height inside the new auto-height definition column, overlapping every panel in design mode — found via Playwright visual pass. Columns now re-flow as natural-height flex stacks. Also removed the leftover pencil emoji from clone.prompt_placeholder in all 21 locales. * feat(design): compact the design control stack — 2-up facet selects, scrollable tag row, tighter rhythm English accent + Chinese dialect dropdowns share one row (full-width on narrow), insertable tag chips collapse from three wrapped rows to one scrollable line, and describe/personality spacing tightens — the whole design stack now fits a single viewport. * docs(spec): unification migration renumbered 0004 — upstream 0003 is voice-profile consent * fix(ci): clear hardcoded-CJK gate — ASCII '+' in spec wireframes, reword voiceIcons comment * docs(spec): migration is 0005 — 0004 taken by mcp bindings upstream --------- Co-authored-by: mergetest <test@local> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> ci(docker): also publish to Docker Hub palashdeb/omnivoice-studio (#375) Push the same images (same tag set: :latest rolling main, :stable/:X.Y.Z releases, :sha-) to docker.io/palashdeb/omnivoice-studio alongside GHCR. Gated on DOCKERHUB_USERNAME/DOCKERHUB_TOKEN secrets — without them the build still publishes to GHCR only. Docs-sync: docker.md mirror note. Requires repo secrets: DOCKERHUB_USERNAME, DOCKERHUB_TOKEN. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> feat(dub): Smart Fit phase B — per-segment video retime export, drift absorption, fitted subtitles (#350) * feat(dub): Smart Fit phase B — per-segment video retime export, drift absorption, fitted subtitles Executes the video side of the Smart Fit plans persisted by Phase A (job["fit_plans"], #347) at export and preview time. Backend: - services/video_retime.py (new, clean-room): two-tier retime executor. ≤48 chunks → the proven single-pass split/trim/setpts/concat filter_complex; above → batches of 40 chunks rendered to intermediate slices (identical libx264 medium/crf20 params, keyframe at t=0) joined losslessly with the concat demuxer. Slices are CFR-resampled (fps=) because setpts leaves VFR-ish timestamps that broke tpad and drifted a frame per retimed chunk on ffmpeg 7.x. Temp slices cleaned on success AND failure/abort. - Drift absorption: fitted track longer than retimed video → freeze-frame tail (tpad=stop_mode=clone) predicted into the last slice / single-pass graph, with residual mux-side tpad; video longer → silence-pad the dub audio chain (apad=whole_dur). ±50 ms tolerance. - VFR guard: probe r_frame_rate vs avg_frame_rate; normalise with fps= before trim/setpts; probe failure degrades gracefully. - Plan resolution: _video_retime_plan_for spans legacy video_stretch_plans (byte-identical resolution + command construction) and fit_plans, gated on the track's own timing_strategy so stale plans never retime a track re-generated under another strategy. - Fitted subtitles: /dub/srt + /dub/vtt accept ?lang= and serve cue times from fitted_segments for Smart Fit tracks; _write_burn_srt does the same for burn-in. burn_subs+retime is now allowed for smart_fit (burn runs AFTER the retime graph); still rejected for legacy stretch_video. - /dub/preview-video resolves the same plan so in-app preview matches export. - Fallback ladder: batch encode failure/timeouts → un-retimed export with a structured core.failure warning (X-Dub-Export-Warning header + job["last_export_warning"]); concat join rejection → one single-pass retry while ≤96 chunks; abort → 409 + proc kill via run_ffmpeg job_id registration (/dub/abort reaches export encodes now) + temp cleanup. Frontend: - Export drawer passes ?lang= on subtitle exports and shows an i18n'd re-encode cost note (~0.5–2× video length on CPU) when a retiming strategy is active — translated in all 21 locales. Tests: tests/test_smart_fit_export.py — plan resolution, batch math, graph parity + new stages, fitted-cue SRT/VTT/burn selection, burn policy, VFR detection; ffmpeg-gated integration renders both executor tiers (batch size forced to 2) and the real /dub/download endpoint, ffprobing durations within ±50 ms across both pad branches. All existing dub export/subtitle/preview/timing tests pass unchanged. Refs docs/competitive-analysis.md Action 1 (dub-length fitting v2); completes Smart Fit (Phase A = #347). * fix(security): sanitize Smart Fit retime work paths at every sink (CodeQL py/path-injection) The job_id-derived retime work path (retimed_*.mp4 / preview_retimed_*.tmp.mp4) flowed unguarded from dub_export into prepare_smart_fit_video / render_retimed_video and their derived slice/concat paths and ffmpeg argv. Apply the repo's proven inline realpath+startswith containment pattern (helpers/commonpath are not recognized — see #309/#328/#329/#348): - dub_export.py: validate work_path against DUB_DIR at both construction sites (export + preview) and pass the validated realpath onward. - video_retime.py: make both entry points self-defending — realpath + DUB_DIR containment on out_path/work_path before any derivation, raising RetimeError(stage="plan") on escape; slices_dir/slice_path/list_path and RetimeDecision.file_path now all derive from the sanitized value. DUB_DIR is read via module attribute so test fixtures reloading core.config work. - ffmpeg_utils.py: document that all caller-assembled argv paths are realpath-validated upstream. - tests: sandbox DUB_DIR in the executor integration tests (tmp_path) so the new guard sees the test workspace. No behavior change for valid (server-built) paths — the guard only fires on traversal. * test(smart-fit): patch DUB_DIR on video_retime's own config ref — survives suite-wide reload The retime guard reads video_retime._config.DUB_DIR at call time; the sandbox fixture patched a fresh 'import core.config' instead. Another test reloads core.config in the full suite, so the two module refs diverged — the patch missed and the guard rejected the test's tmp paths (green in isolation, red in CI's full run). Patch the exact ref the guard dereferences. * fix(dub): resolve DUB_DIR live at call time in retime guards — survive full-suite reload The path-containment guards bound DUB_DIR via a module-level 'from core import config as _config'. Other tests importlib.reload() core.config (sandboxing OMNIVOICE_DATA_DIR), after which the guard checked containment against a stale DUB_DIR while dub_export built the path under the reloaded one — every retime path then 'escaped the dub workspace' (green file-alone, red full-suite: the 5 integration failures CI hit). Re-import DUB_DIR locally in each guard so it always reads the current sys.modules value; simplify the sandbox fixture to patch the canonical module. Verified: full backend suite green on the Smart Fit tests (the 2 remaining settings_store failures are pre-existing on main, unrelated — local data-dir artifact). * fix(security): clear CodeQL alerts on Smart Fit export — job_id allowlist, proc-registry decouple - py/path-injection (8, video_retime.py): validate job_id with a strict inline regex allowlist (re.fullmatch [A-Za-z0-9_-]{1,64}) at the entry of dub_download and dub_preview_video, before it reaches any filesystem path or ffmpeg argv. The existing realpath containment guards stay as defense-in-depth; the regex barrier is the sanitizer CodeQL recognizes through the service-module call chain. - py/log-injection (4): newline-strip job_id inline at the logger calls in ffmpeg_utils.run_ffmpeg and the two retime-fallback logger.error sites in dub_export. - py/empty-except (3): best-effort cleanup os.remove handlers now log the OSError at debug instead of bare pass (video_retime + both dub_export mux finally blocks; _discard_tmp too for consistency). - py/cyclic-import (2): break the dub_pipeline <-> ffmpeg_utils cycle for real — the subprocess registry (register_proc/unregister_proc/ kill_job_procs/has_active_procs + state) moves to a new stdlib-only leaf module services/proc_registry.py. ffmpeg_utils now imports it at module top (no lazy import); dub_pipeline re-exports every name so dub_core aliases and tests keep working unchanged. No behavior change for valid inputs; invalid job ids now get a clean 400 instead of a 404/containment error. * fix(dub): address #350 review — cancelled-vs-failed retime, logged best-effort excepts, redacted probe logs, narrowed test assert - rc<0 (killed by user cancel) now raises RetimeError(stage='aborted') instead of reporting an ordinary render failure (CodeRabbit) - best-effort cleanup/QC-event excepts log at debug instead of bare pass (CodeQL empty-except x3) - probe failure logs use basename, not full user paths (CodeRabbit/CodeQL) - test_render_cleans_slices_on_failure asserts RetimeError, not Exception Rebuttals (no change needed, see PR comment): fitted-cue subtitles track the fitted AUDIO timeline which is correct even on retime fallback; the planner only emits stretch ratios >1 so the early-exit guard is a true no-op check; '\'' is ffmpeg's own utility quoting for concat lists; has_active_procs is an intentional re-export (noqa'd). --------- Co-authored-by: mergetest <test@local> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> feat(dub): regenerate subtitle timeline on the fitted timeline (Wave 3.1) (#371) Smart Fit Phase A (planner) + the export-side video retime + audio stretch already shipped (#347 + dub_export stretch filter). The last piece of Spec 1 was the subtitle timeline: under stretch_video the dubbed audio plays at FITTED positions, but the standalone SRT/VTT export still used the original segment times — so external subtitles drifted against the dubbed video. - services/fitted_subtitles.py (pure, tested): map_time_to_fitted() + fitted_cues() remap original cue times onto the same per-chunk {orig→new, stretch_ratio} plan the video stretch uses, with a monotonicity guard. - dub_export SRT + VTT endpoints: when a job used stretch_video, cues are regenerated from the plan (subtitles track actual dub placement); no plan → original times, unchanged. New optional ?lang= selects the track. 7 pure tests (chunk-bound mapping, linear interpolation, unit-rate tail, fitted cues, monotonicity, empty-plan identity). Spec 1 (remaining) / parity program Wave 3.1. Co-authored-by: Claude Fable 5 <noreply@anthropic.com> feat(dub): second-pass ASR QC — flag lines whose dub drifts from target (Wave 3.3) (#370) After a dub is generated, re-recognize the synthetic audio and compare what the ASR heard against what we asked the TTS to say. Lines that drift are flagged for the user to re-listen / re-dub — turning subtitle timing and pronunciation from trusted math into measured truth, and doubling as an automatic dub-quality check. Design delta from pyvideotrans (which lets recognized text REPLACE the subtitles wholesale): we keep the generated text authoritative and use the second pass only for MEASUREMENT — a per-line drift score + measured start/end that feed the incremental re-dub loop, never silently overwriting the translation. - services/dub_qc.py (pure, tested): word_error_rate (normalized token edit distance, case/punct-insensitive, script-agnostic) + score_dub (matches recognized segments to dub segments by time overlap, concatenates the hypothesis, scores drift, derives measured bounds). - POST /dub/qc/{job_id}: runs the active ASR backend on the dubbed track in the GPU pool, annotates each segment with qc_drift/qc_flagged/ qc_recognized/qc_measured_start-end (non-destructive — content untouched), persists, emits a qc_done job event. Opt-in, never fatal. - Frontend: dubQc() API fn + a red 'Verify' badge on flagged segment rows (en.json keys; other locales fall back). 12 pure scoring tests (identical/substitution/empty/no-overlap/multi-segment matching/measured-timing); endpoint validated in CI. Spec 5 / parity program Wave 3.3. Co-authored-by: Claude Fable 5 <noreply@anthropic.com> feat(dub): per-segment clone references (Wave 3.2) (#369) Cut each long-enough dub segment's clone reference from the isolated vocals at that segment's own timestamps, so the dub of each line carries the prosody/emotion of its source line — finer than one reference per speaker. Reimplemented from the clean-room spec (pyvideotrans per-line ref idea); our design delta is a quality floor with fallback. - services/speaker_clone.py: extract_segment_refs() keyed by segment id; reference transcript is the SOURCE text (text_original), since the vocals slice is source-language audio. Floor at MIN_SEGMENT_REF_DURATION_S=3.0 (not the per-speaker 5.0, which most dialogue lines fall under) — shorter lines are omitted and fall back to the per-speaker clone, so it's a strict improvement, never a regression. - dub_core: run extraction at transcribe (per_segment_refs query param, default on), store job['segment_clones'], default each unassigned segment's profile_id to 'auto-seg:{id}' when it has its own ref, else the existing 'auto:{speaker}'. Forcing per-speaker (per_segment_refs=false) is supported for long-form consistency. - dub_generate _gen: resolve 'auto-seg:' from segment_clones, ahead of the per-speaker 'auto:' path. profile_id is already a fingerprint field, so flipping the mode re-dubs automatically (no _GEN_INPUT_FIELDS change). 7 pure tests over a synthetic vocals wav (own-ref for long lines, short-line omission/fallback, source-text transcript, bounds clamping, floor boundary). Pipeline wiring validated in CI. Spec 4 / parity program Wave 3.2. Co-authored-by: Claude Fable 5 <noreply@anthropic.com> feat(mcp): MCP server v1 — mount on /mcp, per-agent voice binding, stdio shim (Wave 2.2) (#368) * feat(mcp): MCP server v1 — mount on /mcp, per-agent voice binding, stdio shim (Wave 2.2) The FastMCP server (previously dead code, never mounted) is now mounted on the main FastAPI app at /mcp via Streamable HTTP, with its session manager composed into the app lifespan through an AsyncExitStack (best-effort: a missing mcp package or OMNIVOICE_MCP_DISABLE=1 never breaks startup). streamable_http_path set to '/' so the sub-mount lands at /mcp, not /mcp/mcp. Adds the 'mcp' dependency (1.27.x). Per-agent voice binding (Spec 2 headline): each MCP client sends an X-OmniVoice-Client-Id header; generate_speech resolves the voice as explicit arg > the client's binding > global default > app default. New mcp_client_bindings table (alembic 0004 + _BASE_SCHEMA, additive/idempotent), services/mcp_bindings.py (CRUD + resolve_voice + best-effort last_seen), and a loopback-gated REST router (/api/mcp/bindings) the Settings panel drives. New transcribe tool (base64 audio in, 200 MB cap). Stdio shim (backend/mcp_shim, httpx-only, ported from voicebox MIT) proxies stdio clients to the mounted endpoint and forwards OMNIVOICE_CLIENT_ID as the binding header. Settings → Sharing gains an MCP bindings panel. Docs: docs/mcp.md (both connection modes + binding REST) and docs/mcp.json updated to the shim form. Tests: bindings service + resolution precedence + migration up/down (pure, run locally); REST CRUD + mount-not-404 + disable-flag (main-importing, validated in CI). MCP build + mount + initialize handshake verified out-of-band (no torch). Spec: docs/competitive-analysis.md Spec 2 / parity program Wave 2.2. * test(mcp): assert /mcp mount via app.routes, not a lifespan client The two main-importing mount tests ran the app lifespan, which now starts the FastMCP session manager and binds asyncio queues to the test loop — contaminating later lifespan-running tests ('bound to a different event loop'). The mount happens at import time, so inspecting app.routes for the /mcp Mount is the correct loop-free assertion. Same fix shape as the Wave 0.2 consent tests. * test(mcp): stop reload-main poisoning across the MCP test files Root cause of the CI failure: the bindings REST fixture set OMNIVOICE_MCP_DISABLE=1 and reloaded main but never restored it, so a later 'from main import app' in test_mcp_mount saw /mcp un-mounted ({'/audio','/voice_audio'}). Reloading main mutates the shared module for every subsequent test. - REST fixture: drop the disable flag (the mount is harmless without a lifespan), yield the client, and restore main (+ core.config/db) to the default data dir in teardown so the global module is clean again. - test_main_mounts_mcp_route: reload main with the disable flag cleared so the assertion is independent of any earlier reload. --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> fix(settings): remove stray rebase conflict marker in settings.py (#367) A '>>>>>>>' marker from the #365 rebase was committed at the tail of the LLM-endpoint block, making the module unparseable. Strip it; settings.py parses clean and the endpoint tests pass. Co-authored-by: Claude Fable 5 <noreply@anthropic.com> feat(settings): remote LLM endpoint UI — Ollama/vLLM/LM Studio (Wave 2.4) (#365) A focused Settings panel for the OpenAI-compatible LLM that powers cinematic translate, glossary auto-extract, and dictation refinement (Wave 2.1). Persistence reuses the existing TRANSLATE_BASE_URL / TRANSLATE_MODEL / TRANSLATE_API_KEY env vars (already in system.py PERSISTENT_KEYS, restored at startup), so llm_backend/translator resolution is unchanged — vLLM is a verified drop-in, Ollama ignores the key, vLLM/LM Studio require it. - GET/PUT /api/settings/llm-endpoint (loopback-gated): read shape returns base_url, model, masked key, and live availability; PUT treats a null field as unchanged and an empty string as clear (so the key isn't wiped by a base-url-only save). Key is masked to last-4 in the read path, never echoed. - Credentials-tab panel with one-click presets (Ollama/LM Studio/vLLM/ OpenAI), base URL + model + optional key fields, and a reachable/not status badge. 6 endpoint tests (read shape, set+mask, null-unchanged, empty-clears, local-url-no-key, short-key masking); availability assertions guarded on openai being installed. Spec: parity program Wave 2.4 / competitive-analysis §R2 rung 4. Co-authored-by: Claude Fable 5 <noreply@anthropic.com> docs(agentic): OmniVoice as a TTS/STT provider for pipecat/LiveKit (Wave 2.5) (#366) Agentic v1: OmniVoice is a provider, not the orchestrator. Its existing OpenAI-compatible API already serves everything pipecat/LiveKit need (POST /v1/audio/speech with pcm/wav, voice-profile id, speed; default 24 kHz output matching pipecat's OpenAITTSService) — so this is docs + an example + a contract test, no new endpoint. - docs/agentic-voice.md: the provider recipe for pipecat (base_url to :3900/v1) and LiveKit, the remote-backend note (bearer from 2.3), the consent-locked-voice nudge (0.2), and an explicit telephony-is-deferred scope box. - examples/agentic/pipecat_minimal.py: lazy-import skeleton wiring the OmniVoice STT/TTS services (importable without pipecat installed). - tests/test_agentic_provider_contract.py: pins the /v1/audio/speech request shape pipecat sends (pcm + wav formats, voice-profile passthrough, speed) so the documented recipe can't silently break. Validated in CI. Spec: Action 15 / §R1 v1 / parity program Wave 2.5. Co-authored-by: Claude Fable 5 <noreply@anthropic.com> feat(remote): backend URL + bearer key + Tailscale docs (Wave 2.3) (#364) Run inference on a remote GPU box, drive it from the desktop app — opt-in, off by default (loopback-only is unchanged when no key is set). Backend: - BearerKeyMiddleware (main.py): when OMNIVOICE_API_KEY is set, every non-loopback HTTP + WebSocket request must present it (Authorization: Bearer, ?api_key=, or the ov_key cookie set on first auth). Pure ASGI (no response buffering), loopback always bypasses, SPA shell stays reachable. Constant-time compare, never logged. - ws_remote_authorized() in dependencies; capture_ws lets a keyed non-loopback client through its inline loopback guard (the thin-client dictation case: mic local, GPU remote). Frontend: - api/client.ts: ov_backend_url (localStorage) is the top-precedence base override; new wsUrl() derives ws scheme + host from the API base (not window.location, which lies in the Tauri webview) and appends ?api_key. apiFetch attaches the bearer header. Both WS call sites (dictation, events) routed through wsUrl; the HTTP transcribe fallback through apiFetch. - Settings > Sharing > Remote backend panel: URL + key fields, a test-connection probe against {url}/health, save-and-reload. Docs: docs/remote-gpu.md — the Tailscale recipe (MagicDNS + Serve, never Funnel, headscale note, plain-HTTP-is-sniffable warning, PIN-vs-key split). Tests: 10 bearer-middleware cases (inert without env, loopback bypass, 401 without/pass with key via header+query, wrong key, shell exemption, plain-ASGI guard, WS handshake reject/accept). Validated in CI. Spec: parity program Wave 2.3 / competitive-analysis §R2 rungs 1-3. Co-authored-by: Claude Fable 5 <noreply@anthropic.com> feat(dictation): optional local-LLM refinement of finals (Wave 2.1) (#363) Phase 2 of Spec 3, on top of Wave 1.1's deterministic collapse. Prompt design ported from voicebox (MIT): 'text filter, not an assistant' base instruction + three toggleable sections (smart_cleanup, self_correction, preserve_technical) + 7 few-shot examples passed as STRUCTURED chat turns (small local models echo inline examples). Runs through the user's own Ollama/LM Studio/OpenAI-compat endpoint via llm_backend — new additive chat_messages() on the adapter; chat() now delegates to it. Pass-through is the contract: with no LLM configured (backend 'off'), on any error/timeout, or on an empty reply, the raw transcript stands — identical default behavior on every platform. Refinement runs off-thread on FINALS only; the WS final dict gains optional refined_text and the dictation pill pastes refined_text ?? text (raw kept in history). Settings: GET/PUT /api/settings/dictation-refinement (loopback-gated, persisted in the settings table) + a Capture-tab panel with the master switch + per-flag toggles and a 'no LLM configured' hint. 15 new unit tests: prompt sections per flag, structured few-shot message shape, and the full maybe_refine pass-through matrix (off backend, disabled config, LLM failure, empty reply, empty input). Co-authored-by: Claude Fable 5 <noreply@anthropic.com> test(api): pin the /generate surface pyvideotrans's integration consumes (Wave 1.3) (#359) pyvideotrans drives OmniVoice as a per-line clone backend (their videotrans/tts/_omnivoice.py — being replaced upstream with a REST integration against POST /generate). This contract suite pins the exact multipart shape that integration sends (text + uploaded ref_audio + ref_text + language name + num_step/guidance_scale/speed/denoise/ postprocess flags -> audio/wav with X-Audio-Duration) so a /generate change that would silently break the 17.9k-star upstream fails our CI — the engine-compat constraint extended to an external consumer. Engine stubbed; validated in CI (local torch/Triton segfault on main-importing tests, see project memory). Spec 11 / parity program Wave 1.3 (our-repo half). Co-authored-by: Claude Fable 5 <noreply@anthropic.com> feat(stream): sentence-by-sentence /ws/tts via ported chunker (Wave 1.4) (#358) Ports Patter's SentenceChunker (MIT, attribution header) behavior-identical — all 61 upstream golden parity scenarios ship as fixtures and pass, including documented quirks (current_behavior xfail semantics mirrored from their parity runner). Terminator tables carry functional CJK; file added to the test_no_hardcoded_cjk allowlist per convention. /ws/tts now splits the request into sentences and synthesizes each in turn, streaming the first sentence's PCM while later sentences are still generating — the time-to-first-audio win on multi-sentence input. Single-sentence requests behave exactly like the old single-shot path; 'start' metadata still waits for the first generation so lazy-loading engines report their true sample rate. Italian comma-decimal guard hard-disables aggressive first-clause flush per upstream. Spec 8a (docs/competitive-analysis.md) / parity program Wave 1.4. Co-authored-by: Claude Fable 5 <noreply@anthropic.com> feat(tts): unlimited-length generation — sentence-boundary chunking + crossfade (Wave 1.2) (#357) Ports voicebox's chunked TTS (MIT, attribution header) with two deliberate changes: the concat half is reworked for torch tensors (matching what our inference helpers feed the effect chain, incl. multi-channel on the last axis), and the sample rate comes from the engine's declared rate instead of the first chunk (fixes a latent upstream bug). Long text (> max_chunk_chars, default 800) splits at sentence boundaries (abbreviation/decimal-aware, bracket tags atomic, fullwidth enders via unicode escapes for the CJK gate) -> per-chunk generation with deterministic seed variation (seed+i) -> linear crossfade join (default 50 ms, 0 = hard cut) -> effect chain + watermark once on the joined audio. Wired into BOTH inference paths (OmniVoice-native _run_inference and the engine-adapter _run_backend_inference) beside the existing [pause] stitcher; [pause] inputs keep their dedicated path. Short text is byte-for-byte the old single-shot path; max_chunk_chars=0 disables. New /generate form params: max_chunk_chars (>=0, default 800), crossfade_ms (0-1000, default 50). Tests: 15 model-free unit tests (split priorities, abbreviation/decimal/ tag guards, crossfade math incl. multichannel + clamping) + 3 stubbed- engine endpoint tests (long text fans out with no words lost, short text single-shot, 0 disables). Endpoint tests validated in CI — this machine has a pre-existing local torch/Triton segfault on any main-importing test. Spec: voicebox deep dive 1 / parity program Wave 1.2 / #346 unlimited-length item. Co-authored-by: Claude Fable 5 <noreply@anthropic.com> feat(dictation): collapse Whisper hallucination loops in final transcripts (Wave 1.1) (#356) Deterministic pre-pass ported from voicebox (MIT, attribution header): word-level (token repeated >=6x, punctuation-normalized) + character-level (2-60-char unit repeated >=6x, catches multi-word and no-space-script loops). Rhetorical repeats below 6 survive; no LLM involved; identical on every platform. Applied to the FINAL text in /ws/transcribe and POST /transcribe — segments keep raw recognition so timings stay truthful. Phase 1 of Spec 3 (docs/competitive-analysis.md); the optional local-LLM refinement pass (phase 2) lands with parity program Wave 2.1 in the same module. Co-authored-by: Claude Fable 5 <noreply@anthropic.com> feat(profiles): consent-locked voice profiles — verified_own_voice + spoken consent flow (Wave 0.2) (#354) * feat(profiles): consent-locked voice profiles — verified_own_voice + spoken consent flow (Wave 0.2) A profile becomes 'verified own voice' when its owner records themselves reading a consent statement (spoken attestation, not a checkbox). Agentic features and gallery sharing will gate on the flag; plain local synthesis never does. - alembic 0003 (additive, PRAGMA-guarded, downgrade supported) + _BASE_SCHEMA columns: verified_own_voice, consent_text, consent_audio_path, consent_recorded_at - POST/DELETE /profiles/{id}/consent — stores the recording as provenance in VOICES_DIR ({id}_consent.*), replaces on re-record, cleans up on revoke and on profile delete; 422 on empty statement / too-short audio - VoiceProfile page: Verified badge + Voice ownership panel (record via the existing useRecording denoise flow, revoke with confirm); en.json keys only (other locales fall back per the advisory i18n parity policy) Spec: docs/competitive-analysis.md Action 22 / parity program Wave 0.2. Prerequisite for agentic v2/v3 and the persona gallery. * fix(profiles): harden consent paths against py/path-injection; drop lifespan in tests - _voices_path(): resolve DB-stored filenames strictly inside VOICES_DIR (bare-filename check + realpath containment); extension whitelist on the uploaded consent filename (fallback .wav) so a crafted filename can never steer the on-disk path. Applied to write, re-record cleanup, revoke, and profile-delete cleanup. New test: malicious upload filename falls back. - Test fixture no longer runs the app lifespan: startup/shutdown touched module-level asyncio primitives bound to another module's event loop, making the suite order-dependent in full-suite CI. init_db() is called directly; endpoints under test need only the schema. Fixes the CodeQL (3x py/path-injection high) and full-suite event-loop failures on PR #354. --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> test(evals): LLM-judge eval tier — non-gating semantic suites (Wave 0.3) (#355) Ports Patter's eval harness (MIT, attribution headers) into tests/evals/ with the judge transport swapped to services/llm_backend.py — the judge runs against whatever local Ollama/LM Studio/OpenAI-compat endpoint the user configured, keeping local-first. Both Patter hardening details kept verbatim: verdict recomputed locally from the score (hallucinated 'passed: true' at score 0.2 fails), and tolerant JSON parsing (fences stripped, invalid JSON -> fail-with-reasoning). Per-case containment: agent exceptions keep the partial transcript and still judge it; a judge failure records score 0 instead of aborting the suite. HARD RULE preserved: LLM judges never gate CI. The scheduled workflow (weekly + dispatch) is continue-on-error with the JSON report as artifact; run_evals.py exits 0 always and skips cleanly when the active LLM backend is 'off'. Deterministic probe judges remain the only gates; the harness unit tests (10, no LLM needed) do run in gating CI. First suite: dub translation naturalness v1 (4 cases) driving the real cinematic_refine_sync reflect+adapt chain. The telephony-specific session/assertions layers were deliberately not ported. The dictation-refinement suite lands with Wave 1.1/2.1. Spec: docs/competitive-analysis.md Spec 9b / parity program Wave 0.3. Co-authored-by: Claude Fable 5 <noreply@anthropic.com> ci(docs): daily docs-drift job — canonical inventory vs README/docs/registries (Wave 0.1) (#353) docs/features.yaml is the curated single source of truth (12 features, 11 TTS + 7 ASR engine ids, required install docs). scripts/check-docs-drift.py diffs it against README.md, docs/, and the engine registries — parsing registry keys from source so the CI runner never imports torch. The daily workflow updates ONE rolling 'docs-drift' issue in place and auto-closes it when clean (pattern adapted from Patter, MIT). Self-test includes a real-repo-is-clean gate, so any PR that changes engines/features without updating the inventory fails CI too. Spec: docs/competitive-analysis.md Spec 9a / parity program Wave 0.1. Co-authored-by: Claude Fable 5 <noreply@anthropic.com> docs(specs): ElevenLabs-parity program — waved implementation plan from #346 + #345 (#349) Turns the discussion #346 roadmap and the competitive-analysis research (#345) into an executable program of small PRs: 6 waves, dependency-aware, each item citing its Spec/§R section with effort and acceptance criteria. Accounts for Smart Fit Phase A (#347), the timeline editor (#348), and Scalar (#307) having already shipped. Telephony explicitly deferred behind guardrails + two spikes. Co-authored-by: Claude Fable 5 <noreply@anthropic.com> docs: competitive analysis v2 — second-tier landscape, source deep dives, action specs, market sentiment (#345) * docs: expand competitive analysis — second-tier landscape, deep dives, action specs, market sentiment Second research pass over PR #339's analysis (six parallel agents): - Second-tier landscape: 13 projects surveyed, 7 profiled; KrillinAI/KlicStudio promoted to direct-competitor status - Source-level deep dives: voicebox + Patter (MIT, portable briefs) and pyvideotrans (GPL, clean-room functional specs incl. the full _rate.py decision tree with verified constants) - pyvideotrans's OmniVoice integration verified broken (Gradio /_clone_fn vs our FastAPI :3900) — Action 11 reframed as fix-the-bridge - Implementation specs mapping all ranked actions onto our codebase - User-sentiment + market-positioning research (issue clustering, ElevenLabs pricing pressure, honest verdicts on our five differentiators, name-collision risk, four positioning moves) - Three stale matrix grades corrected (docs-drift CI, eval harness, MCP) * docs: ground the #346 roadmap in research — agentic voice, remote GPU, audiobooks, persona gallery, model/env management Third research pass (four agents + five verification sub-agents) adding a 'Roadmap directions' section that maps every item from discussion #346 to either an existing spec or new research: - Agentic voice workflow: pipecat (BSD-2) as the license-clean in-process runtime; honest telephony constraints (no local PSTN path — opt-in carrier creds only); FCC/TCPA, Texas SB 140, ELVIS Act, EU AI Act Art 50 (2026-08-02, OSS exemption does not cover it); six concrete guardrails; v1/v2/v3 scope ladder - Remote GPU/Tailscale/remote API: base-URL + bearer-token consensus pattern; 175k-exposed-Ollama cautionary tale; Tailscale rung (a) docs-only; vLLM drop-in for llm_backend; Scalar already shipped (#307), remaining work is OpenAPI hygiene - Audiobook creator + persona gallery: ACX technical-spec mastering bar; ebooklib/PyMuPDF/mobi AGPL/GPL parser traps with clean alternatives; unoccupied consent-aware-gallery territory; .ovsvoice portable format - Model/env + GPU compat: uv link-mode dedupe math (measured wheel sizes); two-dimensional (torch x cuda-variant) -> sm_XX compat matrix; HF cache as single source of truth (hf cache ls/rm/verify); preflight gate + loud CPU-fallback banner vs the Ollama/voicebox silent-fallback antipattern - Eight consolidated new actions (15-22) --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> feat(dub): timeline segment editor — drag, snap-to-onset, keyboard a11y (#280) (#348) * feat(dub): full-track speech-onset detection + GET /dub/onsets/{job_id} (#280) detect_speech_onsets() lists every speech rise across the track (frame RMS, adaptive threshold, 150ms hysteresis) — powers the timeline editor's snap-to-onset ticks. Route prefers the Demucs vocals stem, falls back to the mix, and caches onsets.json per job (mtime-invalidated). * feat(dub): timeline editor math core — windowing, snap, clamp, fingerprint-safe commit (#280) Pure helpers for the segment track: binary-search windowing, snapTime with deterministic ties, neighbour/min-duration clamps with Alt-overlap (<=200ms), commitMoveResize with fingerprint parity (move touches only start/end; resize sets speed exactly like the old Regions handler and DELETES the key at 1.0 so _canon_value's missing-vs-1.0 hashing can't mark untouched segments stale), and overlap detection. * feat(dub): SegmentTrack editing lane replaces the Regions plugin (#280) Custom DOM segment boxes (6px edge handles, body-drag move, speaker colors, stale/fresh tint, hatched overlap warning) virtualized by time over a single {pxPerSec, scrollLeft} alignment source read off WaveSurfer's wrapper. Snap-to-onset ticks on a viewport-sized canvas light up in snap range; Ctrl/Cmd-wheel zooms centered on the cursor; double-click plays the slot via playRange (timeupdate watcher pauses at slot end). Roving-tabindex listbox keyboard model (arrows / Enter / Shift / Alt / Delete / S) with polite aria-live announcements. WebKit fallback keeps a self-scrolling lane at a fixed px/sec. timeline.* strings translated in all 21 locales. * feat(dub): wire timeline editor — per-gesture undo, id fix, table selection sync (#280) segmentMoveResize() pushes undo ONCE per gesture (drag commits on pointerup; keyboard nudges coalesce per focus session) and matches by String(id) — the old parseInt('seg-3_a') path edited the wrong segment after a split. Commits go through commitMoveResize for fingerprint parity, and the existing recomputeIncremental effect picks up every commit. Clicking a timeline box scrolls + highlights its row in DubSegmentTable; 'preview dub here' parks the player at the slot start, then synthesizes the line. * fix(dub): inline the onsets-cache containment guard — CodeQL can't track helpers Same lesson as #328/#329: the realpath+startswith sanitizer must sit at the sink, not behind a function return. Unused helper removed. --------- Co-authored-by: mergetest <test@local> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> feat(dub): Smart Fit timing strategy — planner, fingerprints, generate path (phase A) (#347) * feat(dub): Smart Fit planner, fit fingerprints, shared ffmpeg stretch helpers - services/fit_planner.py: pure, I/O-free planner for dub-length fitting v2 — slack absorption (gap guard), audio-only band (<=1.2x), geometric 50/50 audio/video split capped at 1.5x / 2.0x, residual overflow accounting, and a stretch_video-compatible video_plan + fitted timeline cursor. Clean-room reimplementation from a published description. - services/incremental.py: fit_fingerprint() over the fit params with the same _canon_value canonicalisation as segment hashes (#281 class). Fit params stay OUT of segment_fingerprint — a fit change re-mixes, never re-TTSes. - services/ffmpeg_utils.py: move _atempo_chain/_pitch_preserving_stretch out of the dub_generate router (lazy torch/numpy imports) so the Phase B export pipeline can reuse them; add probe_duration() ffprobe helper. - schemas/requests.py: timing_strategy gains "smart_fit"; optional fit_options knob overrides default server-side. * feat(dub): smart_fit branch in the generate path TTS loop unchanged (dur_s=None, natural-rate WAVs on disk). After the loop, plan_fit() decides per segment; the mix loop applies audio_rate via the pitch-preserving atempo pipe (linear-interp fallback), trims residual overflow with the existing fades, and places audio at the planned new_start on a fitted-length canvas. Truthful fit_status entries (audio_rate / video_ratio / overflow_s) feed the row badges. Persists job["fit_plans"][lang] = {plan (exact _build_video_stretch_filter_graph shape), fitted_segments (cue times from ACTUAL stretched sample positions), total/orig duration, params, fit_fp} and mirrors fit_fp on dubbed_tracks[lang]. video_stretch_plans untouched. Strategy-transition guard: job["seg_wav_kind"] records whether on-disk seg WAVs are natural or slot-squeezed; a smart_fit partial regen over slotted (or unknown) WAVs forces one full regen instead of double-compressing. Old strategies and old persisted jobs are byte-identical (all new reads via .get()). * feat(ui): Smart Fit option in the dub timing picker (all 21 locales) - prefsSlice: TimingStrategy union gains 'smart_fit'; optional FitOptions overrides (null by default — backend defaults apply identically on every platform); persisted alongside timingStrategy. - DubTab: Segmented gains Smart Fit with i18n label + tooltip. - useDubWorkflow: sends fit_options only when set and strategy is smart_fit. Default strategy stays 'concise' — no default behaviour change on any platform. - locales: dub.timing_smart_fit{,_title} translated in all 21 languages. * test(dub): fit planner unit + golden suites, smart_fit generate-path integration - test_fit_planner.py: threshold boundaries (0.9/1.0/1.2/1.21/4.0), cap saturation -> overflow, slack absorption incl. gap guard, last-segment tail, cursor monotonicity, allow_video_retime=False, video_plan fed straight into _build_video_stretch_filter_graph, fit_fingerprint canonicalisation (int vs float, omitted vs default — the #281 class) and a pinned stable digest. - tests/fixtures/fit_planner/*.json: 4 golden FitPlans; algorithm drift is a deliberate fixture diff, never a silent change. - test_smart_fit_generate.py: hermetic end-to-end runs (mock TTS, no ffmpeg) covering audio-only stretch, hybrid timeline growth + persisted plan shape, fit_options override, strict_slot->smart_fit forced regen then zero-TTS fit-only re-mix, and concise back-compat. * docs(competitive): dub-length fitting row reflects Smart Fit Phase A * fix(incremental): mark fingerprint hashes usedforsecurity=False — dedup keys, not security (Bandit) --------- Co-authored-by: mergetest <test@local> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> docs: model-source support policy — verifiable public sources only (#310) (#344) * docs: model-source support policy — verifiable public sources only Owner decision (issue #310): the local-loading mechanism stays, but official support covers only models from verifiable public sources (HF repos, official releases with license + checksums). Privately distributed / paywalled model files are use-at-your-own-risk; never run bundled executables. Mirrored in SECURITY.md as a supply-chain note. Per the docs-sync rule, shipped alongside the policy decision. * docs: firm up model-source policy — open, public, verifiable only; no private/paid models --------- Co-authored-by: mergetest <test@local> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> docs: community docs refresh — README, CONTRIBUTING, SECURITY, SUPPORT, Docker/macOS install (#341) * docs: refresh community docs to match the project's current reality - README: download badges now point to releases/latest (were frozen at v0.2.7); Intel-Mac note (pre-built bundle is Apple Silicon; source works on Intel; pre-built Intel tracked in #279) - SECURITY: supported-versions table 0.2.x -> 0.3.x + 0.2.7 legacy row - docs/install/docker.md: tag mapping matches docker.yml after #338 — :latest is the rolling main preview, :stable (new) pins releases - PR template: removed the abolished two-RC/48h-soak ceremony; documents continuous-to-main - CONTRIBUTING: new sections — what bot review looks like (CodeRabbit + Greptile), conventional-commit + issue-link expectations, the quality gates (cross-platform parity, 21-locale i18n + CJK allowlist, alembic, engine back-compat, local-first, loopback security posture), and a contribution-licensing grant that keeps the AGPL + commercial dual-license viable - SUPPORT.md: new — channels, before-you-file checklist, expectations - docs/install/macos.md: Intel caveat aligned with reality * docs: codify the docs-sync hard rule — behavior changes update their docs in the same PR * chore(agents): rtk rules for Antigravity — token-compressed tool output --------- Co-authored-by: mergetest <test@local> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> ci(release): reinstate macOS Intel (x86_64) build target on macos-15-intel (#342) Intel MacBook users had no installable artifact: the release matrix only built aarch64-apple-darwin, and Rosetta 2 cannot run arm64 apps on Intel (it only translates the other direction) — the rationale in the old "Intel dropped" comment was backwards. Refs #279. - Add a native `macos-15-intel` matrix leg (GitHub's designated x86_64 migration target after macos-13 retired Dec 2025; standard image, supported through Aug 2027) building --target x86_64-apple-darwin with app,dmg,updater bundles. - Existing per-TRIPLE steps already carry x86_64-apple-darwin cases (uv sidecar tar.gz, evermeet.cx ffmpeg/ffprobe — x86_64 Mach-O, natively correct on Intel), so the leg flows through the same Bundle/Build/Smoke/Verify steps untouched. - The PR #290 signing path applies automatically: ad-hoc seal from tauri.conf.json signingIdentity "-", opt-in APPLE_* stable signing, and scripts/verify-macos-signing.sh both gated on runner.os == macOS. - tauri-action includeUpdaterJson merges the new darwin-x86_64 platform key into latest.json alongside darwin-aarch64, so Intel installs auto-update on both Stable and Preview channels. - docs/install/macos.md: table telling users which DMG (aarch64 vs x64) matches their Mac, and the from-source fallback for old releases. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> fix(tts): pin cudagraph-compiled model inference to one dedicated thread (#315) (#343) torch.compile(mode="reduce-overhead") captures CUDA graphs whose state is thread-local (torch/_inductor/cudagraph_trees keys its tree manager off the capturing thread). The _gpu_pool ThreadPoolExecutor runs up to 4 workers, so the first render captured the graph on worker A and a later render dispatched to worker B replayed mismatched cudagraph state — silently corrupting the audio (static noise + slowed playback from the second render onward, no exception, so the #327 eager fallback never fired). Fix: when the model is compiled with a cudagraph mode, wrap model.generate (the same single choke point #327 uses) so every call hops to a dedicated 1-thread "compiled-infer" executor — capture and replay always happen on the same thread, deterministically. A thread-ident re-entrancy guard runs inline when already on that thread (a 1-worker executor submitting to itself would deadlock). Installed after the #327 fallback wrapper, so the eager retry path also runs on the dedicated thread. No behavior change for CPU / MPS / Windows-no-Triton / compile-disabled paths: should_torch_compile() gates exactly as before and uncompiled models keep the full pool. Closes #315 Co-authored-by: mergetest <test@local> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> macOS: ad-hoc sign so users open without Terminal + signing/notarization verification (#290) * chore(release): add macOS signing/Gatekeeper/notarization verification Codify and enforce the macOS build-signing requirements. The release pipeline built bundles and had opt-in Apple signing, but never verified codesign/spctl/notarization — unsigned or broken bundles could ship silently. - scripts/verify-macos-signing.sh: runs codesign --verify --deep --strict, spctl Gatekeeper assessment, per-nested-Mach-O signature check, stapler validate, and (opt-in) notarytool history. Report-only by default (unsigned dev/preview is expected); --require-signed fails on any unsigned/un-notarized component so a broken release stops instead of publishing an unsigned artifact. - scripts/macos-dev-unquarantine.sh: local-dev-only quarantine stripper, with a loud "never a substitute for notarization" warning. - release.yml: new "Verify macOS signing" step on the macOS leg — report-only on unsigned paths, STRICT on the opt-in signed stable path (same condition as "Configure Apple signing"), so signing/notarization failures fail the job. - docs/macos-signing-verification.md: the canonical 10-point requirements + how-to-verify checklist, cross-linked to docs/install/macos.md and DESKTOP_RELEASE.md. Verified locally: report-only PASS (exit 0) and --require-signed FAIL (exit 1) against the real unsigned debug .app; release.yml parses as valid YAML. * feat(macos): ad-hoc sign bundle so users open it without Terminal (no Apple ID) The "app is damaged and can't be opened" error is caused by a broken/incomplete code-signature seal (codesign --verify failed: "code has no resources but signature indicates they must be present") on the quarantined download — there is no GUI bypass for that variant on modern macOS, forcing users to run `xattr`. Give the bundle a VALID ad-hoc signature at build time (free, no Apple Developer account) via tauri.conf.json bundle.macOS.signingIdentity = "-". Verified through a real `tauri build`: the produced .app is now flags=adhoc,runtime and passes codesign --verify --deep --strict. A valid seal flips the Gatekeeper prompt from the un-bypassable "damaged" to the GUI-bypassable "unidentified developer", which users clear with right-click → Open / Settings → "Open Anyway" — no Terminal. Still not notarized (that needs the paid Apple ID), so there's a one-time confirmation rather than a clean double-click. The opt-in Developer-ID path is unchanged: APPLE_SIGNING_IDENTITY (env) overrides the "-" default on the signed stable release. - tauri.conf.json: signingIdentity "-" (ad-hoc default). - verify-macos-signing.sh: detect ad-hoc tier; report the no-Terminal GUI path in report-only, still FAIL it under --require-signed (production must notarize). - docs/install/macos.md: lead the Gatekeeper section with right-click → Open; keep xattr as fallback for the harsher "damaged"/corrupted-download case. - docs/macos-signing-verification.md: signing-tiers table + ad-hoc default note. - release.yml: comment the ad-hoc default + env override on the signed path. --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> feat(dub): speech-onset alignment + regional dialect targeting (#280) (#330) Items 1 and 2 from the improvement list: 1. Synchronization — Whisper-family ASR stretches segment starts back over leading non-speech (intro music, silence), so the dub starts at 0:00 while the speaker starts at 0:02-0:03. New onset_align service snaps each segment start forward to the first audible vocal onset (adaptive RMS threshold over the Demucs-isolated vocals when available). Forward-only and conservative: never moves a start earlier, ignores sub-100ms shifts, preserves minimum duration, leaves silent-window segments untouched. Pure NumPy — identical across platforms. 2. Accent/vocabulary by country — a Dialect picker in the Dub panel (BCP-47 codes per target language) injects a regional instruction into LLM translation prompts (OpenAI/Ollama engines and the Cinematic refine pass): Argentina yields 'Vos sos muy listo', not 'Tú eres muy listo'. Non-LLM engines show a clear hint that the dialect needs an LLM. New i18n keys translated in all 21 locales. Item 3 (segment rectangles: move/crop/stretch on the timeline) is a larger editor feature and stays open on #280. Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: mergetest <test@local> fix(tts): torch.compile failures fall back to eager — generation never fails on unsupported GPUs (#278) (#327) * fix(tts): torch.compile failures fall back to eager — generation never fails on unsupported GPUs (#278) On GPU architectures the bundled Triton doesn't support (e.g. Blackwell sm_120 / RTX 5060), the compiled model dies mid-generation inside the Dynamo/Inductor/Triton/cudagraph stack — previously surfaced as a fake 'ran out of memory' error and a dead Archetype preview. Now: - up-front arch gate: skip compile when the GPU's compute capability is not in this torch build's arch list (OMNIVOICE_FORCE_TORCH_COMPILE=1 overrides for PTX forward-compat setups) - runtime fallback: model.generate is wrapped once; a compile-stack failure (classified by exception chain: module, message, traceback paths — the cudagraph case is a bare AssertionError) logs a warning, restores the eager module, disables compile for the session, resets dynamo state, and retries eagerly. Non-compile errors propagate unchanged. - the /generate OOM handler no longer mislabels compile crashes as OOM and points users at the actual remedy. Fixes #278 * Potential fix for pull request finding 'CodeQL / Empty except' Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> * Potential fix for pull request finding 'CodeQL / Empty except' Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> * Update backend/api/routers/generation.py Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> Co-authored-by: mergetest <test@local> ci(security): never cancel main scans — merge trains left red ✗ on every intermediate commit (#340) PR branches keep cancel-in-progress (superseded scans are wasted work). On main each commit gets its own concurrency group, so a burst of merges runs every scan to completion instead of cancelling all but the last — 'cancelled' renders as a permanent red ✗ in the commit history even though nothing failed. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> chore(version): main is always latest release + 1 — rule, bump to 0.3.6, Docker retag, auto-bump job (#338) Versioning hard rule (owner-set 2026-06-11), codified in CLAUDE.md: - main's three version sources (tauri.conf.json, Cargo.toml, pyproject.toml) always carry last release + 1 patch; bumped 0.3.5 -> 0.3.6 now. - Preview builds stamp BASE-N which now sorts ABOVE the last stable (0.3.6-N > 0.3.5) — the updater ordering becomes natural and the Windows MSI ProductVersion wrinkle disappears. - Docker: :latest = rolling main preview; :stable + :X.Y.Z + :X.Y = tagged releases. workflow_dispatch still only emits throwaway :sha-. - release.yml gains a version-bump job: on every stable v* tag it bumps main to the next patch automatically. Co-authored-by: Claude Fable 5 <noreply@anthropic.com> fix(dub): burn translated subtitles, fix subtitle save JSON error (#309) (#328) * fix(dub): burn translated subtitles, fix subtitle save JSON error (#309) Two symptoms, one root: the job kept the original-language ASR transcript while the editor only sent translated/edited text in the generate request. - dub_generate now persists the segments the dub was actually generated from back onto the job (metadata carried over by stable id, fallback index; text_original retained for dual-subtitle layouts) — SRT/VTT export and ffmpeg burn-in now render the dub language, not the source. - The SRT/VTT export endpoints honor the save_path query param the Tauri save dialog appends (like every other export) and return the standard JSON envelope — previously they ignored it and returned the raw body, so the frontend's JSON.parse choked on the SRT cue index ('Unexpected non-whitespace character after JSON'). - Frontend guards the save response content-type so any future raw-body response surfaces as a clear error. Fixes #309 * Potential fix for pull request finding 'CodeQL / Uncontrolled data used in path expression' Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> * fix(dub): use the file's established realpath+startswith containment idiom (CodeQL) * fix(dub): write subtitle saves from the Tauri process, not the backend (#309) The backend save_path variant on /dub/srt and /dub/vtt routed a user-controlled destination through the loopback HTTP surface — six new CodeQL path-injection flows plus two log-injection flows. Subtitles are small text bodies, so the frontend now fetches them raw and writes the file via a new save_text_file Tauri command: the OS save dialog in the trusted process is the write authorization, and the backend never sees a destination path. Binary exports keep the established save_path flow. Also strips newlines from user-derived values in the two flagged log lines. * fix(dub): leave _native_save byte-identical to main The newline-strip on the log line moved a path sink onto a changed line, which made CodeQL re-attribute the long-standing binary-export flow to this PR as a new alert. The subtitle endpoints no longer feed this function at all, so restore the exact original line — the baseline alert stays baseline, and hardening pre-existing flows belongs in its own PR. --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> docs: competitive analysis — voicebox, pyvideotrans, Patter (feature matrix + ranked adoption plan) (#339) * docs: competitive analysis — voicebox, pyvideotrans, Patter Feature matrix vs our self-inventoried maturity grades, license-aware reuse verdicts (MIT = port with attribution, GPL-3.0 = reimplement only — copied GPL files would break the AGPL + commercial dual-license), and an 11-item ranked action plan with effort estimates. * docs: append Chatterbox engine evaluation to the competitive analysis --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: mergetest <test@local> chore(review-bots): diagrams + ASCII UI sketches in every PR walkthrough (#337) * chore(review-bots): visual walkthroughs — diagrams for mechanics, ASCII sketches for UI CodeRabbit: enable sequence_diagrams explicitly and instruct the high-level summary to sketch UI changes as compact ASCII before/after and behavior changes as a small mermaid flow. Greptile: new repo-level greptile.json turning on the sequence-diagram and summary sections with matching instructions, plus the project's local-first and cross-platform hard rules so both bots review against them. * chore(review-bots): expert-panel review rubrics, pre-merge rule audits, knowledge base Encode one senior-domain-expert lens per subsystem (ML inference for backend/services, product frontend for src, desktop systems for src-tauri, test infra for tests) as path instructions; add non-gating pre-merge checks for the project's four hard rules (cross-platform default parity, 21-locale i18n completeness, local-first guarantee, backward compatibility); feed CLAUDE.md and docs into CodeRabbit's knowledge base; mirror it all in greptile.json with customContext rules and strictness tuning. --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> feat(setup): unified first-run journey — install gate, studio-console wizard, platform awareness (#295) * feat(setup): first-run install gate — nothing installs until the user confirms a plan New `setup` module parks first runs in BootstrapStage::AwaitingSetup instead of auto-installing. complete_setup validates the user's InstallPlan and only then starts the existing bootstrap: - install modes: installed (platform dirs) / portable (one folder next to the exe / AppImage, config.json travels with it) - user-chosen storage: env dir, data dir (OMNIVOICE_DATA_DIR), model cache (OMNIVOICE_CACHE_DIR) — None = legacy default, byte-identical behavior - minimum-space gate: per-volume free-space check (fs4 statvfs), grouped by filesystem so dirs sharing a disk sum their requirements; install refused when short (9 GiB env + 7 GiB models + 1 GiB data, measured + headroom) - custom mirrors (PyPI index, HF endpoint, python-build-standalone) take precedence over region presets in the venv/sync/backend env wiring - ROCm torch variant selectable via config (env var still wins) - existing installs migrate silently: venv present → setup_complete=true, no questions re-asked; dev trees skip the gate entirely 19 unit tests (disk probing, space grouping, mirror validation, legacy config compat). * feat(setup): first-run setup screen — mode, storage with space gate, mirrors, compute FirstRunSetup renders when the Rust side reports awaiting_setup (lazy-loaded; regular launches pay nothing). One screen, defaults all work: - language picker first (rest re-renders translated), 21 locales shipped - Installed / Portable mode cards (portable disabled with reason when the exe-adjacent folder isn't writable) - storage rows with live per-path free-space probes (debounced check_install_target), 'needs ~X / Y free' readouts, folder pickers - client mirrors the Rust per-volume space gate: Start installation is disabled with an explicit reason until every volume fits - compute (CUDA-auto / ROCm), update channel, region + custom mirror URLs - complete_setup errors surface inline; on success the normal bootstrap progress UI takes over on the next status poll Verified on a wiped machine: gate parks (no spawn, no downloads), screen renders, 450 GB ≥ 17 GB requirement → Start enabled. * feat(setup): studio-console redesign of the first-run screen The setup screen now reads as powering on studio hardware rather than a web form — true to a voice studio, and self-sufficient offline (every font and asset is bundled; a first run may be on a restricted network): - breathing waveform masthead (CSS-only, deterministic speech-cadence silhouette, staggered per-bar delays) - Source Serif 4 display headline + engraved IBM Plex Mono panel labels + Inter body — the three faces the app already ships - rack-unit panels with corner screws, engraved title rules, serial plate (OVS · vX.Y.Z) - disk space as segmented LED capacity meters: lit = what the install consumes, alarm-blink red on insufficient volumes - mode cards with indicator LEDs; 'armed' Start button — LED lights and a halo pulses only once every volume passes the space gate - atmosphere: corner accent glows + SVG film grain; staggered rise-in choreography on load - all motion transform/opacity only; prefers-reduced-motion holds every frame still; theme-token derived colors; focus-visible rings throughout No logic changes: same IPC calls, same i18n keys, same space-gate math. * feat(setup): wide desktop deck, hardware-aware Compute + Update channel cards Three pieces of feedback addressed: - width: the console is now a 1240px two-column deck (storage rail left, decision rail right) that uses desktop real estate; collapses to one column under 980px and stacks fully under 620px - no outer chassis box: panels float directly on the atmospheric backdrop, each carrying its own rack-unit treatment - Compute and Update channel split into separate cards with real information: get_setup_state now detects hardware (nvidia-smi → CUDA name, /sys/class/drm vendor 0x1002 → AMD/ROCm, Apple Silicon → MPS, CPU cores + RAM via sysinfo; best-effort, never blocks) — the Compute card shows a live 'Detected: …' readout, badges the option that matches the machine, and pre-selects ROCm on AMD boxes; both cards use LED radio options with full descriptions (6 new i18n keys × 21 locales) Also pins playwright-core as an explicit devDep — bun did not materialize it through @playwright/test, breaking programmatic browser use. 20/20 Rust tests · vite build · CJK guard green. Verified live (gate engaged, responsive single-column) and at 1600×1000 via mocked-IPC browser shot (two-column deck). * feat(setup): move network (region + mirrors) into the masthead with language Language and download region are the two 'where am I' choices — they now sit together top-right of the masthead, with the custom-mirrors disclosure tucked beneath the subtitle. The Network panel is gone, leaving a balanced deck: Install mode + Storage left, Compute + Update channel right. * feat(setup): strip the boxes — fills and rules carry the structure One design rule now: borders only where state demands them. Panels lose their boxes entirely (engraved mono title + rule separates sections); option cards, storage rows, selects/inputs, the hw readout, the version plate and the ghost buttons are all flat fills; active options glow with an accent tint + LED; blocked rows and errors use a red tint + 2px inset edge bar instead of a border. The badge chip is fill-only too. * feat(setup): quiet pass — every element earns its visual weight - waveform becomes a whisper: 22px trace, 2px bars, ~half opacity — an ambient signature instead of a billboard - storage readouts collapse to one mono line ('needs ~9 GB · 449 GB free'); the LED meter now appears only when it carries information (install would consume >35% of free space, or the volume is blocked) — at 449 GB free a bar was a meaningless sliver - Change… buttons go text-quiet (transparent until hover) - custom-mirrors disclosure right-aligns under the region select it extends, instead of floating under the subtitle - version plate moves to the footer next to the disk total — the masthead keeps only title, subtitle, and the two locale/region selects * feat(setup): platform-matrix awareness — distro+arch detection, ROCm gated to Linux, no Windows console flash The install matrix is OS family × distro × arch × GPU vendor, and the setup screen now both shows it and only offers choices valid for it: - HardwareInfo gains os_name (distro PRETTY_NAME from /etc/os-release on Linux, macOS/Windows elsewhere) and arch (x86_64/aarch64) — the detected line reads 'CachyOS x86_64 · NVIDIA RTX 4070 · 32×CPU · 31 GB RAM', exactly what bug reports cite - SetupState gains os; the ROCm option renders on Linux only (wheels don't exist elsewhere) and complete_setup clamps rocm→auto on non-Linux as the server-side backstop - nvidia-smi probe gets CREATE_NO_WINDOW on Windows — no cmd flash on the first screen a user ever sees - Apple Silicon → MPS, Intel mac → CPU, ARM Linux → CPU: all matrix cells resolve through the same base constructor * feat(setup): unify the whole first-run journey under the studio-console system Setup → Installing → Model wizard now read as one continuous experience: the same atmosphere, whisper waveform masthead, serif/mono type, LED language and quiet fills across all three acts. - Installing (BootstrapSplash): rebuilt in frs-* — segmented LED journey meter (completed steps + live byte progress), LED step rail (done=green, active=pulsing accent, pending=dim), engraved ACTIVITY panel with the quiet mono log (collapse/copy as text-quiet actions), failure act with red-tint error + hints + armed Retry. All logic untouched: stage poll, event subscription + backfill, dedupe, hints, region/language selects. - Model wizard (SetupWizard): same masthead with the step rail as engraved mono LED steps top-right, welcome cards as option-card surfaces, preflight as LED check rows (pass/warn/fail), frs nav buttons with armed primaries, embedded Model Store / Engines / Dictation panels scroll inside the act. Old 556-line stylesheet replaced by ~60 lines of glue; BootstrapSplash.css reduced to a resolving stub. - FirstRunSetup.css is now the journey's shared design system (step rails, log panel, banners, hints, wizard chrome, check rows appended). - 2 new strings (Installing / Activity) translated across all 21 locales. Validated end-to-end on this machine: setup screen → Start installation → real venv bootstrap (~10 min) → backend healthy on 3900 → model wizard. 20/20 Rust tests · vite build · CJK guard green · installing act verified via mocked-IPC screenshot at stage=installing_deps. * feat(setup): --setup re-entry flag + make the install-plan screen un-stealable The setup stage is first-run-only by design (completed installs skip it), but it must be reachable on demand and must actually win the mount when engaged. Three fixes: - 'omnivoice-studio --setup' parks the bootstrap in AwaitingSetup on any launch — checked before the attach-to-healthy-backend shortcut, so a running backend can't skip past it - App routing: awaiting_setup now outranks everything (a live backend answering /setup/status used to route straight to the model wizard); the wizard additionally requires stage === 'ready' so it can't mount during the initial stage race - useBootstrapStage: a transient IPC miss no longer permanently declares 'ready' (which killed the poll loop and silently skipped the setup / progress screens) — it retries up to 5 ticks before conceding Plus journey-wide titlebar clearance (content never sits under the GTK headerbar / macOS traffic lights / Windows controls) and drag-region mastheads on all three acts. Verified: mocked-IPC harness with stage=awaiting_setup + a LIVE backend answering /setup/status renders the setup screen, not the wizard. * style(setup): remove backdrop decoration — flat surface, state-only emphasis The corner accent glows and SVG film grain rendered as visible banding / noise artifacts on many panels — both gone; the journey now sits on a clean flat chrome background. Also swept the remaining decorative bloom: the active option card drops its glow shadow (flat accent tint + LED carry the state), and the armed Start button loses its pulsing halo (the lit LED already signals actionable). Remaining shadows are functional micro-detail only: 6px LED glows, meter track inset, red edge bars. * feat(setup): journey rail + verbosity diet — clean, smooth, elegant The setup page is now visibly stage 1 of the install flow: a quiet breadcrumb rail (SETUP → INSTALLING → MODELS & ENGINES) sits between the waveform and the headline on both the setup and installing acts, LEDs marking done/active/pending — one continuous story across the journey. Verbosity halved without hiding information: - option descriptions unfold (260ms ease) only on the selected card; the page shows exactly one explanation per group, collapsed cards keep the text as a tooltip - storage rows drop their always-on caption (label + path + readout + Change… on one line; caption lives in the row tooltip) The whole page now fits a laptop window without scrolling. * feat(setup): merge Models + Engines into one wizard act Two tabs weren't necessary: models are the required gate, engines the optional extras — now two stacked panels in a single 'Models & engines' step (label reuses the journey-rail key, translated in 21 locales). Wizard shrinks to 4 steps: Welcome → System check → Models & engines → Dictation. Continue still gates on models_ready only; engines stay optional. Welcome cards updated to the 3 remaining acts; static cards keep their descriptions visible (the active-only fold is for radios). * fix(setup): wizard was skipped after first-run install — probe /setup/status on bootstrap ready The models-needed probe started at mount with a ~30s retry ceiling. On a first run, mount happens at the setup page — by the time the user reads it and the multi-minute install finishes, the attempts were long burned, so setupChecked landed as 'no wizard needed' and the studio rendered with zero models on disk. The probe is now keyed on bootstrapStage and runs when it hits 'ready' — the first moment a backend exists to answer. Normal launches (backend up quickly) behave exactly as before. Caught by running the full journey three times end-to-end: rounds 2–3 skipped Models & engines after install; with the fix the wizard mounts with models_ready=false (Whisper large-v3 listed missing). * feat(setup): drop the Welcome step — wizard opens on System check The welcome act had nothing left to say: the journey rail names the stages, the setup page already oriented the user, and the cards repeated both. The wizard is now three steps — System check (auto-runs on mount) → Models & engines → Try dictation — landing the user directly on live preflight results instead of a page about the pages to come. * feat(setup): true unified library — models + engines as ONE list 'Merge them' meant one list, not two panels stacked — fair criticism. The wizard's Models & engines act is now a purpose-built WizardLibrary: every installable is a row of the same grammar (LED · name · chip · size · action): - required models lead (REQUIRED chip, Download action, live SSE progress bar + percent, green LED when installed) — they gate continue - TTS engines follow (ENGINE chip): active engine glows accent, available ones offer one-click Use (selectEngine), heavy installs defer honestly to Settings ('install later in Settings' + reason tooltip) - the optional-model tail folds behind 'Show N optional models' The full management surface (search, HF token, deletes, sorting) stays in Settings — a first run needs a checklist, not a store. 9 new strings × 21 locales. Verified against the live backend via the browser harness: required/installed/engine/active/Use/defer states all render in one list. * feat(diagnostics): local-first self-check, error journal, and bug-report pipeline (#296) * feat(diagnostics): local self-check + scrubbed bug-report pipeline Closes the gap between 'something broke' and 'a useful GitHub issue exists' — entirely within the local-first constraint: the only outbound path remains the user's own browser opening a prefilled issues/new URL. Backend: - core/scrub.py: privacy scrubber for anything leaving the machine — env-var secret values (*TOKEN*|*KEY*|*SECRET*|*PASSWORD*), credential shapes (hf_/ghp_/github_pat_/sk-), home dirs on all three OSes - core/diagnose.py: 9-check self-check (device+GPU, ffmpeg, HF token, disk, data-dir writability, RAM, engine registry, hub reachability), pre-scrubbed, ASCII-safe output - GET /system/diagnose + 'python main.py --diagnose' (exit 0/1) - /system/info: hardware inventory (os_version, cpu_model, cpu_count, ram_total_gb, gpu_name, vram_total_gb, disk_free_gb), cached statics Frontend: - utils/bugReport.js: single source for the prefilled-URL builder — scrubText twin, hardware context capture, scrubbed error+stack embed, URL-length cap; ReportBugButton refactored onto it - ErrorBoundary 'Report this bug' action with the error attached - utils/errorToast.jsx toastErrorWithReport(); wired into export toasts - Settings > About 'Run self-check' with per-check status badges Tests: 27 pytest (scrub, diagnose) + 15 vitest (bugReport); existing suites green; verified live (--diagnose, TestClient, vite build). * feat(diagnostics): error journal, diagnostic bundle, crash notice, global handlers Second slice of the bug-tracking work — still zero outbound paths beyond the user's own browser/file manager. - core/error_journal.py: deduped ring of recent unhandled backend errors (fingerprint counts, error_class triage: GPU_OOM, HF_AUTH_FAILED, PYANNOTE_LICENSE_REQUIRED, DISK_FULL, FFMPEG_MISSING, NETWORK_ERROR), scrubbed, JSONL-persisted so the error that killed the last run survives restart. Wired into the global exception handler; 500 bodies now carry error_class; GET /system/errors/recent. - core/diagnostic_bundle.py + POST /system/diagnostic-bundle + Settings > About 'Save diagnostic bundle': zip of self-check report, error journal, scrubbed log tails — drag onto a GitHub issue; bypasses the ~8k prefill-URL ceiling. - crash-on-next-launch: /system/notifications flags a crash logged before this session started (size vs acked-size in prefs, mtime vs process start); POST /system/crash/ack; LogsFooter acks on action click. - utils/globalErrorHandlers.js: uncaught errors + unhandled rejections get a throttled, noise-filtered 'Report this bug' toast. - sidecar log parity fix: _tauri_log_candidates() now lists the Rust sidecar's backend.log/backend_err.log on Linux (XDG state dir) and Windows (LOCALAPPDATA) — sidecar crashes were only visible on macOS. Tests: +19 pytest (journal, bundle); suite at 102 passed. Vitest 124 passed; vite build green. Live-verified: journal recorded and classified a real HF 401 from the test run (HF_AUTH_FAILED, paths scrubbed). * feat(diagnostics): breadcrumbs, deep self-check, report sweep, issue search Final slice of the bug-tracking work. - toastErrorWithReport adopted at the high-traffic failure sites: TTS generation, dub upload/ingest/transcribe, engine install, engines-matrix load, voice profile save/delete/test, batch enqueue/cancel/delete. Validation toasts and cancellations stay plain on purpose. - utils/breadcrumbs.js: local-only ring of the last 20 action names (closed-set names only — never content or paths), embedded as a 'Recent actions' section in the prefilled report. Instrumented: view changes, generate, dub pipeline, export, engine switch. - deep self-check: /system/diagnose?deep=true and --diagnose --deep load the active engine and synthesize a short utterance (num_step=4) — catches 'installed but broken'. 180s time-box, skips during model load, scrubbed failure detail. Verified live: cold-loaded omnivoice and produced 2.2s of audio in 43.9s on CUDA. - 'Search similar issues' action on the ErrorBoundary: scrubbed, noise-stripped GitHub issue search URL — dedupe before filing. - bug_report.md template now points at the diagnostic bundle and the --diagnose CLI so manual reports arrive with the same evidence. Tests: pytest 107 passed (4 new deep-check tests, CJK gate green); vitest 218 passed (breadcrumbs + issue-search suites); vite build green. * docs(diagnostics): self-diagnosis section in troubleshooting + README pointer Settings > About self-check / --diagnose / --deep / diagnostic bundle are now the documented first step before the per-error entries — and the support team's first ask on every issue. --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(setup): flush sticky action bar, global dbl-click maximize, open maximized First-run polish on the studio-console journey: - FirstRunSetup: fixed-footer / scrollable-middle layout — mast + decision grid live in a dedicated .frs__scroll region; the install action bar is the last flex item, so it sits flush at the window's bottom edge and nothing (e.g. an expanded compute-option description) can render beneath it on small windows. - Double-click-to-maximize on the custom borderless titlebar now works on EVERY drag region (splash, first-run, wizard, main header) via one delegated listener in main.jsx, on all platforms; removed App.jsx's redundant inline handler so it doesn't double-toggle. Skips interactive controls in the bar. - Window opens maximized to the available desktop size (tauri.conf.json). * fix(diagnostics): quiet Bandit on the journal hash and hub probe The journal fingerprint is a dedup key, not a security boundary — usedforsecurity=False. The hub reachability probe gets an explicit https scheme guard on its constant URL so the urlopen sink is audited. * fix(setup): address PR #295 review findings — security, lifecycle, privacy, i18n Security: - setup.rs valid_mirror: reject plaintext http:// mirror URLs (MITM supply-chain path into UV_PYTHON_INSTALL_MIRROR / UV_INDEX_URL / HF_ENDPOINT); explicit http://localhost / 127.0.0.1 / [::1] exceptions only. Tests extended incl. loopback-lookalike hosts. - setup.rs detect_hardware: AMD vendor ID alone no longer maps to kind="rocm" — a cheap ROCm userspace probe (/opt/rocm or rocminfo on PATH) gates it; bare AMD GPUs report kind="amd" so the UI offers ROCm without pre-selecting it ("matches this machine" only when verified). Functional: - lib.rs/setup.rs --setup re-entry: complete_setup now kills any backend still serving on the port before retry_bootstrap, so changed env/mirror/layout settings actually apply instead of re-attaching. - setup.rs: nvidia-smi probe runs behind a 3 s timeout thread — a wedged driver degrades to CPU instead of hanging the first-run IPC. - setup.rs: is_first_run is now a pure read; the existing-install migration write moved to migrate_existing_install_if_needed, invoked only from the bootstrap thread (get_setup_state no longer writes). - setup.rs complete_setup: config save errors now abort setup and surface in the UI instead of bootstrapping into a stale on-disk layout. - setup.rs complete_setup: logs default-vs-custom flags instead of the user's absolute env/data/models paths (privacy rule). - scrub.py + bugReport.js: also redact forward-slash Windows homes (C:/Users/<name>, file:///C:/Users/...), ordered before the macOS pattern so "C:~" residue can't form. Tests added on both sides. - bugReport.js: context fetches bounded by a 2.5 s AbortController timeout so report assembly degrades to partial context instead of hanging on a stalled backend. - system.py: crash ack is now {size, mtime} (legacy size-only ack still honored) and /system/logs/clear drops the ack — truncation can no longer permanently suppress 'crash-last-session'. - system.py: Linux Tauri-log probe honors XDG_DATA_HOME. - setup.ts/WizardLibrary.jsx: SetupProgressEvent type now documents the full phase taxonomy actually emitted (per-file start/progress/done + install_*/delete_* lifecycle); reducer verified correct against the backend stream and annotated — a file-level 'done' must not clear the repo row. - SetupWizard.jsx: step rail clamps to the highest unlocked step (preflight/models gates) — no more jumping straight to "Enter studio". Polish: - BootstrapSplash.jsx: Waveform heights wrapped in useMemo([bars]) like its siblings. - BootstrapSplash.jsx: detectHints returns i18n keys (bootstrap.hint_*) rendered through t(); translated in all 21 locales. - SetupWizard.jsx: step rail aria-label localized (setup.step_aria / setup.step_completed) in all 21 locales. - FirstRunSetup.css: deprecated word-break: break-word → overflow-wrap: anywhere; reduced-motion override also stops the frs-hw-pulse LEDs (.frs-step.is-active LED + .swiz-lib__led--busy). Deferred (design-level, follow-up PR): --setup re-entry round-tripping of custom dirs/mirrors into the form (setup.rs), and worker-thread leak on timed-out deep checks (diagnose.py). * fix(i18n): translate back-filled keys in all 20 locales, drop inline fallbacks The reconciliation merge back-filled 16 new keys (about.self_check*, about.*bundle*, dub.num_speakers_*, errors.*) with English text in every non-English locale — CodeRabbit flagged 9 locales; fixed all 20. Interpolation tokens preserved and asserted during the rewrite. Also removed the two inline English fallback strings in App.jsx (firstrun.first_sound_*) so copy lives only in locales/*.json. --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: mergetest <test@local> fix(dictation): microphone permission — OS usage descriptions, WebView grant handler, actionable denied-state UI (#323) (#336) On Windows 11 the dictation pill (Ctrl+Shift+Space) always reported "Microphone access denied" even though OS-level mic permission was granted (Voice Clone worked, backend transcribed fine). Root cause: no WebView2 PermissionRequested handler was registered, so WebView2 fell back to its own permission UI — which the 300x64 transparent, undecorated, deliberately-unfocused pill window can never host — and getUserMedia() rejected with NotAllowedError. Per-platform fixes: - Windows (WebView2): register a PermissionRequested handler on both the main and widget webviews that allows microphone/camera requests in code, for the app's own origin only (tauri.localhost + dev loopback). The Windows privacy toggle still applies on top. - Linux (WebKitGTK): the media-stream enable + permission auto-grant previously covered only the "main" window — the dictation widget is a separate WebView and was silently denied. Now applied to both. - macOS: already correct — NSMicrophoneUsageDescription ships in src-tauri/Info.plist and wry grants media capture to the app origin; documented in the shared helper. Frontend: getUserMedia failures are now mapped by error name (utils/micError.js) instead of one blanket "access denied" toast — permission denials get a per-OS "where to re-enable it" hint (Windows hint now mentions the desktop-apps mic toggle), missing devices and busy devices get their own messages, and the previously hardcoded English toast in useRecording goes through i18n. New keys added to all 21 locales. Tests: vitest unit tests for the error mapping (19 cases) and a Rust unit test for the WebView2 origin allow-list; Windows handler code cross-checked against webview2-com 0.38.2 / windows-core 0.61.2. Fixes #323 Co-authored-by: Claude Fable 5 <noreply@anthropic.com> fix(scripts): desktop-prod works from cmd/PowerShell via cross-platform launcher (#282) (#333) `bun run desktop-prod` (and its :run/:upgrade/:pill/:run:pill variants) invoked `bash scripts/desktop-prod.sh` directly. On Windows, cmd and PowerShell have no `bash` on PATH unless Git Bash happens to be there, so the documented from-source install path died with a cryptic spawn failure before printing anything — the exact first step in issue #282's repro. Add scripts/desktop-prod.mjs, a tiny launcher (runs under bun or node): - macOS/Linux: execs the bash script unchanged — zero behavior change. - Windows: locates Git Bash via `where.exe bash`, well-known Git for Windows install paths, or derived from git.exe's location; explicitly skips C:\Windows\System32\bash.exe (the WSL launcher, which would run the script inside Linux and wipe/launch the wrong paths). - No usable bash: prints an actionable error (install Git for Windows, use `bun run desktop`, or use the installer) instead of a spawn error. All flags are forwarded untouched and the child's exit code is propagated. scripts/desktop-prod.sh itself is unchanged, and docs/install/windows.md now lists Git for Windows as a prerequisite for from-source installs. Refs #282 Co-authored-by: Claude Fable 5 <noreply@anthropic.com> chore(probe): standardized PR-report publisher with redaction + review gate (#334) Turns the ad-hoc 'attach a probe trace to the PR' habit into one script: redacts credentials/home-dirs/emails/IPs from the HTML report, prints a markdown digest, prunes old local reports, and only uploads (secret gist + PR comment) behind an explicit --post --yes after human browser review. Co-authored-by: Claude Fable 5 <noreply@anthropic.com> fix(updater): preview channel offers the newest build across channels (#326) (#335) Root cause, two layers: 1. tauri-plugin-updater's default comparator is plain semver (remote > current). Preview builds are published as X.Y.Z-N (e.g. 0.3.5-41 = main, 41 builds after the 0.3.5 tag), which semver treats as a *pre-release* of X.Y.Z — so it sorts BELOW stable X.Y.Z. Once stable 0.3.5 shipped, preview users were told "you already have the latest version" forever. 2. The endpoint list [preview, stable] is not a "best of both" — the plugin stops at the first manifest that parses and uses later endpoints only as network fallbacks, so a reachable preview manifest hid a newer stable release entirely. Fix: for the preview channel, check BOTH manifests with a custom version_comparator implementing cross-channel ordering (higher base version wins; on equal base a suffixed preview build outranks the bare stable it was built on; preview-vs-preview uses numeric-aware semver pre-release comparison), then offer the newest candidate. A manifest error is non-fatal while the other manifest answers. The stable channel keeps the single endpoint and the plugin's default comparison — default behavior unchanged on all platforms. Adds 7 unit tests covering preview ahead of stable (the bug case), stable passing preview, equal-base both directions, equal versions (no ping-pong), numeric build-counter ordering, and base dominance. Co-authored-by: Claude Fable 5 <noreply@anthropic.com> feat(design): free-text 'describe your voice' field maps to design parameters (#317) (#331) Parity with the hosted omnivoice.app describe field, implemented fully locally: a deterministic, ordered synonym-table mapper (no model, no network, stdlib only) projects a natural-language description onto the existing six-category design space (Gender/Age/Pitch/Style/EnglishAccent/ ChineseDialect). Every emitted token is validated at import time against the engine taxonomy, so the mapper can never produce an instruct item the engine validator would reject; Chinese token forms are derived from the taxonomy, never hardcoded (the one functional pinyin->dialect mapping is allowlisted in test_no_hardcoded_cjk.py with justification). UI: a describe textarea in the Design tab fills the attribute picker live (hand-tuning still possible afterwards); parts of the description the taxonomy can't express are listed back to the user as 'ignored' instead of failing silently. New i18n keys in all 21 locales. Fixes #317 Co-authored-by: Claude Fable 5 <noreply@anthropic.com> fix(dub): re-dub honors transcript edits — fingerprints canonicalised, preview cache-busted, atomic mux (#281) (#329) * fix(dub): re-dub honors transcript edits — fingerprints canonicalised, preview cache-busted, mux made atomic (#281) Three symptoms, three causes: 1. Edited line, unchanged result: the dubbed preview-video URL was identical across re-dubs, so the WebView kept serving the previous dub. A generation nonce now cache-busts the preview after every completed generation. 2. Preview stuck loading forever: overlapping preview requests ran ffmpeg against the same output path and the mtime cache check saw the half-written file as valid. The mux now runs under a per-path lock, writes to a temp file, and os.replace()s into place. 3. One edit re-dubs all lines: server-side fingerprints were computed from pydantic-parsed segments (defaults filled in) but recomputed client-side from raw dicts (keys omitted), so every segment always looked stale and incremental degraded to a full re-dub. Values are now canonicalised on the backend and the frontend builds generation inputs through one shared helper (utils/segments.js) for both the generate request and the incremental plan. Fixes #281 * Potential fix for pull request finding 'CodeQL / Uncontrolled data used in path expression' Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> * fix(dub): realpath containment for job-derived preview paths (CodeQL) Request-supplied job_id/lang flowed into the preview mux output path. Both now pass a realpath containment guard against DUB_DIR (the file's existing per-segment pattern) and lang is allowlist-validated before it lands in a filename. * fix(dub): inline the containment guard — CodeQL can't track it through a helper --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> fix(tts): /generate honors the selected TTS engine (#312) (#324) * fix(tts): /generate honors the selected TTS engine (#312) The /generate route always ran the OmniVoice model directly, ignoring both the Settings engine selection and any per-request override. It now resolves the active backend (env var > Settings selection > default), supports an explicit `engine` form field (same pattern as /ws/tts and /v1/audio/speech), reuses the per-process engine instance cache, keeps inline [pause Nms] markers working on every engine, and honors applies_own_mastering so studio engines skip the broadcast mastering chain. The OmniVoice default path is byte-identical to the old behavior — existing API consumers see no change. * test(312): resolve modules at run time, drop lifespan client — fixes full-suite isolation tests/backend/** runs before tests/test_*.py and pollutes sys.modules (re-imports the services tree), so module-level imports bound at pytest collection pointed at a stale services.tts_backend — registry patches landed on a dict the routes no longer read ('Unknown TTS engine' in CI). Modules are now resolved through sys.modules inside each test. The client fixture also drops the module-scoped lifespan context manager that bound event_bus queues to this module's loop (teardown 'Queue bound to a different event loop') — plain function-scoped TestClient, the test_api.py pattern. --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> fix(bootstrap): self-heal structurally broken venv instead of exiting 106 (#314) (#325) A venv with no pyvenv.cfg (interrupted creation, half-deleted dir, or a managed Python that was removed) made the backend exit 106 forever; the only fix was manually deleting .venv. Bootstrap now (1) validates venv structure before declaring it ready and (2) recognizes the broken-venv death signature (exit 106 / 'No pyvenv.cfg file') after spawn — in both cases it quarantines only the .venv itself (rename-aside if deletion fails, never user data) and rebuilds through the normal setup path with existing progress stages. Healing is attempted once per launch; a healthy venv is never touched. The spawn+health-poll loop is extracted from lib.rs and shared with the retry path. Fixes #314 Co-authored-by: Claude Fable 5 <noreply@anthropic.com> fix(design): stop button + single-playback manager for voice previews (#316) (#322) Voice previews and synthesized outputs could overlap with no way to stop them: playBlobAudio() fire-and-forgot a fresh Audio()/AudioContext per call, and each component (Design demo grid, gallery, demo player) kept its own uncoordinated audio handle. - Add utils/playback.js: a global single-playback manager. claimPlayback() stops whatever was playing before registering the new playback, returns a release() for natural end, and exposes stopActivePlayback() plus a usePlaybackSource() hook for UI affordances. - Register every preview/output path with the manager: playBlobAudio (Synthesize output, profile previews, dub segment previews), DemoPresetGrid cards, VoiceGallery previews (archetypes / community / imports), and the CloneDesignTab "Hear demo" player. - Visible stop affordance: while a synthesized output is playing, the Design/Clone footer CTA becomes a "Stop playback" button (new i18n key clone.stop_playback in all 21 locales). Preview cards keep their existing play/pause toggle, now wired through the manager. - Tests: unit suite for the playback manager (claim/stop/release/ subscribe semantics) and two DemoPresetGrid regression tests for the single-playback invariant and the stop toggle. Fixes #316 Co-authored-by: Claude Fable 5 <noreply@anthropic.com> style(icons): thinner HD icon strokes app-wide + themed native file inputs (#300) Lucide ships stroke-width 2 on a 24px grid; at the app's 11-16px render sizes that weight reads heavy. One global rule (svg.lucide) re-weights every icon to 1.5 with geometricPrecision shape-rendering — crisper, lighter, no call-site churn. Hand-rolled SVGs (logo mark, batch spinner) don't carry the .lucide class and keep their bespoke weights; the one explicit per-icon strokeWidth (archetype icons) is dropped so the global weight governs everywhere. Native <input type="file"> chips are now themed via ::file-selector-button mirroring .ui-btn--subtle (chrome tokens, pill radius, hover states). All current file inputs hide behind themed labels, but any visible one — future panels, the LAN/share web view — no longer renders the OS-default grey button. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> feat(setup): flush action bar, global dbl-click maximize, open maximized (#318) - First-run action bar is now a pinned flex sibling below a dedicated scroll region (.frs__scroll) — flush to the window's bottom edge, with nothing rendering beneath it; only the content above scrolls. - Double-click-to-maximize is wired once in main.jsx, delegated across every data-tauri-drag-region (splash, first-run, wizard, main header) on all platforms, skipping interactive controls. Replaces the wizard-only handler in App.jsx. - Main window opens maximized (tauri.conf.json). - Setup wizard preflight checks flow into responsive columns on wide windows instead of one tall single column. Co-authored-by: Claude Fable 5 <noreply@anthropic.com> fix(bootstrap): sync venv deps on app upgrade — stale venv crashed on new imports (#307) (#319) Upgraded installs replaced backend/ + omnivoice/ sources from the bundle but never refreshed pyproject.toml/uv.lock or re-ran uv sync, so any dependency added after the user's venv was created was missing at import time — e.g. a venv predating scalar-fastapi (added May 4) died on startup with ModuleNotFoundError once v0.3.5 code landed on it. - bootstrap.rs: refresh pyproject.toml + uv.lock from the bundle whenever a healthy venv is reused; when the lockfile content changed, run `uv sync --frozen --no-dev` so newly added deps land. On sync failure (e.g. offline upgrade) keep the existing venv instead of bricking a previously-working install. - bootstrap.rs: the repair path now refreshes manifests first (it used to sync against the stale lock from when the venv was created) and applies the restricted-network HTTP env tuning it was missing. - backend/main.py: scalar_fastapi import is now guarded — it only powers /docs, so a venv without it must still boot; /docs returns 503 with an actionable message instead. Closes #307 Co-authored-by: Claude Fable 5 <noreply@anthropic.com> fix(asr): clone references transcribe via the ASR registry, not the broken transformers pipeline (#308) (#321) Voice cloning without a transcript fell through to OmniVoice's built-in load_asr_model() — a transformers pipeline() load of whisper-large-v3-turbo that fails outright on transformers 5.3 — even when whisperx / faster-whisper / mlx-whisper were installed and working. The dub pipeline already used the registry; the /generate clone path never did. - services/asr_backend.py: new transcribe_reference() resolves the active registry backend (honoring auto-detect order and the OMNIVOICE_ASR_BACKEND override), extracts text from either result shape (top-level "text" or whisperx-style segments), and degrades to None on any failure so the model fallback behaves exactly as before. When the registry itself resolves to pytorch-whisper it defers to the model's lazy load instead of building a second pipeline. - api/routers/generation.py: transcript-less references get transcribed in the GPU pool before inference. - tests/test_transcribe_reference.py: covers both result shapes, failure degradation, and the pytorch-whisper deferral. The remaining half of #308 — pytorch-whisper itself being incompatible with transformers 5.3 when it truly is the last resort — is tracked in the issue. Co-authored-by: Claude Fable 5 <noreply@anthropic.com> fix(dub): Timing strategy options never rendered — wrong prop name on Segmented (#313) (#320) The Timing control passed `options=` to <Segmented>, whose prop is `items=` (defaulting to []), so the toggle group rendered as a single empty pill with nothing to click — users had no way to pick Concise / Stretch Video / Strict slot. Broken since the control was introduced; every other Segmented call site already uses `items=`. Closes #313 Co-authored-by: Claude Fable 5 <noreply@anthropic.com> fix(tts): let studio engines skip the broadcast mastering chain (#311) * fix(tts): let studio engines skip the broadcast mastering chain apply_mastering() (HighpassFilter + Compressor + 8% Reverb) is tuned for OmniVoice's 24 kHz clone output. The OpenAI-compatible /v1/audio/speech route (_run_tts) runs it on every engine, including VoxCPM2 — whose native 48 kHz output is already studio-grade. There the compressor pump and the reverb tail are audible degradation rather than polish. Add an opt-out class flag TTSBackend.applies_own_mastering (default False, so all existing engines are unchanged) and set it True on VoxCPM2Backend. _run_tts() skips apply_mastering() when the active backend declares it. Loudness normalisation still runs for every engine (benign peak scale). * fix(tts): also skip mastering on the streaming route for studio engines tts_stream.py is the other route that runs the *active* TTS backend (get_active_tts_backend), so it needs the same applies_own_mastering guard as openai_compat._run_tts — otherwise VoxCPM2 output is still pumped/reverbed when streamed. The remaining apply_mastering() call sites (generation.py, batch.py, batched_tts.py, dub_generate.py) run the OmniVoice model directly via get_model(), never the active backend, so VoxCPM2 cannot reach them. * docs(tts): mark OmniVoice-only mastering sites with TODO(#312) Per review: instead of always-False guards on routes that never run the active backend, leave a pointer so the applies_own_mastering guard is added exactly when those routes become engine-aware (issue #312). fix(gguf): forward speech generation controls (#306) Co-authored-by: openclawer <bdfzer8@gmail.com> fix: disable tqdm on non-TTY to prevent OSError on Windows (#305) * fix: disable tqdm on non-TTY to prevent OSError on Windows (#283) When running as a Tauri backend (non-TTY stdout), tqdm tries to write terminal control characters which fails with Errno 22 on Windows. Set TQDM_DISABLE=1 when stdout is not a TTY during model loading. * fix: guard sys.stdout against None and fix import ordering (#283) - Add None check before calling isatty() to prevent AttributeError - Fix import ordering (sys after re alphabetically) fix(dictation): macOS auto-paste — don't steal focus, write clipboard natively (#287) (#299) Dictation via the global shortcut transcribed fine but the text never reached the target app on macOS, due to two stacked bugs (diagnosed, patched, and verified by @geektf in #287): 1. The ShortcutState::Pressed handler called win.set_focus(), making the widget frontmost — the simulated ⌘V from simulate_paste() landed in the widget instead of the app being dictated into. Skip set_focus() on macOS (same #[cfg(not(target_os = "macos"))] guard the other widget call sites already use). 2. With the widget unfocused, the WebView clipboard APIs (navigator.clipboard.writeText / execCommand('copy')) fail silently in WKWebView, so ⌘V pasted whatever was previously on the clipboard. simulate_paste now takes Option<String> and writes the transcript to the clipboard natively (arboard) before sending the keystroke — no window focus required. CaptureWidget passes the transcript; copyText() stays as best-effort for browser (non-Tauri) mode, and the optional param keeps any text-less call sites working. cargo check clean (the unreachable_code warning in setup.rs is pre-existing from #286); frontend node:test suite passes. End-to-end behavior verified by the reporter on macOS 26 / M4 Pro with both patches applied. Fixes #287 Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> fix(dub): video retry after URL ingest, responsive layout, icon-only toolbar (#304) Three reported issues in the dubbing editor: 1. Dark video after YouTube ingest: the preview mounted while yt-dlp was still finalizing the media file — the first load failed (MediaError 2 network / 4 non-media body) and the once-only error handler declared the source dead, leaving a black box until the project was reloaded. The error handler now retries with backoff (up to 6× over ~21s) before giving up; decode errors (3) stay terminal. 2. Responsive/resizable layout: min-width:0 on the split-grid columns (the classic shrink trap), settings-bar fields get real shrink room instead of locked min-widths, bulk selects flex, prep-bar overlays are viewport-bounded, and the segment table's fixed rails narrow at 1100px and collapse speaker/gain entirely below 760px so the text column keeps usable width at any size. 3. Toolbar: Save / Reset / Export are icon-only with hover tooltips (+ aria-labels); Generate Dub keeps its label as the primary verb. Skeleton header matches. Vitest 196/196 green. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> feat(setup): optional Hugging Face token in the library act (#303) The unified library dropped the inline HF-token field the old ModelStoreTab embed used to provide — so onboarding produced installs with no token, and users hit the 'speaker diarization disabled' wall on their first multi-speaker dub. Restored as a quiet disclosure at the bottom of the Models & engines act: password input → POST /system/set-env HF_TOKEN (same durable persistence Settings uses), saved/error states, Enter-to-save. Copy names the concrete benefit (pyannote diarization) and the local-first promise (token stays on this machine). 6 strings × 21 locales. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> test(setup): update DictationDemo asset-missing contract to #294 (#302) The test asserted the component renders nothing when demo clips 404 — the exact behavior #294 deliberately removed (it blanked the wizard's Try-dictation act on every real install). New contract under test: the script cards are asset-gated and disappear; the hotkey card (shortcut + press-to-verify, zero assets needed) stays. This was the single failure breaking CI on main since #294 merged (34 files / 196 tests green with the fix). Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> style(setup): stable scaffold — no layout shift anywhere in the journey (#301) Fair criticism taken: vertically centering variable-height content meant every act and step reflowed the page around its own center, and selecting an option pushed everything below it. The journey now has one stable scaffold — only the content region changes: - deck is top-anchored (waveform opens the page right under the titlebar; the centering dead-zone is gone) and fills the viewport - footer (serial plate, totals, armed action) is sticky at the bottom with a soft fade — never scrolls out of view, hugs the bottom when content is short - variable text gets reserved space: masthead subtitles hold two lines; option descriptions move out of the cards into a fixed two-line caption slot per radio group (aria-live), so switching options swaps text in place with zero shift — cards themselves are title-only - description tooltips retained on every card Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> feat(setup): first-sound ending, a11y pass, orphan-backend EPIPE fix (#298) * feat(setup): first-sound ending + accessibility pass First sound — onboarding ends with the product doing the thing: the moment the studio mounts after the wizard, one short line is generated locally and played ('Welcome to your studio. Every word you hear was generated on this machine, just now.' — localized, 21 locales), with a toast naming what just happened. sessionStorage handoff so it fires only on the run that completed the wizard; every failure path is silent — a first impression must never surface an error. Accessibility: - WAI-ARIA radio pattern on all option groups: roving tabindex (selected option owns the tab stop) + Arrow-key navigation, selection follows focus; groups get aria-labels - aria-live='polite' on the installing act's stage label so screen readers hear stage transitions - contrast: quiet text raised from 0.45–0.55 to 0.6–0.68 opacity — small visual change, real WCAG gain * fix(backend): orphaned backend couldn't load models — EPIPE-safe stdio Caught in the wild by the in-app diagnostic report: when the desktop shell that spawned the backend dies but the backend survives, its stdout/stderr pipes close — and transformers' tqdm weight-loading bar crashes the entire model load with BrokenPipeError on the next write. Fix: wrap sys.stdout/stderr in utils.hf_progress.SafeFileWrapper (the same EPIPE-swallowing wrapper the patched hub tqdm already uses) at startup. Logs are best-effort for a server process; model loading is not. Progress bars stay alive — they feed the loading-progress UI via hf_progress listeners, so disabling them was not an option. --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> feat(setup): onboarding quick wins — trust line, resume reassurance, download ETAs (#297) Three small high-leverage additions from the onboarding audit: - trust line on the setup page footer — 'Everything runs and stays on this machine — no account, no cloud, no telemetry.' The product's thesis, stated at the moment the user decides. - resume reassurance on the installing act and (while downloading) in the model library — 'Interrupted downloads resume automatically — closing the app is safe.' Kills unnecessary Clean&Retry panic; uv and the HF hub both genuinely resume. - ETAs on the long waits: the installing act derives an EMA byte-rate from successive bootstrap-progress events; library rows aggregate the per-file rates already on the SSE stream. Shown as '~3m left', only while a total is known and progress is mid-flight. 3 new strings × 21 locales. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> fix(setup): Try-dictation act was blank — keep the hotkey card when demo clips aren't bundled (#294) The wizard's final act rendered nothing on installs without the build_demos.sh sample WAVs (they aren't committed or shipped — every real install hits this). DictationDemo returned null whenever the asset probe 404'd, hiding the hotkey card too, even though that card teaches real things with zero assets: the registered shortcut and live press-to-verify via the tray-dictate events. Now only the replayable script cards gate on the bundled WAVs; the hotkey card always renders, with a hotkey-only lede ('hold, speak, release — press it now to verify') translated across 21 locales. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> i18n(license): translate AGPL commercial-license strings across 20 locales (#292) Follow-up to the AGPL relicense (abae6e2): the in-app Commercial License page strings were updated in English only, leaving 20 locales describing the old FSL model ("free for internal use, license required for competing products", "converts to Apache-2.0 in two years" — now false). - Translate the 5 reworded strings (enterprise.hero_desc/hero_note, enterprise_faq.a_internal_tools/a_try_before/a_watermark) into all 20 non-English locales: ar de es fr hi id it ja ko nl pl pt ru sv th tr uk vi zh-CN zh-TW, reusing each locale's existing terminology (Settings → Privacy path names, formality register). - Remove the now-orphaned q_apache/a_apache keys everywhere (the renderer block was already removed app-wide in 07479be's follow-up), restoring enterprise_faq key parity with en.json across all locales. - README: one-line macOS first-launch note under the download badges (right-click → Open / Settings → "Open Anyway", no Terminal) linking to docs/install/macos.md#gatekeeper-quarantine. Translations are AI-generated and tone-matched to each locale's existing strings — native-speaker review welcome. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> fix(release): MSI-legal preview version stamp — numeric pre-release identifier (#293) The Windows preview build dies in WiX with 'optional pre-release identifier in app version must be numeric-only and cannot be greater than 65535 for msi target' because the stamp was BASE-preview.N. Drop the word: BASE-N is still a valid semver prerelease (sorts below the stable BASE for the updater channel), unique per run, and MSI-legal. Failed run: 27096586578 (Windows x64; macOS + Linux built fine but the publish job was skipped). Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> feat: first-run setup screen — install mode, storage choice + minimum-space gate, mirrors, compute (#286) * feat(setup): first-run install gate — nothing installs until the user confirms a plan New `setup` module parks first runs in BootstrapStage::AwaitingSetup instead of auto-installing. complete_setup validates the user's InstallPlan and only then starts the existing bootstrap: - install modes: installed (platform dirs) / portable (one folder next to the exe / AppImage, config.json travels with it) - user-chosen storage: env dir, data dir (OMNIVOICE_DATA_DIR), model cache (OMNIVOICE_CACHE_DIR) — None = legacy default, byte-identical behavior - minimum-space gate: per-volume free-space check (fs4 statvfs), grouped by filesystem so dirs sharing a disk sum their requirements; install refused when short (9 GiB env + 7 GiB models + 1 GiB data, measured + headroom) - custom mirrors (PyPI index, HF endpoint, python-build-standalone) take precedence over region presets in the venv/sync/backend env wiring - ROCm torch variant selectable via config (env var still wins) - existing installs migrate silently: venv present → setup_complete=true, no questions re-asked; dev trees skip the gate entirely 19 unit tests (disk probing, space grouping, mirror validation, legacy config compat). * feat(setup): first-run setup screen — mode, storage with space gate, mirrors, compute FirstRunSetup renders when the Rust side reports awaiting_setup (lazy-loaded; regular launches pay nothing). One screen, defaults all work: - language picker first (rest re-renders translated), 21 locales shipped - Installed / Portable mode cards (portable disabled with reason when the exe-adjacent folder isn't writable) - storage rows with live per-path free-space probes (debounced check_install_target), 'needs ~X / Y free' readouts, folder pickers - client mirrors the Rust per-volume space gate: Start installation is disabled with an explicit reason until every volume fits - compute (CUDA-auto / ROCm), update channel, region + custom mirror URLs - complete_setup errors surface inline; on success the normal bootstrap progress UI takes over on the next status poll Verified on a wiped machine: gate parks (no spawn, no downloads), screen renders, 450 GB ≥ 17 GB requirement → Start enabled. * feat(setup): studio-console redesign of the first-run screen The setup screen now reads as powering on studio hardware rather than a web form — true to a voice studio, and self-sufficient offline (every font and asset is bundled; a first run may be on a restricted network): - breathing waveform masthead (CSS-only, deterministic speech-cadence silhouette, staggered per-bar delays) - Source Serif 4 display headline + engraved IBM Plex Mono panel labels + Inter body — the three faces the app already ships - rack-unit panels with corner screws, engraved title rules, serial plate (OVS · vX.Y.Z) - disk space as segmented LED capacity meters: lit = what the install consumes, alarm-blink red on insufficient volumes - mode cards with indicator LEDs; 'armed' Start button — LED lights and a halo pulses only once every volume passes the space gate - atmosphere: corner accent glows + SVG film grain; staggered rise-in choreography on load - all motion transform/opacity only; prefers-reduced-motion holds every frame still; theme-token derived colors; focus-visible rings throughout No logic changes: same IPC calls, same i18n keys, same space-gate math. * feat(setup): wide desktop deck, hardware-aware Compute + Update channel cards Three pieces of feedback addressed: - width: the console is now a 1240px two-column deck (storage rail left, decision rail right) that uses desktop real estate; collapses to one column under 980px and stacks fully under 620px - no outer chassis box: panels float directly on the atmospheric backdrop, each carrying its own rack-unit treatment - Compute and Update channel split into separate cards with real information: get_setup_state now detects hardware (nvidia-smi → CUDA name, /sys/class/drm vendor 0x1002 → AMD/ROCm, Apple Silicon → MPS, CPU cores + RAM via sysinfo; best-effort, never blocks) — the Compute card shows a live 'Detected: …' readout, badges the option that matches the machine, and pre-selects ROCm on AMD boxes; both cards use LED radio options with full descriptions (6 new i18n keys × 21 locales) Also pins playwright-core as an explicit devDep — bun did not materialize it through @playwright/test, breaking programmatic browser use. 20/20 Rust tests · vite build · CJK guard green. Verified live (gate engaged, responsive single-column) and at 1600×1000 via mocked-IPC browser shot (two-column deck). * feat(setup): move network (region + mirrors) into the masthead with language Language and download region are the two 'where am I' choices — they now sit together top-right of the masthead, with the custom-mirrors disclosure tucked beneath the subtitle. The Network panel is gone, leaving a balanced deck: Install mode + Storage left, Compute + Update channel right. * feat(setup): strip the boxes — fills and rules carry the structure One design rule now: borders only where state demands them. Panels lose their boxes entirely (engraved mono title + rule separates sections); option cards, storage rows, selects/inputs, the hw readout, the version plate and the ghost buttons are all flat fills; active options glow with an accent tint + LED; blocked rows and errors use a red tint + 2px inset edge bar instead of a border. The badge chip is fill-only too. * feat(setup): quiet pass — every element earns its visual weight - waveform becomes a whisper: 22px trace, 2px bars, ~half opacity — an ambient signature instead of a billboard - storage readouts collapse to one mono line ('needs ~9 GB · 449 GB free'); the LED meter now appears only when it carries information (install would consume >35% of free space, or the volume is blocked) — at 449 GB free a bar was a meaningless sliver - Change… buttons go text-quiet (transparent until hover) - custom-mirrors disclosure right-aligns under the region select it extends, instead of floating under the subtitle - version plate moves to the footer next to the disk total — the masthead keeps only title, subtitle, and the two locale/region selects * feat(setup): platform-matrix awareness — distro+arch detection, ROCm gated to Linux, no Windows console flash The install matrix is OS family × distro × arch × GPU vendor, and the setup screen now both shows it and only offers choices valid for it: - HardwareInfo gains os_name (distro PRETTY_NAME from /etc/os-release on Linux, macOS/Windows elsewhere) and arch (x86_64/aarch64) — the detected line reads 'CachyOS x86_64 · NVIDIA RTX 4070 · 32×CPU · 31 GB RAM', exactly what bug reports cite - SetupState gains os; the ROCm option renders on Linux only (wheels don't exist elsewhere) and complete_setup clamps rocm→auto on non-Linux as the server-side backstop - nvidia-smi probe gets CREATE_NO_WINDOW on Windows — no cmd flash on the first screen a user ever sees - Apple Silicon → MPS, Intel mac → CPU, ARM Linux → CPU: all matrix cells resolve through the same base constructor * feat(setup): unify the whole first-run journey under the studio-console system Setup → Installing → Model wizard now read as one continuous experience: the same atmosphere, whisper waveform masthead, serif/mono type, LED language and quiet fills across all three acts. - Installing (BootstrapSplash): rebuilt in frs-* — segmented LED journey meter (completed steps + live byte progress), LED step rail (done=green, active=pulsing accent, pending=dim), engraved ACTIVITY panel with the quiet mono log (collapse/copy as text-quiet actions), failure act with red-tint error + hints + armed Retry. All logic untouched: stage poll, event subscription + backfill, dedupe, hints, region/language selects. - Model wizard (SetupWizard): same masthead with the step rail as engraved mono LED steps top-right, welcome cards as option-card surfaces, preflight as LED check rows (pass/warn/fail), frs nav buttons with armed primaries, embedded Model Store / Engines / Dictation panels scroll inside the act. Old 556-line stylesheet replaced by ~60 lines of glue; BootstrapSplash.css reduced to a resolving stub. - FirstRunSetup.css is now the journey's shared design system (step rails, log panel, banners, hints, wizard chrome, check rows appended). - 2 new strings (Installing / Activity) translated across all 21 locales. Validated end-to-end on this machine: setup screen → Start installation → real venv bootstrap (~10 min) → backend healthy on 3900 → model wizard. 20/20 Rust tests · vite build · CJK guard green · installing act verified via mocked-IPC screenshot at stage=installing_deps. * feat(setup): --setup re-entry flag + make the install-plan screen un-stealable The setup stage is first-run-only by design (completed installs skip it), but it must be reachable on demand and must actually win the mount when engaged. Three fixes: - 'omnivoice-studio --setup' parks the bootstrap in AwaitingSetup on any launch — checked before the attach-to-healthy-backend shortcut, so a running backend can't skip past it - App routing: awaiting_setup now outranks everything (a live backend answering /setup/status used to route straight to the model wizard); the wizard additionally requires stage === 'ready' so it can't mount during the initial stage race - useBootstrapStage: a transient IPC miss no longer permanently declares 'ready' (which killed the poll loop and silently skipped the setup / progress screens) — it retries up to 5 ticks before conceding Plus journey-wide titlebar clearance (content never sits under the GTK headerbar / macOS traffic lights / Windows controls) and drag-region mastheads on all three acts. Verified: mocked-IPC harness with stage=awaiting_setup + a LIVE backend answering /setup/status renders the setup screen, not the wizard. * style(setup): remove backdrop decoration — flat surface, state-only emphasis The corner accent glows and SVG film grain rendered as visible banding / noise artifacts on many panels — both gone; the journey now sits on a clean flat chrome background. Also swept the remaining decorative bloom: the active option card drops its glow shadow (flat accent tint + LED carry the state), and the armed Start button loses its pulsing halo (the lit LED already signals actionable). Remaining shadows are functional micro-detail only: 6px LED glows, meter track inset, red edge bars. * feat(setup): journey rail + verbosity diet — clean, smooth, elegant The setup page is now visibly stage 1 of the install flow: a quiet breadcrumb rail (SETUP → INSTALLING → MODELS & ENGINES) sits between the waveform and the headline on both the setup and installing acts, LEDs marking done/active/pending — one continuous story across the journey. Verbosity halved without hiding information: - option descriptions unfold (260ms ease) only on the selected card; the page shows exactly one explanation per group, collapsed cards keep the text as a tooltip - storage rows drop their always-on caption (label + path + readout + Change… on one line; caption lives in the row tooltip) The whole page now fits a laptop window without scrolling. * feat(setup): merge Models + Engines into one wizard act Two tabs weren't necessary: models are the required gate, engines the optional extras — now two stacked panels in a single 'Models & engines' step (label reuses the journey-rail key, translated in 21 locales). Wizard shrinks to 4 steps: Welcome → System check → Models & engines → Dictation. Continue still gates on models_ready only; engines stay optional. Welcome cards updated to the 3 remaining acts; static cards keep their descriptions visible (the active-only fold is for radios). * fix(setup): wizard was skipped after first-run install — probe /setup/status on bootstrap ready The models-needed probe started at mount with a ~30s retry ceiling. On a first run, mount happens at the setup page — by the time the user reads it and the multi-minute install finishes, the attempts were long burned, so setupChecked landed as 'no wizard needed' and the studio rendered with zero models on disk. The probe is now keyed on bootstrapStage and runs when it hits 'ready' — the first moment a backend exists to answer. Normal launches (backend up quickly) behave exactly as before. Caught by running the full journey three times end-to-end: rounds 2–3 skipped Models & engines after install; with the fix the wizard mounts with models_ready=false (Whisper large-v3 listed missing). * feat(setup): drop the Welcome step — wizard opens on System check The welcome act had nothing left to say: the journey rail names the stages, the setup page already oriented the user, and the cards repeated both. The wizard is now three steps — System check (auto-runs on mount) → Models & engines → Try dictation — landing the user directly on live preflight results instead of a page about the pages to come. * feat(setup): true unified library — models + engines as ONE list 'Merge them' meant one list, not two panels stacked — fair criticism. The wizard's Models & engines act is now a purpose-built WizardLibrary: every installable is a row of the same grammar (LED · name · chip · size · action): - required models lead (REQUIRED chip, Download action, live SSE progress bar + percent, green LED when installed) — they gate continue - TTS engines follow (ENGINE chip): active engine glows accent, available ones offer one-click Use (selectEngine), heavy installs defer honestly to Settings ('install later in Settings' + reason tooltip) - the optional-model tail folds behind 'Show N optional models' The full management surface (search, HF token, deletes, sorting) stays in Settings — a first run needs a checklist, not a store. 9 new strings × 21 locales. Verified against the live backend via the browser harness: required/installed/engine/active/Use/defer states all render in one list. --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> chore(license): relicense from FSL-1.1-ALv2 to AGPL-3.0 (open-core) Replace the Functional Source License with the GNU Affero General Public License v3 across the project, with a paid commercial license retained for proprietary/closed-source use without AGPL obligations (open-core, like Firecrawl). - LICENSE: verbatim AGPL-3.0 text under an AGPL Notice + Scope header; drops the FSL "Competing Use" framing and the 2-year Apache-2.0 conversion. The bundled omnivoice/ TTS model stays Apache-2.0 upstream (AGPL-compatible). - Manifests now declare SPDX AGPL-3.0-only: pyproject.toml, Cargo.toml (normalized from bare AGPL-3.0), and both package.json (added license field). - README.md / README_CN.md: badge, pricing, commercial-use FAQ, License section. - en.json: in-app Commercial License copy reworded to AGPL; the false "converts to Apache 2.0" FAQ removed (+ its renderer block in SupportPage.jsx). Non-English locale strings still describe the old FSL model and are left for a follow-up translation pass. feat(tts): inline [pause Nms] marker for silence in generated speech (#276) (#277) Lets users insert pauses in the transcript: `[pause]` (350ms default), `[pause 500ms]`, `[pause 1s]`, `[pause 1.5s]`. Requester confirmed the `[pause Nms]` syntax (fits the existing marker style). Implementation is fully opt-in and model-free: - `omnivoice/utils/text.parse_pause_markers()` splits the text into `(span, pause_ms_after)` tuples (case-insensitive; bare number = ms; `s` suffix = seconds; adjacent markers sum; clamped to 10s). Text with no marker returns unchanged, so existing behavior is untouched. - `_run_inference` synthesizes each span as today and stitches a `torch.zeros` silence buffer between them at the `[pause]` points (matching channel dims/dtype/device); DSP/mastering then runs once over the combined audio. An explicit overall `duration` isn't split across spans (left to the model per span). Tests (no TTS model loaded): tests/test_pause_markers.py covers the parser (ms/s/default/clamp/leading/trailing/adjacent/round-trip) and the silence stitching with a fake gen fn (lengths + zeroed regions). Full pause + CJK guard + router smoke suites pass (39). Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> feat(dub): optional speaker-count hint for diarization (#274) (#275) When a clip has multiple speakers, pyannote's auto-detect sometimes collapses them into a single "Speaker 1" — so the transcript merges turns and the dub mixes voices. The diarization-consumption side is correct (overlap-weighted, distinct Speaker N ids — pinned by a new test), so the collapse comes from auto-detect itself. Add an optional speaker-count hint (the reporter's own suggestion): - backend: `/dub/transcribe-stream/{job_id}?num_speakers=N` (clamped 1–20; None → auto-detect) threaded to `diar_pipe(audio, num_speakers=N)`. Omitted entirely when unset so we don't depend on the kwarg in every pyannote build. - frontend: `dubNumSpeakers` store field + a compact "Speakers" number input in the dub panel (placeholder "Auto") + i18n; `transcribeStreamUrl` appends the param; the SSE hook reads the hint at stream-open time. Tests: tests/test_assign_speakers_from_diarization.py (multi-speaker split, overlap weighting, label robustness, empty-result safety) + dub.transcribeUrl.test.ts (param appended only for a positive int). Full backend diarization + frontend suites pass; CJK i18n guard passes. Does NOT close #274 — pending the reporter confirming that setting the count resolves the collapse on their video (can't verify pyannote behaviour without a CUDA box + the clip). Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> chore(release): v0.3.5 (#272) Patch release. Version bumped across all sources + lock files; [0.3.5] CHANGELOG. Ships: - #270 — speaker diarization fixed on PyTorch >=2.6 (weights_only=True rejected the pyannote checkpoint's TorchVersion global); the loader now registers the shared safe-globals allowlist before loading. Tagging v0.3.5 triggers release.yml (desktop) + docker.yml. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> fix(diarization): register torch safe-globals before pyannote load (#270) (#271) On torch>=2.6, `Pipeline.from_pretrained("pyannote/speaker-diarization-3.1")` fails with "Weights only load failed ... Unsupported global: GLOBAL torch.torch_version.TorchVersion" — PyTorch 2.6 flipped torch.load's default to weights_only=True and its secure unpickler rejects the checkpoint's metadata globals. This broke diarization on torch>=2.6 even when the license IS accepted (reported on v0.3.4, RTX 4070 Ti, license accepted). The WhisperX VAD load already solved this via `WhisperXBackend._allow_vad_pickle_globals()` (allowlists TorchVersion, omegaconf nodes, pyannote metadata, builtins, numpy, …). `get_diarization_pipeline` just never called it. Reuse it before the diarization load — idempotent, per-process, verified to register TorchVersion on torch 2.8. Graceful fallback (silence-gap heuristic) is preserved if anything still fails. Tests: tests/test_diarization_weights_only.py (allowlist runs before load; no-token short-circuit). Existing diarization classification tests still pass. Cross-platform (the torch 2.6 weights_only change affects all platforms). Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> chore(release): v0.3.4 (#269) Patch release. Version bumped across all sources + lock files; [0.3.4] CHANGELOG. Ships: - #255 — PyTorch-Whisper backend works as a standalone fallback (no cuDNN 8, no OMNIVOICE_PRELOAD_TTS_ASR=1), unblocking Windows+NVIDIA users hitting the cudnn_ops_infer64_8.dll error. Tagging v0.3.4 triggers release.yml (desktop) + docker.yml. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> fix(asr): PyTorch-Whisper fallback works without cuDNN 8 or preload (#255) (#268) Windows + NVIDIA users hit `Could not locate cudnn_ops_infer64_8.dll`: WhisperX/faster-whisper run on CTranslate2, which needs cuDNN 8, but PyTorch 2.8 ships cuDNN 9 and the side-loaded `cudnn8_compat` libs were missing from the venv. The PyTorch-Whisper backend should have been the fallback, but it errored "set OMNIVOICE_PRELOAD_TTS_ASR=1" because it only worked when the TTS model preloaded an ASR head. - `PyTorchWhisperBackend._ensure_pipe()` now builds its OWN transformers ASR pipeline on demand (PyTorch stack → cuDNN 9, no CTranslate2/cuDNN-8), without loading the full TTS model and without the preload env var. A constructor- passed pipe (when the TTS model already has one) is still reused. Model is overridable via OMNIVOICE_PYTORCH_ASR_MODEL. - dub_core transcribe preflight no longer hard-rejects pytorch-whisper when no pipe is preloaded — it lazy-loads; any failure surfaces per-chunk with the real cause. So a Windows box without cuDNN 8 can switch ASR backend to "PyTorch Whisper" in Settings → Models and transcription works. Docs: troubleshooting entry. Tests: tests/test_pytorch_whisper_fallback.py (lazy standalone build, reuse of a passed pipe, no get_model() call, env override). Full tests/ suite: 700 pass. Does NOT close #255 — pending the reporter confirming the fallback works on their machine; the cuDNN-8 install gap (faster-whisper path) is a follow-up. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> chore(release): v0.3.3 (#267) Patch release. Bumps version across all sources + lock files; adds [0.3.3] CHANGELOG. Ships: - #262 — Settings → About now shows the server's CPU architecture (was the client browser's platform, e.g. "Win32", in Docker). - Validates the bash-3.2 checksum CI fix on a real release (the macOS SHA256SUMS should now upload automatically). Tagging v0.3.3 triggers release.yml (desktop) + docker.yml (GHCR :0.3.3/:0.3/:latest). Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> fix(about): show server CPU arch, not the client browser's platform (#262) (#266) Settings → About → Architecture rendered `navigator.platform` — the *client browser's* OS. In the Docker/web build that's the remote machine (e.g. "Win32" when browsing from Windows), not the container, which is misleading. Expose the server's `platform.machine()` as `arch` on /system/info and render that instead, so the row reflects the machine OmniVoice actually runs on — for both the desktop app (local backend) and Docker. Note: the *blank* version/GPU/RAM/VRAM in the same report were the loopback-gate 403s fixed in v0.3.2 (#261); this PR fixes the remaining architecture row. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> fix(ci): make SHA-256 checksum step bash-3.2 safe (macOS runner) (#265) The "Compute SHA-256 checksums" step used `mapfile -t` (a bash 4+ builtin) but macOS GitHub runners execute `shell: bash` as /bin/bash 3.2, which has no `mapfile`. The step exited 127 ("mapfile: command not found") on the macOS leg, so `SHA256SUMS-macOS Apple Silicon.txt` was never produced/uploaded for v0.3.1 and v0.3.2 (the binaries themselves shipped fine; only the macOS checksum file was missing and had to be regenerated by hand each time). Replace `mapfile` with a portable `while IFS= read -r … done < <(find … | sort)` loop (works on bash 3.2). Verified on bash 3.2.57: builds the array correctly, handles spaces in bundle filenames. Linux/Windows legs are unaffected. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> chore(release): v0.3.2 (#264) Patch release. Bumps the version across all sources + lock files and adds the [0.3.2] CHANGELOG section. Ships: - #261 — "Loopback origin required" 403s in the Docker admin UI (and blank version): the image now runs in OMNIVOICE_SERVER_MODE so the loopback gate is relaxed for the headless deployment; desktop loopback boundary unchanged. Tagging v0.3.2 triggers release.yml (desktop) and docker.yml (GHCR :0.3.2/:0.3/:latest). Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> fix(server): relax loopback gate in headless server mode so Docker admin UI works (#261) (#263) In Docker the loopback origin gate (`require_loopback`) is unenforceable: Docker's NAT rewrites `request.client.host` to the bridge gateway (e.g. 172.17.0.1) even for a localhost-only `-p 127.0.0.1:3900:3900` mapping, so every request looks non-loopback. The gate then 403s the operator out of the routes the web UI needs — `/system/*` (incl. `/system/info`, which left the version blank, re-breaking #249 in Docker) and `/api/settings/*` (HF-token entry) — surfacing as "Loopback origin required" all over the UI. Fix: add an explicit, opt-in `OMNIVOICE_SERVER_MODE` flag. When set, `require_loopback` becomes a no-op; exposure is then governed by the operator's port mapping plus the optional share PIN (NetworkAccessMiddleware still 401s unauthenticated non-loopback clients whenever a PIN is set). The Docker image sets `OMNIVOICE_SERVER_MODE=1` (Dockerfile + documented in compose). Security: the desktop build NEVER sets this, so its loopback boundary is unchanged — LAN share guests are still denied the admin/system routes. New unit tests lock the contract (strict 403 by default incl. the PR #81 vectors; relaxed only under the flag). Existing non-loopback 403 tests still pass. Docs: docker.md troubleshooting entry for "Loopback origin required". Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> chore(release): v0.3.1 (#260) First tagged build of the 0.3 line off main. Bumps the version across all sources (pyproject + frontend package.json + Tauri conf + Cargo + both lock files + the source-checkout fallback in core/version.py) and adds the [0.3.1] CHANGELOG section. Ships: - #256 — browser/Docker file-export crash (invoke undefined) - #249 — version surfaced in web/Docker UI + desktop-only updater hidden - #255 — transcribe stream now surfaces the real ASR/model-load failure Tagging v0.3.1 triggers release.yml (desktop binaries) and docker.yml (GHCR :0.3.1 / :0.3 / :latest). Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> fix(dub): surface real ASR/model-load failures instead of dropping the stream (#255) (#259) * fix(dub): surface real ASR/model-load failures instead of dropping the stream (#255) When the transcribe SSE stream died before emitting any event, the UI showed a misleading generic "Transcribe stream dropped before emitting any segments. Likely ASR backend failed to load" — hiding the real cause (e.g. a faster- whisper/CTranslate2 cuDNN load failure, or a missing pkg_resources). The per-chunk transcribe was already wrapped, but two preflight/setup calls in the stream generator were not — if either raised, the connection dropped with no structured error event: - `get_model()` (preflight) — now wrapped; failures emit a structured `error` event built via `core.failure.build_failure` (sanitized reason + actionable hint, e.g. the pkg_resources→setuptools hint). - `offload_tts_for_asr()` — now non-fatal; an offload hiccup logs and continues rather than killing the stream. - The empty-segments guard now sanitizes each chunk error (no home-path/token leakage) and appends the recognized-failure-class hint. Adds a regression test: a raising `get_model()` must yield a structured `error` SSE event carrying the real message, not a dropped connection. Does NOT close #255 — this makes the underlying cause visible (pending the reporter's backend log) rather than asserting a specific Windows-CUDA fix. * test(dub): drive transcribe-stream gen directly (avoid cross-loop teardown) The regression test for #255 hit the SSE streaming endpoint through TestClient, whose lifespan created an asyncio Queue bound to a different event loop than the streaming request — erroring at teardown in the full-suite run. Drive the route's async generator directly instead: the preflight-error path yields a single event with no executor/Queue, so it stays isolated from any app loop. --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> fix(version): surface running version in web/Docker UI + hide desktop-only updater (#249) (#258) The Docker web build has no Tauri runtime, so Settings → About → Version read `getVersion()` (Tauri-only) and rendered a dash — leaving Docker users unable to tell which version they were running (issue #249). The update-channel toggle was also shown there even though the auto-updater is desktop-only. - Backend: expose the single-source `APP_VERSION` over HTTP — add it to `/system/info` (`app_version`) and `/health` (`version`). Both are model-free and the latter is zero-auth. - Frontend: Settings → About → Version falls back to `info.app_version` when no Tauri `getVersion()` is available, so Docker shows the real 0.3.x version. - Frontend: hide the update-channel toggle, update-endpoint row, and the "Check for updates" button outside Tauri — the Docker image updates by pulling a new tag, not via the in-app updater. - Docs: fix the wrong package name in the version-check command (`omnivoice-studio` → `omnivoice`) and document the new `/health` version field + the in-UI version row. Tests: assert `/system/info.app_version` and `/health.version` equal APP_VERSION (test_router_smoke.py). The stale `:latest` tag itself was already fixed in #252; cutting a v0.3.x release repopulates it. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> fix(frontend): browser/Docker fallback for file export (closes #256) (#257) The history-item export button (and the dub/audio export path) called the Tauri `save` dialog unconditionally. In the Docker web-server build there is no Tauri shell, so the plugin's internal invoke() dereferences an undefined __TAURI_INTERNALS__ and crashes with: TypeError: Cannot read properties of undefined (reading 'invoke') …which is exactly what users hit when downloading a freshly cloned voice from the browser/Docker UI. Fix: extract a shared `browserDownload` helper (utils/download.js) that does a plain HTTP-blob download via a temporary <a download>, and guard `handleNativeExport` on `isTauri` — falling back to that helper (streaming the file already served at /audio/<path>) when no Tauri runtime is present. `triggerDownload`'s browser branch now reuses the same helper instead of duplicating the blob-download logic. Adds utils/download.test.js covering the Content-Disposition parser and the no-Tauri download path (regression guard for #256). Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> fix(bootstrap): surface setuptools-repair failures + verify pkg_resources (follow-up to #253) (#254) * fix(bootstrap): surface setuptools-repair failures + verify pkg_resources (follow-up to #253) Three gaps flagged by review bots on PR #253 are addressed: 1. **Layer-2 repair result captured** (`bootstrap.rs` ~line 481): the `let _ = run_streaming(...)` that silently discarded network/permission failures from the targeted `uv pip install setuptools>=75,<80` is replaced with a `match` block that logs `log::info!` on success and `log::error!` on failure (consistent with the Layer-3 path). 2. **Post-repair re-verification added** (`bootstrap.rs`): after the targeted install in Layer 2, a second `import pkg_resources` check is run. If pkg_resources is *still* absent, a `log::error!` with an actionable remediation message is emitted before returning. This closes the gap where bootstrap handed back a known-bad venv that caused the dubbing crash (#248) with no clear signal in the log. 3. **Test strengthened** (`bootstrap.rs` `setuptools_repair_uses_correct_specifier`): the test now mirrors the exact `&[&str]` slice used in both repair branches and asserts `repair_args[2] == "setuptools>=75,<80"` as a single positional argument. This catches the split-arg regression the review bot identified (e.g. `["setuptools>=75", ",<80"]`) which would silently install the latest setuptools and leave pkg_resources absent. 4. **Smoke-test INST-01/02 hardened** (`scripts/smoke-test.sh`): exports `UV_PYTHON_PREFERENCE=only-system`, `UV_HTTP_TIMEOUT=120`, and `UV_HTTP_RETRIES=5` before the `uv run` import checks so that failures reflect real bootstrap regressions, not harness-network timeouts. `cargo test bootstrap` → 5 passed, 0 failed. Closes review findings on #253. Related: #248. * fix(bootstrap): fail clearly when pkg_resources repair fails (PR #254 review) - ensure_venv_ready now returns None (via fail()) when pkg_resources is still missing after the targeted setuptools repair, instead of returning a venv that crashes on the first ASR/dub call. The 'pkg_resources' message routes to the PKG_RESOURCES_MISSING failure mapping for a clear, doc-linked remediation. - smoke-test.sh: correct the comment (timeout+retry vars, not a non-existent index var). --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> test(probe): whole-app coverage — dubbing, i18n, engines, security, migration, dictation, design (#247) * test(probe): expand coverage — dubbing, i18n, engines, security, migration, dictation, design, coverage-critic Broadens the probe harness from one happy-path spec per layer to whole-app feature coverage (web, backend, dictation, clone, design), keeping the Actor/Judge split and offline-by-default + enable-on-demand for heavy paths. New specs + judges (one subprocess boot shared across backend-touching specs): - dubbing (L4): segment duration-ratio, SRT/VTT well-formed, export-archive contents, output language-ID (advisory) - i18n: locale files valid JSON (gate); orphan-keys + coverage (advisory). NOTE: surfaced a real bug — all 20 non-en locales carry gallery.cat_*/ bootstrap.lines keys absent from the en reference (reported, not gated). - engine matrix: active engine available + every unavailable engine explains why (11 TTS / 7 ASR backends via /engines/*) - loopback security: system routes reject non-loopback origins (403) - DB migration: alembic UPGRADE on the seeded omnivoice_data fixture - Coverage Critic: every declared layer still has a spec (gate) + API inventory - dictation: streaming-ASR WebSocket /ws/transcribe registered + handshake - voice design: reuses the audio-correctness ladder - real ASR round-trip: enable-on-demand (PROBE_E2E=1) Enriched _boot_runner.py to capture engines/asr/loopback/openapi/ws in ONE isolated boot (conftest boot_capture session fixture); added env.seeded_data_dir. 13 specs total. probe suite 74 passed / 5 skipped; full repo 687 passed, 0 failures. * fix(probe): address all 15 unresolved review findings on #247 - coverage.py:22 — use `with open(...)` context to close spec files after yaml.safe_load (file handle leak) - _boot_runner.py:80 — store only `type(exc).__name__` for WS errors; drop raw str(exc) that could leak home paths / secrets into capture JSON - _boot_runner.py:99 — snapshot DB files before boot; set db_created=True only when boot creates NEW files (not when fixture already had one) - dubbing.py:46 — FAIL segments_duration_ratio when validated==0 (guards against empty/corrupt segment list passing vacuously) - i18n.py:49 — FAIL locale_valid_json when locales_dir is empty/missing - i18n.py:7 — fix docstring: locale_no_orphan_keys is advisory, not blocking - test_probe_i18n.py:59 — assert r.passed is False, not just r.advisory - coverage_critic.probe.yaml:15 — add "meta" to required layers list - dub_export.probe.yaml:17 — capture dub_audio in steps before advisory reads it - migration.probe.yaml:13 — add path_exists(db_path) data-integrity check - test_probe_asr_e2e.py:33 — os.path.exists → os.path.isfile for PROBE_ASR_SAMPLE - test_probe_migration.py:24 — assert context["db_path"] (presence) not db_created (new creation), aligning with the boot_runner fix Two findings intentionally skipped with reasons (see review thread replies): test_probe_design.py:36 — offline pattern is intentional; actor step is bypassed by design throughout the probe suite for CI compatibility test_probe_engines.py:22 — whisperx pin is intentional; it verifies the shipped default ASR engine is available out-of-the-box * fix(probe): ASCII x in dubbing detail (ruff) + run migration judges inside seeded dir Two regressions from the hardening pass: - dubbing.py: replace non-ASCII '×' with 'x' (Ruff ambiguous-unicode → Tests lint fail) - test_probe_migration: move run_judges inside the seeded_data_dir with-block so the new path_exists check sees the DB before the temp dir is torn down (was always failing) --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> fix(docker): fix stale :latest tag + add push-to-main trigger (#252) * fix(docker): fix stale :latest tag and add push-to-main trigger (closes #251, addresses #249) Two bugs caused the Docker image to be stale (showing v0.2.7 inside a :latest/:0.3.x-tagged image): 1. **`:latest` was never set on tag pushes.** The metadata-action rule `type=raw,value=latest,enable={{is_default_branch}}` evaluates `is_default_branch` as false on tag-triggered runs (which run in a detached-HEAD context, not on the default branch). The tag rule was replaced with `enable=${{ github.ref_type == 'tag' }}` so `:latest` is updated on every `v*` tag push. 2. **No trigger for main-branch pushes.** There was no way to keep an up-to-date `:main` edge image between releases. Added `push: branches: [main]` which produces a `:main` rolling tag. Also added a note in the workflow and docs clarifying that the update-channel toggle (Settings → About) is a Tauri desktop feature and does not apply to the Docker image (headless web-server build). * fix(docker): gate mutable tags to push events + guard :latest against prereleases (PR #252 review) - semver / :latest / :main now require github.event_name == 'push' so a manual workflow_dispatch only ever emits a throwaway :sha- tag (no mutable-tag rollback) - :latest excludes prerelease tags (ref contains '-') so an rc/beta can't clobber it - header + tag-strategy comments corrected (:sha- emits on every trigger) Addresses greptile + coderabbit review on #252. --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> fix(bootstrap): guarantee pkg_resources in backend venv (closes #248) (#253) Root cause: the existing-venv fast-path in ensure_venv_ready() only checks `import uvicorn` before returning — it never verified that `pkg_resources` (dropped by setuptools≥80, issue #224) was present. Users who installed before commit675cc20(Jun 1) had setuptools 82.x in their venv; the app launched fine but any dubbing/transcription run immediately crashed with `ModuleNotFoundError: No module named 'pkg_resources'` via the whisperx → ctranslate2 → import chain. Reinstalling didn't help because the app re-detected uvicorn and skipped the repair sync entirely. Fix (three-layer defence): 1. **Existing-venv health check**: if uvicorn imports but `import pkg_resources` fails, fall through to the repair-sync path instead of returning the broken venv. Logs a clear message distinguishing "uvicorn missing" from "pkg_resources missing (#248)". 2. **Post-repair verification**: after the repair sync succeeds, run `import pkg_resources` again; if still absent, run a targeted `uv pip install "setuptools>=75,<80"` to install it directly without re-resolving the full dependency tree. 3. **Post-fresh-sync verification** (belt-and-suspenders): same pkg_resources check + targeted pip-install added after every fresh-install `uv sync`, catching the edge case where the bundled uv.lock is absent and uv resolves setuptools≥80 from scratch. All three paths use the same scrub_python_env + apply_uv_http_env guards already applied elsewhere; safe on macOS/Linux/Windows. Also adds `setuptools_repair_uses_correct_specifier` unit test and updates the INST-01 smoke-test comment to reference #248. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> test(probe): spec-driven AI-agent test harness (L1–L5 + HTML report + triage) (#245) * test(probe): add spec-driven AI-agent test harness (L1/L2/L4/L5 + report + triage) Introduces `tests/probe/`, a portable, mostly-deterministic test harness built on the Actor/Judge split: AI agents may drive and self-heal, but verdicts are always deterministic code + metrics — no LLM on the verdict path. Layers: - L1 API: Schemathesis property-fuzz over in-process ASGI (enable-on-demand). - L2 web: Playwright Driver + deterministic self-heal (id→test-id→text, loosened CSS) → pluggable Healer; LLMHealer/anthropic_healer for genuine agentic heal. Judges + self-heal logic unit-tested offline via FakePage; live browser skips. - L4 media: audio correctness — exists/decode/duration/not-silent/clipping/NaN, round-trip ASR WER (pure-python, faster-whisper backend), speaker similarity. No golden-WAV (device-stable metrics only); naturalness is advisory-only. - L5 env/first-run: fresh-data-dir backend boot in a SUBPROCESS (no session contamination), asserts health + DB init + endpoint reachability. Docker gated. Plus: hybrid YAML spec engine + JudgeResult/registry; self-contained HTML report that auto-opens (suppressed in CI/headless/PROBE_NO_OPEN); Triager that clusters failures and drafts a prefilled GitHub issue URL (sanitized, no auto-submit) with a one-click button in the report. Dependency-light: runs in the base venv; schemathesis/resemblyzer/playwright/ anthropic are enable-on-demand and skip cleanly. Generated reports gitignored. Full suite green (657 passed); no contamination of existing tests. * test(probe): add L3 desktop layer (Tauri config-integrity + guarded launch) Per the architecture decision, desktop E2E is substituted by backend-over-HTTP (L5) + browser (L2) since Tauri has no official macOS WebDriver. L3 guards the packaging/shell contract a browser test can't see, against the real tauri.conf.json (with platform-override merge), running on any platform with no Tauri toolchain: - version parity between tauri.conf.json and pyproject (release integrity) - dev/build wiring (devUrl matches the Vite frontend, frontendDist, before* cmds) - bundled binaries first-run depends on (uv / ffmpeg / ffprobe in externalBin) - CSP actually permits the local backend origins (desktop-only failure mode: packaged app can't reach :3900 while the browser build works) Adds desktop.py (config load + platform deep-merge + bundle discovery + launch guard), judges/desktop.py (config_present/config_eq/config_contains/csp_allows), desktop_smoke.probe.yaml, and tests covering integrity, platform-merge replace semantics, and a live bundle launch that skips without a built bundle/display. Full suite green (662 passed). --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> fix: dub OOM fallback, watermark on /generate, Settings responsiveness (#241 follow-up) (#244) * fix(dub): whisperx CUDA OOM → CPU fallback instead of a bare 500 Found while exercising the dub pipeline on an 8 GB RTX 4070 Laptop GPU: with the TTS model + GPU worker pool resident, whisperx's CTranslate2 load of large-v3 dies with "CUDA failed with error out of memory", and POST /dub/transcribe surfaced it as an unhandled 500 with no guidance. WhisperXBackend now catches a CUDA OOM at load and retries on CPU (int8, same model + accuracy, just slower) after clearing the CUDA cache. Dubbing keeps working on small/laptop GPUs instead of dead-ending. Only triggers on a CUDA OOM, so the MPS/CPU paths are untouched (cross-platform parity). Verified: /dub/transcribe on the prepped job went 500 → 200 with correct segments. Added a deterministic unit test (forces the OOM, asserts the device switches cuda→cpu; a non-OOM RuntimeError still propagates). * fix(watermark): embed invisible watermark on /generate output, not just dubs embed_watermark was wired only into the dub pipeline (dub_generate.py), so plain TTS from /generate came out unmarked even with invisible watermarking enabled — i.e. the setting silently did nothing for the main generate path. Embed it on the final audio in the generate handler too. embed_watermark self-gates on the setting + AudioSeal availability and passes audio through unchanged on failure, so it's a no-op when off and never breaks generation. Verified: detector on a fresh /generate clip went is_watermarked:false → true, confidence 1.0, message OMNI ("OM"), is_omnivoice:true. * fix(settings): wrap the settings sub-nav so tabs don't clip out of view The settings sub-nav has 10 tabs (General…Privacy) but the shared .ui-tabs primitive is a non-wrapping inline-flex row, so on a narrow Settings pane the later tabs (Credentials/Logs/About/Privacy) overflowed the right edge and were unreachable. Scope flex-wrap to `.ui-tabs.settings-tabs-ui` only — the bar now grows to 2–3 rows instead of running off-screen. The shared primitive (used by the models role tabs, log-source tabs, etc.) is unchanged. Verified at 900px (2 rows) and 700px (3 rows): all 10 tabs visible. * feat(settings): connect active tab to content via accent + tighten spacing Make the active settings tab read as connected to the panel below: each tab carries its own semantic accent (already in TAB_DEFS — Models pink, Engines purple, …) instead of a uniform pink, and that accent is threaded down as --settings-accent to paint a matching hairline along the top of the content panel. The shared colour ties tab→content subtly and wrap-proof (no fragile positional connector). Content wrapped in .settings-content with deliberate margin/padding so it breathes under the bar; the bar's own bottom margin is dropped so the bridge owns that gap. * fix(engines): make the compatibility matrix responsive (scroll, don't overlap) On a narrow Settings pane the matrix's fixed-width columns (status/gpu/ isolation/actions ≈ 630px) plus the flexible name column couldn't fit, so the cells collapsed and OVERLAPPED — name text rendered under the AVAILABLE/ACTIVE badges and GPU chips. Give the table a horizontal-scroll container with a shared header/body min-width (840px) and stop the fixed cells from shrinking, so columns keep their shape and stay legible at any width (scroll for the overflow) — the same data-table treatment used elsewhere. Fills normally on wide panes. * feat(settings): border-connect the active tab to its content panel Refine the tab→content connection from a single accent hairline to a "border-connect": the pill bar opens at its bottom (flat corners, no bottom border) into a 3-sided panel (.settings-content) framed in the active tab's accent, with a 2px full-accent top edge at the seam. The bar + panel read as one outlined container, and the active tab's colour visibly feeds into the panel it opens. Accent is threaded per-tab via --settings-accent. --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> fix(asr): repoint two 404 ASR model repo IDs in catalog (closes #239) (#242) * fix(asr): repoint two 404 ASR model repo IDs in catalog (closes #239) Model install failed with HTTP 404 for two ASR entries whose Hugging Face repos don't exist: - UsefulSensors/moonshine-small -> UsefulSensors/moonshine-tiny (Moonshine ships tiny/base; there is no 300M 'small') - Systran/faster-whisper-large-v3-turbo -> deepdml/faster-whisper-large-v3-turbo-ct2 (Systran publishes no turbo repo; deepdml is a valid CTranslate2 build) Audited all 25 catalog repo_ids — every one resolves 200 on HF after the swap. Adds a static (no-network, CI-safe) regression test asserting repo_ids are well-formed and the known-404 IDs can't reappear. * test/docs(asr): safer repo_id access + flag turbo repo as community build (PR #242 review) - test_known_404_repo_ids_absent: m.get('repo_id','') so a missing field gives a clean assertion instead of KeyError regardless of test order. - models.yaml: note the turbo entry is a community CTranslate2 conversion to re-verify on future audits (greptile). --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> fix(license): declare actual FSL-1.1-ALv2 in pyproject (was Apache-2.0) (#236) pyproject declared license = "Apache-2.0", but the repo's LICENSE is FSL-1.1-ALv2 (Functional Source License; each release converts to Apache-2.0 two years after publication). The Apache-2.0 declaration was inaccurate for the current grant. Declared as a PEP 639 LicenseRef since FSL isn't an OSI/SPDX-listed identifier. Validated: hatchling accepts the expression and builds the project cleanly (uv build OK), so uv sync / packaging in CI is unaffected. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> feat(update): move update pill into status bar + Updates panel (changelog/channel/history) (#240) * docs(spec): updates-in-status-bar design (move pill to LogsFooter + Updates panel) * docs(plan): updates-in-status-bar implementation plan (11 tasks, TDD) * docs(plan): pin i18n task to scripts/translate_all.py backfill * feat(update): pure chip + release presentation helpers * feat(update): listReleases + fetchAppVersion wrappers * feat(update): transient releasesSlice composed into store * feat(update): app version + channel in updaterSlice * feat(update): list_releases Tauri command (GitHub releases) * feat(update): UpdateStatusChip bar indicator * feat(update): UpdatesPanel (live row + channel + releases list) * feat(update): mount chip+panel in LogsFooter, retire floating UpdateBadge * feat(update): Settings channel switcher shares store value (auto-sync) * i18n(update): add updates.* keys across 21 locales * chore(deps): lock reqwest for list_releases command * polish(update): a11y radiogroup on channel switch, safer release key, drop dead test seam Addresses final-review nits (non-blocking): role=radiogroup/radio + aria-checked on the channel Segmented; key={r.name||r.version} to avoid collisions; remove the unused vi import + __loader seam in releasesSlice.test.ts. * fix(update): i18n the channel-set error + correct flagged updates.* translations (PR #240 review) * fix(update): add 10s timeout to list_releases HTTP client (PR #240 review) * fix(update): guard chip Restart against in-flight dub job (greptile P1, PR #240) The always-visible status chip's one-click Restart (ready state) called installUpdate→relaunch without the dub-busy guard the panel uses, so a user with a dub/transcription job running could lose in-flight work. Mirror the panel's gate: toast update.busy and bail when dubStep === 'generating'. * fix(update): surface channel-switch failures in the Updates panel (greptile, PR #240) Mirror Settings' error handling: the panel's stable/preview switch now catches a failed set_update_channel and toasts settings.channel_set_failed instead of an unhandled rejection. --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> fix(gallery): OmniVoice Gallery rename, dark dropdowns, and noisy/stale archetype previews (#241) * fix(gallery): rename to "OmniVoice Gallery" + fix dark-theme dropdown colors The gallery heading now reads "OmniVoice Gallery" (gallery.title, all 21 locales — brand prefix on each localized word). The facet filter <select>s (Gender/Age/Pitch/Accent/Language) rendered with the OS-default light control surface on the dark theme: .facet-select set background/border from --bg-tertiary / --border-color, which are defined nowhere. An undefined var() reads as transparent on the sibling <div> filters (fine over the dark page) but falls back to the native light background on a form control. Switch to the defined dark-chrome tokens and add color-scheme: dark + an explicit dark option list so the popup matches across WebKit / WebView2 / WebKitGTK. * fix(gallery): archetype previews render a noise buzz instead of voice The Hype Host, The Podcaster and The Vlogger previews played a loud tonal buzz, not speech. The preview renderer pinned num_step=16 and seed=42; the "social" sample script at that exact point lands on a degenerate diffusion trajectory and collapses to a near-pure tone. The blank-audio guard missed it because the buzz is loud (peaks near -2 dBFS), not silent — so the garbage was cached and served. The cache key is (instruct, language) only, so it never self-corrected. - Bump preview num_step 16 -> 32: reliably converges to speech across the gallery's instruct/script space (one-time, cached render cost). - Add a spectral-flatness floor (_is_unusable_audio) so a degenerate tonal render is rejected like a blank one, reusing the existing retry-on-new-seed path. Whisper/breathy voices are broadband (high flatness) so they're safe. Verified: flatness Hype Host 0.001->0.050, Podcaster 0.0002->0.083, Vlogger 0.004->0.039; whisper control (Calm Guide) 0.239, not flagged. * fix(gallery): stop preview playback replaying stale cached audio Preview audio is re-rendered server-side when an archetype is fixed, but the URL is stable and the response carried no Cache-Control — so the WebView's HTTP cache replayed the first clip it ever fetched (e.g. the old buzz) indefinitely, even after the server file was corrected. - Frontend: fetch previews with { cache: 'no-store' } so playback always pulls current bytes. - Backend: send Cache-Control: no-cache on the preview response so any client revalidates against the ETag instead of serving a stale clip. * test(e2e): add Playwright UI smoke + gallery specs and preview-quality unit test UI testing system to catch regressions like "Use design → Importing a module script failed" (a dead Vite/module server) and the noisy-preview bug. - Playwright (frontend/e2e): drives the system chromium (no browser download) against the Vite dev server. ui-smoke mounts all 12 routable views and fails on any code-split/import failure, uncaught exception, or ErrorBoundary fallback. gallery.spec asserts the "OmniVoice Gallery" heading, the dark facet dropdowns (computed bg = rgba(255,255,255,0.04), not the OS-default light surface), and that opening an archetype in the Designer mounts the lazy CloneDesignTab. `bun run e2e`. - backend/tests/test_archetype_preview_quality.py: unit-tests the _spectral_flatness / _is_unusable_audio guard with synthetic signals (tone < threshold < speech < noise; loud tone + silence are unusable) and pins the render constants. CI-safe — no model/GPU. --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> feat(launchpad): Transcripts card + recent OmniDrive files strip (#235) - New Transcripts action card (lime accent, FileText) → opens the Transcriptions view, alongside Clone/Design/Dub/Stories/Gallery. - Below the cards, a "Recent files" strip shows the last few exports from OmniDrive (GET /export/history, already loaded on mount) with a "View all files →" link that jumps to the full OmniDrive browser (Projects page). Hidden when there are no exports yet. App.jsx passes exportHistory to Launchpad; reuses existing lp-project-card chrome, adds a small files-head + view-all + grid in index.css. 5 new launchpad.* keys, backfilled across 21 locales. Verified: tsc clean, build OK, vitest 167/167, CJK guard passes. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> feat(support): unify Donate + Commercial License behind one toggle (#234) * feat(support): unify Donate + Commercial License behind one toggle The donate page and the commercial-license (enterprise) page were two separate full-screen modes reached from different places. Merge them into a single SupportPage with a charming segmented toggle: - Segmented "💛 Support ⇄ 🏢 Commercial License" control with a sliding active pill that carries each panel's accent hue (pink for Support, teal for License) and an icon that pops on selection. - Switching cross-fades the panel (key remount replays the hero/card entry animations) over the shared Launchpad aurora + a single Back button. - Both legacy modes still work: 'donate' opens the Support tab, 'enterprise' opens the Commercial License tab — so the footer heart and the dub/export "commercial license" links land on the right tab unchanged. Reuses the existing donate/enterprise chrome (DonatePage.css + EnterprisePage.css kept and imported); SupportPage.css only owns the toggle + transitions. Both views share one 640px container width so the frame doesn't jump on toggle. DonatePage.jsx + EnterprisePage.jsx removed (content folded in). 3 new support.* i18n keys, backfilled across 21 locales. Verified: tsc clean, build OK (SupportPage chunk replaces the two old ones), vitest 167/167, CJK guard passes. * feat(support): add 'Other ways to help' chips + FSL hero_note fix + i18n backfill - SupportPage: add Star GitHub / Join Discord chips below donation methods - SupportPage CSS: vertically center short Support panel, single-column donation grid, ghost-pill chip styles - Fix FSL hero_note wording across all 21 locales to accurately reflect the license (internal use at any scale is free; only competing product/service triggers commercial license) - New i18n keys: support.other_ways, support.star_github, support.join_discord * feat(support): polish Support panel + correct Commercial License wording Support panel (from screenshot feedback): - Donation methods now stack in a single clean column — no orphaned PayPal card floating in a half-empty second row. - Short Support panel is vertically centered so it no longer clings to the top of an empty page (License stays top-aligned; it's tall enough to fill). - New "Other ways to help" row: Star on GitHub + Join Discord ghost chips, so people who can't donate still have a real way to support — and it balances the layout. Commercial License wording: - Fixed enterprise.hero_note: it implied "deploying at scale (pay-per-use API)" triggers a commercial license. Per the actual FSL-1.1-ALv2, scale does NOT trigger licensing — internal use is free at any scale; the trigger is offering OmniVoice to others as a competing product/service. Reworded to say exactly that, and re-translated across all 21 locales. 3 new support.* keys. Verified: tsc clean, build OK, vitest 167/167, CJK guard passes, 21 locales at parity. * chore: drop stray stories-editor plan doc that slipped into the branch This planning artifact (with CJK i18n examples) was accidentally swept into an earlier commit on this branch; it isn't part of the Support-page feature and isn't on main. Removing it so the CJK guard passes — the committed tree no longer carries hardcoded CJK outside the translation layer. --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> fix(i18n): close coverage gap the translation PR missed (#230 follow-up) (#232) The "full i18n coverage" PR (#230) was based on a stale snapshot, so strings added to main after that point — the updater/channel dialog, dictation shortcut, and a batch of export/save/project toasts — were still hardcoded English. This extracts the remaining user-facing imperative strings (toasts + the update ask() dialog) plus the adjacent JSX labels in DubFailureNotice: - App.jsx (17): export/save/download/project/flush toasts → i18n.t('app.toast_*') (App already imports the configured i18n instance; reused it rather than plumbing a hook through 17 handlers). - Settings.jsx (14): save/clear failures, engine-switch, channel, updater download/install + the "Update available" ask() dialog, dictation-shortcut set/register/reset → t('settings.*'). - DubTab.jsx (DubFailureNotice): added the useTranslation hook; "Diagnostic copied"/"Copy failed" toasts + "Open docs"/"Copy diagnostic" labels. 38 new keys added to en.json, backfilled across all 21 locales. No regressions: the only shared-key value change from the #230 merge was the intentional engines.unavailable casing fix. Verified: 21 locales at parity, tsc clean, build OK, vitest 167/167, CJK guard. NOTE: this covers imperative strings (toast/ask) in these 3 files. A full codebase audit of all JSX text/placeholders is a larger separate sweep. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> feat(gallery): calmer, more elegant archetype cards (#231) The "Use voice" buttons were solid, fully-saturated per-category color fills — 16 loud, differently-hued blocks on screen drew the eye to the buttons instead of the voice names. Polish pass: - Use-voice button: tonal by default (13% accent wash + accent-colored text + hairline accent border), going solid only on hover/focus. Keeps the per-category hue as identity but lets the resting grid stay calm; the CTA lights up on the card you're pointing at. - Chip row: always rendered with a reserved min-height so cards without an accent/whisper chip (e.g. Captain Crusty) no longer leave a ragged void — action rows now line up across the grid. - Designer (wand) button: quiet at 0.5 opacity at rest, full on card hover/focus — it's tertiary, so it no longer competes on every tile. - Card hover border softened a touch. color-mix() is already used in 17 frontend files (proven on all WebView targets). Verified: tsc clean, build OK, vitest 167/167. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> feat(l10n): extract remaining 531 hardcoded strings — full i18n coverage (#230) * feat(l10n): complete translations for all 21 languages (837 keys each) Translate all UI keys across every component for 20 non-English locales: ar, de, es, fr, hi, id, it, ja, ko, nl, pl, pt, ru, sv, th, tr, uk, vi, zh-CN, zh-TW - 837 flattened keys per language (100% coverage) - Covers settings, splash, main UI, dialogs, tooltips, errors - Placeholders ({{var}}) and HTML tags (<1>) preserved - Add translate_all.py batch script for future re-translations * feat(l10n): extract remaining 531 hardcoded strings and translate all UI Scan found ~250 hardcoded user-facing strings across ~30 component files. Extracted all into en.json (837 → 1368 keys, 49 namespaces) and updated every component to use t() / i18next.t(). Components updated (35 files): - Zero-i18n: AudioTrimmer, CaptureWidget, CastingView, CheckpointBanner, CompareModal, DirectionDialog, EngineCompatibilityMatrix, ErrorBoundary, FloatingPill, KeyboardCheatsheet, NetworkToggle, ReadinessChecklist, SupertonicLicenseDialog, VoicePreview, BatchAddDialog - Partial-i18n: BootstrapSplash, DubSegmentRow, Header, LogsFooter, DictationDemo, DubbingDemo, NavRail, MultiLangPicker, SearchableSelect, Sidebar, Settings, SetupWizard, EnterprisePage, VoiceGallery, SharingPanel, ReportBugButton, StoriesEditor, App.jsx - Hooks: useDubWorkflow.js, useTTS.js (using i18next.t directly) New namespaces: trimmer, casting, checkpoint, compare, direction, errors, keyboard, header, sidebar, network, readiness, license, voicePreview, models, enterprise_faq, gallery_extra, dub_workflow, tts_errors, sharing, reportBug, app All 20 non-English locales translated to 100% (1368 keys each). --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> fix(logs): import useAppStore in LogsFooter (donate + notification nav crashed) (#229) LogsFooter.jsx called useAppStore.getState().setMode(...) in four handlers (donate button + notification action targets, lines ~405/447/880/922) but never imported useAppStore — clicking any of them threw 'ReferenceError: Can't find variable: useAppStore' and the handler died. Add the canonical 'import { useAppStore } from "../store"'. tsc + build clean. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> feat(launchpad): surface Stories + Voice Gallery; fix gallery key-spread warning (#228) - Launchpad showed only Clone/Design/Dub. Add ActionCards for the two newer modes: Stories (multi-voice audiobooks → setMode('stories')) and Voice Gallery (browse designed-voice archetypes → setMode('gallery')). i18n in en + zh-CN, backfilled across all 21 locales. - VoiceGallery: stop spreading a 'key' prop into <ArchetypeCard {...cardProps}> (React dev warning + ignored). cardProps no longer carries key; pass key={a.id} directly at the two render sites. Verified: tsc clean, build OK, vitest green, CJK guard passes. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> feat(stories): redesign — chapter section bars, grouped toolbar, readable column (#227) Full design polish pass (behavior unchanged; JSX structure + CSS only): - Chapters render as distinct section bars (heading title + grip + delete) with an accent left-border — no speaker/voice/tune/preview controls. Detection (isChapterText) is lenient so clearing the title doesn't flip the bar back to a voiced line mid-edit; unified with the chapter auto-numberer. - Toolbar split into three labelled clusters with thin dividers — Project (Projects · Cast) · Content (Import · Paste&Split · +Line · +Chapter) · Output (Stems · format · Generate) — and wraps instead of cramming one row. - Editor centered at a 1040px reading column so lines no longer stretch edge-to-edge on wide windows. - A line's secondary actions (inline-voice / tune / pause / preview / delete) are quieted to 0.5 opacity and revealed on row hover/active, cutting visual noise. Drag handlers factored into a shared dragProps (reused by both bar and line) so reordering still works across chapters + lines. Verified: tsc clean, build OK, vitest 167/167. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> feat(release): version preview builds (0.3.0-preview.N) + rollback spec (#226) Phase A: stamp each preview build with a unique monotonic semver prerelease (<base>-preview.<run_number>) via an ephemeral tauri.conf.json rewrite on the preview path. Today every preview reported the static 0.3.0, so the updater never saw a newer version and never delivered preview updates. The prerelease ordering makes each new preview offer-able and converges to stable when <base> ships. (Windows MSI ProductVersion strips the prerelease — caveat noted to verify; mac/linux unaffected.) Phase B (rollback) is captured as a design spec for review, not implemented: per-version preview releases + retention, an in-app Preview-builds picker, an allow_downgrades install path, and the alembic-head data-safety boundary. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> fix(asr): cap setuptools <80 so pkg_resources stays present (#224) (#225) Windows v0.3 users hit two transcription failures with models installed: - WhisperX: "Transcription produced no segments. No module named 'pkg_resources'" - Whisper PyTorch fallback: "No ASR backend is ready. … set OMNIVOICE_PRELOAD_TTS_ASR=1" Shared root cause: whisperx / faster-whisper import `pkg_resources` at runtime, and setuptools 80+ DROPPED the bundled pkg_resources. The existing pin `setuptools>=75` therefore resolved to 82.0.1 — which has no pkg_resources — so `import whisperx` fails. That both breaks WhisperX transcription and makes its is_available() return false, which is why every backend reports "not ready" and the engine asks for the PyTorch fallback (the user's PowerShell env var never reached the GUI-launched app, a separate red herring). Fix: pin `setuptools>=75,<80`. Verified: <80 resolves to 79.0.1 which ships pkg_resources; 82 does not. `uv lock` changed only setuptools (82.0.1→79.0.1). This fixes BOTH errors — WhisperX imports again, so it's available and the fallback is no longer needed. Adds tests/test_pkg_resources_available.py to guard the pin from regressing. Full suite 602 passed (incl. the new test), 0 failures. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> refactor(settings): move Performance toggles to the General tab (#223) The Performance panel (Disable torch.compile / Show live system metrics in header) was nested under the Credentials tab — an odd home. Render it in the General tab instead, where users look for app-level toggles. Pure relocation: PerformancePanel is unchanged; removed its render from CredentialsTab and added it after GeneralTab in the general view. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> fix(release): macOS smoke mount path preserves space in volume name (#221) fix(release): macOS signing env must be ABSENT not empty (unblocks mac build) (#220) feat(update): dismiss button for the failed-update pill (#219) #216 stopped the 6h periodic re-check from clobbering a failed-install error badge — correct, but it left no way to clear that badge except retrying, so a transient install failure pins a red "Update failed · Retry" pill until the user retries or restarts the app. Add a × to dismiss it (mirrors FloatingPill), returning the updater surface to idle. - updaterSlice: dismissUpdate() → idle + clears error/progress - UpdateBadge: × dismiss button on the error state (i18n: update.dismiss) - updaterSlice.test: dismiss returns to idle and clears the error - en.json: update.dismiss ("Dismiss"); other locales fall back to en Verified: vitest 10/10 (updaterSlice + updater guard), typecheck:ci clean, build OK. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> fix(release): resolve AppImage path before cd in Linux smoke (exit 127) (#218) fix(release): make macOS signing opt-in so a bad cert can't break builds (#217) The APPLE_CERTIFICATE secret is currently set-but-invalid, so tauri-action's 'security import' fails and kills the whole macOS build — on stable v* releases too, not just preview. Make Developer-ID signing OPT-IN: pass the Apple creds only on a v* tag push AND when the repo variable MACOS_SIGNING_ENABLED == 'true'. Otherwise pass empty -> the build stays unsigned and succeeds (users clear quarantine via xattr -cr, as documented). Preview is always unsigned. To re-enable signed stable releases: fix the signing secrets, then set MACOS_SIGNING_ENABLED=true (Settings -> Secrets and variables -> Actions -> Variables). No code change needed to flip it. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> fix(update): keep error/retry pill across periodic re-check (#214 follow-up) (#216) * fix(update): keep error/retry pill across periodic re-check (#214 follow-up) PR #214's 6h periodic re-check guard skipped only downloading/ready, not error. setUpdateChecking() clears updateError and the badge renders null for 'checking', so a tick while 'Update failed · Retry' was showing silently erased the prompt the user still needed to act on — defeating the PR's own error-surfacing goal (greptile P1, unresolved). - updater.js: also short-circuit the re-check on 'error'. Retry is user-initiated (installUpdate → downloading), so auto re-check is unnecessary in that state. - updater.test.js: new regression test — guard no-ops on error/downloading/ready, proceeds from idle. - UpdateBadge.jsx: add aria-controls + panel id to the 'What's new' disclosure (greptile P2 a11y). - UpdateBadge.css: word-break:break-word → overflow-wrap:break-word (CodeRabbit; the deprecated value). Verified: vitest 166/166, typecheck:ci clean, bun run build OK, CJK guard pass. * fix(update): keep notes panel mounted so aria-controls always resolves greptile P2: aria-controls pointed at a conditionally-rendered panel, so the IDREF dangled while collapsed. Render the panel whenever notes exist and toggle with the hidden attribute (canonical disclosure pattern) — the reference now always resolves. --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> fix(release): unblock all-platform preview/release builds + auto-generated notes (#215) The first preview build surfaced four real release-pipeline issues (all of which also affect a stable v* release): - macOS: build died at codesign — `security import: failed to import keychain certificate` (the APPLE_CERTIFICATE secret is set but invalid). Preview now force-skips Apple signing (passes empty creds) so it can't fail on a bad/absent cert; stable v* tags still receive the secrets, so signing engages once the cert is fixed. - Linux: .deb bundling fails with "Failed to create control scripts: No such file or directory" (no custom deb config of ours). Drop .deb, ship AppImage only — the universal Linux format and the Linux auto-update target. - Installer smoke (all 3 OSes): the steps hunted for a frozen backend binary to boot with --health-check, but the thin uv-venv installer ships no such binary (the venv builds on first launch). Rewrite to structural verification — assert the bundle carries the shell binary + bundled uv sidecar + backend source resources (pyproject.toml + backend/main.py). Also: a new preview-notes job regenerates the rolling preview release body with GitHub's auto-generated notes (What's Changed by PR + New Contributors + Full Changelog) plus a Contributors avatar strip built from the PR authors — instead of the bare "Auto-generated release for main…" fallback. Runs once after the matrix, preview-only; stable keeps its CHANGELOG section + appended checksums. Stable v* tag-push behavior is otherwise unchanged. YAML validated. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> feat(gallery): multilingual designed-voice archetypes (#213) * feat(gallery): multilingual designed-voice archetypes Ship curated designed voices in 9 more languages (Spanish, French, German, Italian, Portuguese, Russian, Hindi, Japanese, Korean) so the gallery offers more than English + Chinese out of the box -- 27 new featured archetypes across three reusable roles (Narrator / Explainer / Companion). Voice-design timbre (gender/age/pitch) is language-independent, and a designed voice's spoken language is driven by the preview text, not the instruct. So these reuse a neutral instruct + a localized sample script + a `language` value matching frontend/src/languages.json -- byte-for-byte the same model.generate(text, language, instruct) call the Generate tab already makes. They carry no accent/dialect token (accents are English-only, dialects Chinese-only; an invented "spanish accent" would crash synthesis, the issue-#89 mode), so every instruct stays inside the validator vocabulary. - backend/core/archetypes.py: _ML_SAMPLES + _ML_ROLES + _make_multilingual() - frontend VoiceGallery: extend the language facet filter - tests: assert the 9 languages are present, neutral-timbre, valid-token - test_no_hardcoded_cjk: note JA/KO sample text in the existing allowlist entry * fix(gallery): surface featured-only languages through the filter Address review feedback (greptile/coderabbit) on #213: - VoiceGallery: the Browse query hard-coded `featured: false` while the Featured strip is hidden whenever a filter is active. The 9 new languages have *only* featured archetypes, so selecting Spanish/French/etc. produced an empty Browse AND a hidden Featured strip -> "No voices match these filters" despite 3 archetypes existing per language. Now Browse includes featured exactly when the Featured strip is hidden (i.e. when filtering), with no duplication when nothing is filtered. - archetypes.py: module docstring said the Featured tier was "~24"; it is now ~51 (24 English + 27 multilingual). Added a docstring to _make_multilingual(). --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> feat(update): release notes in badge + periodic re-check + error surfacing (#214) Closes the three highest-value gaps from the auto-update audit vs best-in-class: 1. Release notes — the available pill gains a 'What's new' expander showing the release body (already captured as updateNotes) so users see what changed before installing. 2. Periodic re-check — App.jsx re-checks every 6h, not only on boot, so long-running sessions get notified. checkForUpdate now no-ops while a download/restart is in flight, so the interval can't interrupt an install. 3. Error surfacing — the badge no longer returns null on 'error'; it shows a 'Update failed - Retry' pill (with the error as tooltip) that re-attempts the install, instead of silently vanishing. i18n: update.whats_new/failed/retry added to en + zh-CN and backfilled across all 21 locales (placeholders intact). Verified: tsc clean, vitest 162/162, build OK, CJK guard passes, 21 locales valid + key-complete. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> docs(readme): add ASR Engines table (surfaces SenseVoice/FunASR) (#212) * docs(readme): add ASR Engines table (surfaces SenseVoice/FunASR) The README documents the multi-engine TTS backend but never listed the ASR backends, so users filed requests (#206, #208) for engines that already ship. Add an ASR Engines section mirroring the TTS table, grounded in backend/services/asr_backend.py engine ids + display names, plus a nav anchor. * docs(readme): correct Parakeet language scope + MLX framework name Address review feedback on PR #212: - Parakeet TDT: NeMoASRBackend docstring documents 25+ European languages w/ auto language detection (not English-only); note GPU req. - MLX Whisper: the engine uses Apple's MLX (Metal-backed) framework, not the CoreML inference stack. --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> fix(ci): skip supertonic license/cpu tests when the optional dep is absent (#211) release.yml installs deps with plain 'uv sync' (no optional engines), so test_cpu_only_honest and test_license_gate failed there — is_available() short-circuits with 'supertonic package not installed' before reaching the license check those tests assert on. This blocked EVERY release (preview and stable) at the test gate, not just the preview build that surfaced it. Skip the two when 'supertonic' isn't importable (optional opt-in engine). They still run fully under ci.yml's 'uv sync --all-extras'; the absent-package path is covered independently by test_optional_dep_missing. Also fixes the same two failing on a local '.venv' without the extra. Verified: tests/test_supertonic3.py now 8 passed, 5 skipped, 0 failed without supertonic installed. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Add Arch Linux installation instructions (#209) * Add Arch Linux installation instructions Added installation instructions for Arch Linux. * Update docs/install/linux.md Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --------- Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> chore(lint): remove unused imports + variables (ruff F401/F841) (#210) Autofixes the genuine lint behind the CodeQL py/unused-import and py/unused-local-variable note-level alerts — actually removing the dead code rather than dismissing it. 68 safe fixes via 'ruff check --select F401,F841 --fix' across 29 backend files (dead stdlib/symbol imports like io/sys/json/torch/typing.Optional and unused locals). Only ruff's safe fixes applied — the 9 'unsafe' fixes and the audio_dsp numpy availability import were left untouched. Not touched: empty-except (needs per-site judgement, not autofixable); frontend js/unused-local-variable (eslint no-unused-vars has no autofix); the loopback-low-risk path/log/stack-trace alerts (real, left visible). Verified: full tests/ suite unchanged at 601 passed (the 2 test_supertonic3 failures are pre-existing on main, local .venv state, green in CI). Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> feat(gallery): lucide/flag icon redesign + community marketplace (omnivoice-gallery) (#207) * feat(gallery): lucide icons + country flags + card redesign (replace emoji) - backend archetypes emit lucide-react icon *names* (cross-platform; emoji render inconsistently across OSes) for use-cases and the 24 featured voices. - new frontend/src/utils/archetypeIcons.jsx: name→lucide map, accent→country flag (country-flag-icons, tree-shaken to ~11), per-category color scale, color-coded avatar tile, and a CSS-animated now-playing equalizer (prefers-reduced-motion aware). - card redesign: real elevated surfaces (cards were invisible on the dark bg), avatar + name + facet sub-line, accent/flag chips, and a footer with Preview / category-colored "Use voice" / Open-in-Designer. * feat(marketplace): community voice gallery via omnivoice-gallery submodule Offloads curated + community gallery content to the standalone debpalash/omnivoice-gallery repo — added here as a submodule for authoring, loaded at runtime via the jsDelivr CDN so the binary stays small. Content repo (seeded + pushed separately): manifest.json (24-voice starter pack generated from the featured archetypes), a JSON schema, CONTRIBUTING, and GitHub submission templates carrying consent / no-impersonation guardrails. Backend (api/routers/community.py): configurable sources (env var > file > default), CDN fetch with offline disk cache, strict validation (invalid presets and non-allow-listed audio URLs are dropped, so a bad community entry can neither crash synthesis nor fetch from an arbitrary host), filtering, the prefilled submit URL, and "use" (preset → archetype render path; voice → sha256-verified download). 11 tests. Frontend: a third gallery zone, "Community", reusing the redesigned card, plus "Submit a preset / voice" buttons opening the prefilled GitHub forms. Local-first preserved: network only on open/refresh; everything cached; the built-in generated archetypes need no network, so the gallery is never empty. * fix(marketplace): address review feedback on the community gallery - community_use: run the blocking manifest read + voice download in a thread (asyncio.to_thread) so they don't stall the event loop (greptile P2). - community_submit_url: validate the `source` override against an owner/repo pattern, falling back to the configured default (greptile P1 hardening). - rename the `type` query param to `item_type` (alias="type") so it no longer shadows the Python builtin (coderabbit). - frontend submit buttons use the canonical openExternal() (Tauri-aware) instead of window.open, which doesn't open the system browser in the desktop app. - lowercase the `currentcolor` CSS keyword (stylelint). * refactor(marketplace): rename useCommunityItem -> addCommunityItem (not a hook) Avoids the use-prefix on a plain API function (rules-of-hooks smell). --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> i18n: backfill gallery + archetypes keys across 20 locales (#205) The de-celebrified Voice Gallery (#203) added gallery.* (45) + archetypes.* (15) keys to en.json only, so all 20 other locales fell back to English for the new gallery UI. Backfill all 60 keys into every locale via the project's own scripts/translate_all.py (Google Translate, placeholder-masked, incremental) — the same tool/path fixed in the earlier update-channel backfill. Also removes 8 stale gallery.cat_* keys per locale (cat_celebs, cat_marvel, cat_disney, cat_politicians, cat_anime, cat_books, cat_gaming, cat_news) — the dead celebrity categories #203 removed from en.json but left behind in the other locales. Finishes the de-celebrification across the whole i18n layer. en.json / source untouched. Verified: all 21 locales valid JSON and key-aligned to en for gallery + archetypes (0 missing); {{version}}/{{pct}}/{{channel}}-style placeholders intact (0 losses); tsc clean; build OK; CJK guard passes (locales are the allowlisted translation layer). Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> fix(audio): stop near-silent renders becoming blank noise + guard archetype renders (#204) * fix(audio): stop near-silent renders becoming "blank noise" + guard archetypes Root cause of the blank/hiss voices: normalize_audio peak-normalized to -2 dBFS whenever max(|audio|) > 0. When the model emits a near-silent clip (peak at the noise floor, e.g. 1e-4), that applies thousands of × of gain and lifts the noise floor to full scale — silence turned into loud hiss. This affected every generation path (clone/dub/design/archetypes), which is why "some voices" came out as blank noise. - services/audio_dsp.py: normalize_audio gains a -50 dBFS silence floor. At or below it the audio is left untouched (stays inaudible) instead of being amplified. Real speech — even a whisper — peaks well above the floor, so normal output is unchanged. - api/routers/archetypes.py: after rendering, _is_blank_audio() detects a dead clip (empty / non-finite / peak < 0.02 — a real normalized clip peaks ~0.79). The render retries once with a different seed, then fails loudly (503 via the existing handlers) so a blank preview or voice profile is never cached/saved. Also extracts the script with a non-empty fallback. - core/archetypes.py: _build never falls back to an empty script (empty text synthesizes to silence). Tests (tests/, runs in CI): normalize_audio doesn't amplify silence but still normalizes real audio to target; _is_blank_audio flags dead renders and passes real audio; every archetype carries a non-empty sample script. Verified: full tests/ suite 601 passed incl. 8 new (the 2 test_supertonic3 failures are pre-existing on main — local .venv engine/license state, green in CI — and unrelated to this diff). * fix(gallery): static log message in blank-render retry (clears py/clear-text-logging) --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> feat(gallery): designed-voice archetype gallery + neutral importer (#203) * feat(gallery): designed-voice archetype gallery + neutral importer Adds a browsable library of ~1,100 designed voice archetypes (no real people), generated from OmniVoice's own voice-design taxonomy and organized ElevenLabs-style: 24 curated Featured voices plus a facet-filtered "Browse all" explorer (595 English + 504 Chinese-dialect). Every generated instruct is built from the validator's own vocabulary, so none can trigger the issue-#89 synthesis crash. Backend: - core/archetypes.py: catalog engine (featured + generated, implausible combos pruned, stable hashed ids); loads the taxonomy by file path to stay torch-free in tests. - api/routers/archetypes.py: categories / list+filter+paginate / get / preview (render-on-demand + disk cache) / use (materialize a voice profile). Preview/use reuse generation.py's proven inference path. - gallery.py: drop the celebrity/character catalog; the importer is now a neutral, user-driven "My Imports" (paste a URL you have the rights to). No project-shipped directory of named real people. Frontend: - Gallery UI rewrite: Archetypes zone (featured grid + facet filters + favorites) and My Imports zone; per-card Use voice / Open in Designer. - api/archetypes.ts, useArchetypes/useArchetypeCategories hooks (v5 placeholderData:keepPreviousData), gallerySlice, en.json keys. Tests: 27 new (engine contract + API), full backend suite green (72); CJK guard allowlists the one functional Chinese preview sample. * fix(gallery): clear new bandit alerts (sha1 + SQL false-positive) The PR's code-scanning "Bandit" check fires on NEW alerts vs main's baseline. The archetype work introduced three: - archetypes.py / core/archetypes.py: hashlib.sha1 used to derive a deterministic preview-cache key and archetype id (not a security digest) — flagged B324 (HIGH). Add usedforsecurity=False; the digest is unchanged. - gallery.py: the UPDATE query interpolates only static, code-controlled column fragments ("is_favorite = ?", "description = ?"); every user value is bound via a ? placeholder — flagged B608 (false positive). Annotate `# nosec B608` with the justification. Behavior-preserving. Net new bandit alerts after this: zero (verified with bandit -ll -ii; only main's pre-existing baseline remains). * fix(gallery): resolve PR #203 CI (SHA-256 ids, log sanitization, CJK allowlist) All failures stemmed from the initial commit: - Bandit + CodeQL (2 high): SHA-1 weak-hash on the archetype id and the preview cache key. These are deterministic identifiers, never security digests — switched to SHA-256, which the SAST scanners accept. - CodeQL (log injection): the render-failure logs echoed the raw user-supplied archetype_id; log the catalog's canonical a["id"] instead (untainted — it comes from the trusted in-memory catalog, not the request). - CodeQL (superfluous argument): declare createGallerySlice's StateCreator store param so its arity matches the 3-arg call site. - Tests (test_no_hardcoded_cjk): the committed design spec's Chinese-dialect reference table tripped the guard; allowlist it under documentation. * fix(gallery): clear CodeQL clear-text-logging on archetype render errors CodeQL's sensitive-data heuristic flags any request-derived value interpolated into a log call (it persisted even after switching the raw id to the catalog's canonical a["id"]). Log a static message with exc_info=True instead: the full traceback still reaches the backend log for debugging, but no data expression remains for the query to flag. --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> i18n: backfill update-channel + auto-update keys across 18 languages (#202) PR #200 added 18 new locale files, but they predated #199 (auto-update badge + Stable/Preview channel toggle), so they were missing the `update.*` namespace (6 keys) and `about.channel_*` (5 keys) — those strings fell back to English in ar/de/es/fr/hi/id/it/ja/ko/nl/pl/pt/ru/sv/th/tr/uk/vi/zh-TW. Backfill all 11 keys in every one of those languages so the updater UI is fully localized. en.json / zh-CN.json already had them and are untouched. Placeholders ({{version}}, {{pct}}, {{channel}}) preserved verbatim; files re-emitted in the exact format scripts/translate_all.py writes (ensure_ascii=False, indent=2) so the diff is additions only (+13 lines/file, 0 deletions). Also fix scripts/translate_all.py: LOCALES_DIR was hardcoded to a contributor's absolute path (/Users/.../orca/...) — make it repo-relative so the generator actually runs for anyone. Verified: all 21 locales valid JSON + key-complete, placeholders intact; tsc clean; vitest 162/162; build OK; CJK guard passes (locales are the allowlisted translation layer). Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> feat(l10n): complete translations for all 21 languages (837 keys each) (#200) Translate all UI keys across every component for 20 non-English locales: ar, de, es, fr, hi, id, it, ja, ko, nl, pl, pt, ru, sv, th, tr, uk, vi, zh-CN, zh-TW - 837 flattened keys per language (100% coverage) - Covers settings, splash, main UI, dialogs, tooltips, errors - Placeholders ({{var}}) and HTML tags (<1>) preserved - Add translate_all.py batch script for future re-translations chore(security): scope CodeQL to shipped product code (#201) CodeQL flagged 459 alerts on main, but triage showed the bulk are in code that never ships in the installer's runtime path: file-not-closed in the omnivoice/eval harnesses, unused-global "FPs" in alembic migration boilerplate (revision/down_revision), bind-all in tests, and path sinks in the legacy Gradio research UI. They drowned out the handful of real findings. Add a CodeQL config (inline, supported because build-mode is `none`/interpreted) that scopes analysis to product code via paths-ignore: omnivoice/eval, research, tests, backend/migrations, and *.test.* files. Queries move into the inline config so security-and-quality stays the single source of truth alongside paths-ignore. Net effect on the next scan: the non-shipped-code alerts auto-resolve, leaving the security tab focused on shipped backend + frontend. No product code changes. Deliberately NOT touched (assessed, left as-is): - Stack-trace-exposure (detail=str(e) in routers) — these are intentional, helpful one-line diagnostics (the error-transparency work inb64f53b). Genericizing them on a loopback/single-user app would regress a product value for ~zero real benefit. - "Critical" command-injection in exports.py and the high path-injections in settings.py / system.py — design-correct false positives: list-form argv (no shell), and the destination/model-dir/ffmpeg paths are arbitrary user-chosen paths by design (containment guards would break the features). Best handled by dismiss-with-justification, not code surgery. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> feat(update): Stable/Preview update channels with opt-in toggle (#199) Adds a user-selectable updater release channel (Settings -> About -> Update channel). Stable (default, every install + launch) tracks tagged vX.Y.Z releases; Preview tracks the latest main build via a rolling "preview" prerelease, falling back to stable if a stable release is ahead. Why Rust: tauri-plugin-updater reads its endpoints from tauri.conf.json and neither the JS check() nor the plugin's registration Builder can change them at runtime (verified against the 2.10.1 source). The only runtime-endpoint API is UpdaterExt::endpoints, so check+install move into two Rust commands that mirror the plugin's own check/download_and_install -- the Stable path behaves identically to the JS flow it replaces; only which manifest is consulted changes. Switching is instant (channel is read per check), no restart. backend (Rust): - config.rs: update_channel field (default "stable", VALID_CHANNELS) + get/set_update_channel commands. - updater_channel.rs: channel_endpoints() (preview -> [preview, stable]) + check_update / install_update commands; install emits update://progress. frontend: - utils/updateChannel.js (+test): single source of truth, normalizeChannel. - utils/updater.js: routes the badge flow (#198) through the Rust commands via the same store contract -- UpdateBadge/App.jsx unchanged. - Settings About: Stable/Preview segmented toggle, channel-aware endpoint row + diagnostics; Check-for-updates honors the live channel. - i18n en + zh-CN. release.yml: additive, workflow_dispatch-guarded preview publish to a rolling "preview" prerelease. The v* tag-push stable path evaluates to its exact prior values (verified) and is never affected. Preview builds are manual -- no scheduled CI spend, nothing auto-published. docs/update-channels.md. Verified: cargo check (compiles clean), tsc, vitest 162/162, build, CJK guard, release.yml YAML parses. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Merge branch 'debpalash/translation' feat(l10n): add 15 more major UI languages and update auto-detector Merge branch 'debpalash/translation' feat(l10n): add Spanish, French, German, Japanese UI locales, auto-detection, and splash page switching feat(update): non-blocking auto-update with progress + states + busy-gating (#198) Replaces the blocking ask() dialog with a state-driven, progress-visible flow that never interrupts in-flight work — and preserves existing work by design (user data lives outside the bundle; alembic migrates on next backend start). - updaterSlice: idle→checking→available→downloading(pct)→ready/error state machine (transient, not persisted). 5 reducer tests. - utils/updater: checkForUpdate() (launch, non-blocking → store) + installUpdate() (downloadAndInstall with a Started/Progress/Finished → progress callback, then relaunch). No-ops outside packaged Tauri. - UpdateBadge: a non-intrusive pill — 'Update vX available · Install & Restart' → progress bar → 'Restart to update'. Install is gated while a dub job is generating (toast 'finish your dub first') so a relaunch can't lose work. - App.jsx: launch check now just surfaces availability into the store + mounts the badge (no blocking dialog, no silent auto-install). - i18n (en + zh-CN). Builds on the existing tauri-plugin-updater (signed, GH-release latest.json). Preview/main channel (a release.yml latest-preview.json + channel toggle) is a follow-up; this is the stable-channel core. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> feat(dub): FunASR cam++ inline diarization → dub speakers (#182 Phase 2) (#197) When FunASR is the active ASR backend, use its cam++ per-segment speaker IDs directly and skip pyannote — the 'all-in-one' diarization the issue asked for. - asr_backend: FunASRBackend loads spk_model='cam++' (ASR_FUNASR_SPK, set '' to disable); transcribe()/_normalize_funasr already surface per-segment speakers. - segmentation.assign_speakers_from_turns(segments, turns): generalised overlap-weighted speaker assignment from {start,end,speaker} turns (mirrors assign_speakers_from_diarization without a pyannote object; falls back to the silence-gap heuristic when no turns). Pure + tested. - dub_core: _transcribe_chunk collects offset-shifted speaker turns; they accumulate across chunks; _diarize() uses them and skips pyannote when present. Default (WhisperX) flow unchanged — no turns → existing pyannote/heuristic path. 4 new tests (overlap winner, containing turn, malformed-turn filtering, empty→ heuristic). Router smoke confirms boot. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> feat(ui): density follow-up — tighten studio-panel padding (14/16 → 10/12) (#196) Reduces the panel chrome padding across the studio (left + right columns), reclaiming vertical + horizontal space uniformly. Safe global compaction; the remaining bar-merge + borders→tints are a visual-iteration follow-up. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> feat(dub): density pass — tighten segment-row rhythm (min-height 30→27, padding 4→3) (#194) Safe slice of the compactness pass: shave per-row vertical space without clipping the two-line (translated + ORIG) content. The higher-impact wins (collapsing the stacked TRANSCRIPT/GLOSSARY/translations-ready bars, borders→ tints) are a follow-up best tuned visually. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> fix(ui): compact the top bar height (per request — keep it, don't move) (#193) Reduce .header-area vertical padding 6px → 3px and tighten the title (line-height 1.15, 1.05rem) so the top bar is shorter, reclaiming vertical space for content without relocating the bar. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> fix(ui): larger Projects/History/Exports icons when the sidebar is collapsed (#192) The collapsed sidebar rail showed the tab icons at size 13 — too small to read as the only affordance. Bump to 18 when collapsed; the expanded tab bar keeps 13. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> feat(asr): add FunASR (SenseVoice) as an opt-in alternative backend (#182) (#191) FunASR is an all-in-one multilingual ASR (50+ languages, punctuation, optional cam++ speaker diarization). ASR is already pluggable, so this is a new ASRBackend: - FunASRBackend (id 'funasr'): deferred funasr import in is_available() (reports an install hint when absent — opt-in, NOT a hard dep); _ensure_model loads AutoModel(SenseVoiceSmall + fsmn-vad); transcribe() normalises output. - _normalize_funasr(): pure, defensive normaliser → OmniVoice's {chunks, segments, language} shape (handles VAD sentence_info with ms timestamps + optional speaker, single-utterance fallback, strips SenseVoice rich tokens). Unit-tested without funasr installed. - Registered in _REGISTRY → auto-appears in /system/asr-backends → the Settings ASR picker, with availability/install hint. WhisperX stays the default. Phase 2 (future): wire FunASR's cam++ speaker ids into dub diarization. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> feat(dub): move Output Options + Timing to the top of the right (transcript) section (#190) Relocates the OUTPUT OPTIONS (Mix BG / Dual subs / Burn subs / Default Track) and Timing-strategy rows from the full-width footer panel to the top of the right transcript column, where the export-relevant settings sit next to the segments they affect. Footer keeps only the done/error banners + export-track toggles. typecheck/build green. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> feat(dub): move Generate Dub + Export to the header bar (with Save/Reset) (#189) Relocate the stateful primary-action cluster (Generate Dub / Stop / Stopping / Regen-changed + Export) from the bulky footer button bar up to the header bar next to Save/Reset, rendered as compact sm FooterBtns behind a thin divider. Frees the footer to hold just output settings — tighter, less scrolling, and the primary CTA sits where the file/Save/Reset context already is. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> fix(win): SoniTranslate venv paths cross-platform — Scripts/ on Windows (#186) (#188) sonitranslate.py hardcoded SONI_VENV/'bin'/{pip,python} (POSIX). On Windows, python -m venv creates Scripts\ with .exe, so is_venv_ready() was always False (install looped) and start() fell back to the wrong interpreter -> 30s timeout. - Add _venv_bin(name): Scripts/{name}.exe on win32 else bin/{name} (mirrors engines/indextts/bootstrap.py). Use it for the is_venv_ready/install/start pip+python paths. - stop(): _proc.terminate() instead of send_signal(SIGTERM) (cross-platform); drop the now-unused signal import. - test: _venv_bin returns Scripts/pip.exe on win32, bin/python on posix. Closes #186. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> fix(dub): key per-segment WAVs by stable id, not list index (#185) (#187) * fix(dub): key per-segment WAVs by stable id, not list index (#185) Partial regeneration ('regenerate only changed segments') reloaded/wrote seg_{i}.wav by LIST INDEX while the regen allow-list + fingerprints were keyed by STABLE id. After a delete/merge/split (ids preserved, positions shifted), unchanged segments reused a different segment's audio → silently corrupted dub output on the default in-UI incremental path. - core.config.dub_seg_path(job_id, seg_id): per-segment path keyed by stable id, sanitized to a bare filename (defends against path traversal via crafted ids). A numeric index sanitizes to the legacy seg_{i}.wav, so old jobs resolve through the same helper. - dub_generate: write/reload per-segment WAVs by stable seg_id (deferred write, RVC write, regen reload) with a legacy seg_{i}.wav fallback; persist a job['seg_order'] manifest (index -> stable id) for index-keyed readers. - dub_export: preview + stems-zip resolve the file via seg_order (-> stable id) with legacy fallback, so they keep finding the right audio. - test: dub_seg_path id-naming, legacy-index equivalence, traversal sanitization. Back-compatible with in-flight jobs (legacy index files still resolve). Closes #185. * fix(dub): harden dub_seg_path with realpath containment; route all seg paths through it (CodeQL path-injection) Both job_id and seg_id are request-derived. Sanitise both and verify the resolved path stays inside DUB_DIR (realpath + startswith) — raises on escape. Route the legacy index fallbacks in dub_generate/dub_export through dub_seg_path so no raw os.path.join(DUB_DIR, job_id, ...) remains and a bare '..' component can't traverse. * fix(dub): assert path containment at export sinks (recognized CodeQL barrier) dub_seg_path already validates realpath containment, but CodeQL doesn't propagate the barrier across the call. Re-assert at the FileResponse / zf.write sinks (realpath + startswith on the value used) so the guard is recognized in-function — clears the py/path-injection false positives. * fix(dub): realpath+containment guard before any path sink in export/preview CodeQL flags os.path.exists/FileResponse/zf.write as path sinks and won't propagate dub_seg_path's internal guard. Resolve each candidate, realpath it, and containment-check (startswith DUB_DIR) BEFORE any filesystem access — the guard now dominates every sink in-function, clearing py/path-injection. --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> fix(dub): ExportModal crash — t-shadowing in .map callbacks (#183) (#184) Opening the Export modal on a dub job threw 'TypeError: e is not a function' inside an Array.map during render, tripping DubTab's ErrorBoundary. Cause: the zh-CN i18n sweep (#157) added t('…') translation calls INSIDE .map(t => …) / .filter(t => …) callbacks where 't' was the loop variable (a track object / a lang-code string), shadowing the useTranslation 't'. Calling the shadowed 't' as a function threw (minified to 'e is not a function'). Rename the loop vars (t -> track / code) at the three <option>/<label> render sites so they no longer shadow the translation fn; strings still go through t(). The harmless non-rendering shadows (useMemo/handlers that never call t()) are left as-is. Regression test renders ExportModal with a dub track equal to dubLangCode (the exact crashing branch) and asserts it doesn't throw. Full suite 154/154, typecheck/build/legacy ✓. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> feat(stories): MP3 export — backend ffmpeg encode + format selector (#181) Completes the pro-output story: an Export-format selector (WAV / MP3) on the Stories toolbar. WAV stays fully client-side; MP3 routes the client-stitched WAV through a new backend POST /stories/encode (ffmpeg via the Windows-reload- safe spawn_subprocess, #175), with a strict format whitelist (mp3/m4b/ogg) and bitrate validation so the uploaded format can't inject ffmpeg args. Both the audiobook and per-character stems honor the selected format; MP3 falls back to WAV with a toast if ffmpeg is unavailable. - backend/api/routers/stories.py + main.py registration; temp-file cleanup. - frontend/src/api/stories.ts encodeAudio() (apiFetch: same-origin + PIN). - tests: format-whitelist 400, ffmpeg-missing 501, real mp3 encode (skips if ffmpeg absent). Backend 26 incl. router smoke; frontend 152/152, typecheck/ build/CJK ✓. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> feat(stories): Phase 4 — pro output (stems + chapters) + named projects (#180) Final phase of the pro-studio Stories Editor. - Named projects: storiesSlice gains storyProjects[] + currentProjectId with save/load/new/delete/rename (transient fields stripped on snapshot); a Tauri-safe Projects panel (name input + Save + New + load/delete list). Persisted to localStorage. - Per-character stems: 'Stems' export renders one WAV per cast voice (exportStems → exportStoryAudio per character group) and downloads each. - Chapter markers: lines starting with '# ' are chapter headings (not spoken); Generate emits the audiobook WAV + a story-chapters.txt cue sheet with HH:MM:SS timecodes. 'Add Chapter' inserts a heading line. - Pure + tested: isChapterLine/chapterTitle/formatTimecode/tracksByCharacter/ buildCueSheet + project reducers. exportStoryAudio now returns {blob, chapters, durationSec}. - i18n (en + zh-CN); 9 new unit tests. Full suite 152/152, typecheck/build/CJK/ legacy ✓. Client-side, no new deps, no DB. (MP3/M4B encode — a backend ffmpeg step — is the one remaining optional add; WAV output is universal.) Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> feat(stories): Phase 3 — per-line studio drawer (tone tags + speed) (#179) Click the tune button on any line to reveal a drawer: - Tone chips insert OmniVoice's native inline emotion/sound tags ([laughter], [sigh], [question-en], [surprise-wa], [confirmation-en], [dissatisfaction-hnn]) at the cursor — the model-native way to direct tone (not the instruct param, which only whitelists gender/age/pitch/style/accent and rejects free emotion). - Per-line speed slider (0.5–2.0x) → threaded into /generate for both preview and the audiobook export; reset-to-default. - insertToken extracted to storyTokens (pure + tested); insertPauseInto + tone chips share it. exportStoryAudio now resolves per-track {profileId, speed}. - i18n (en + zh-CN); 4 new unit tests. Full suite 143/143, typecheck/build/CJK ✓. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> feat(stories): Pro Studio Phase 2 — auto-cast from text + import (.txt/.srt) (#178) * feat(stories): Phase 2 — auto-cast from text + import (.txt/.srt) The no-brain ingestion path. Paste or import a story, click Auto-cast ✨, and the editor detects who's speaking and builds the cast + lines for you. - parseScript(text): screenplay 'NAME: dialogue' + prose quote attribution ('said the fox' / 'the fox asked', straight + curly quotes); narration → Narrator. Pure + tested (speaker normalization, URL guard, fallbacks). - importStory: parseSrt strips indices/timestamps → cue text; importToText routes .srt vs .txt. File import button (accept .txt/.srt) fills the panel. - Auto-cast wiring: distinct speakers → cast members (round-robin voice assign from installed profiles), lines appended; existing cast/work preserved. - i18n (en + zh-CN); 14 new unit tests. Full suite 139/139, typecheck/build/CJK ✓. * fix(stories): use substring check for SRT arrow (avoid CodeQL js/bad-tag-filter false positive) --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> feat(stories): Pro Studio Phase 1 — real audiobook output, cast, persistence, reorder, i18n (#177) * docs(spec): Stories Editor pro-studio design (line cards, auto-cast, pro output, projects) * feat(stories): Phase 1 — real audiobook output, cast, persistence, reorder, i18n First phase of the pro-studio Stories Editor (spec: docs/superpowers/specs/2026-05-30-stories-editor-studio-design.md). Makes the editor actually produce audiobooks and remember your work: - Persistence: storiesSlice (tracks + cast) via zustand persist -> localStorage; transient fields (generating/audioUrl) stripped on persist; id counter reseeds from persisted tracks. Dropped the hardcoded sample seed -> clean empty state. - Cast: editable CastMember[] (name, color, voice) with a Cast panel; each line picks a character and inherits its voice (per-line override still available). - Real Generate: exportStoryAudio() stitches every line + [pause] gaps into one WAV via the Web Audio API (job-less /generate per chunk) with a % progress indicator and download. Per-line preview already shipped (#176). - Reorder: native HTML5 drag-and-drop (pure reorder() helper). - i18n: all Stories strings via t('stories.*') (en + zh-CN). - Tests: storiesSlice reducers, storyCast resolution, storyExport WAV/concat/ silence, storyReorder. 18 new unit tests. No DB/alembic; localStorage only. Same-origin + PIN-safe synth (apiFetch). No new deps. Cross-platform-identical default behavior. --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> fix(stories): preview via job-less /generate (was 404 on /api/dub/preview-segment/__stories__) (#176) The Stories Editor preview POSTed to a relative /api/dub/preview-segment/__stories__ URL: that dub route requires a real dub job ("__stories__" -> 404) and the bare relative path skipped the API base + PIN header entirely. Route through the standalone /generate endpoint via the shared api client (generateSpeech), which is same-origin and PIN-aware. Per-line and marker-chained preview now work. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> fix(win): subprocess spawns work under `bun run dev` (--reload) on Windows (#122) (#175) issue #122 'Extract: Unknown Error' on Windows: 'bun run dev' fails, running backend+frontend separately works. Root cause: dev:api launches uvicorn with --reload, so use_subprocess=True, and uvicorn 0.42's asyncio_loop_factory EXPLICITLY forces the SelectorEventLoop on Windows in that case (passed as loop_factory to asyncio_run, overriding any policy). The SelectorEventLoop has no subprocess support -> asyncio.create_subprocess_exec raises NotImplementedError. 'python backend/main.py' (no reload) uses ProactorEventLoop -> works. So an event-loop-policy fix is futile; the thread fallback is the fix. The ffmpeg extract path already routed through _spawn_async's thread fallback (landed in #157), but several other spawn sites used raw create_subprocess_exec and stayed broken on the dev loop: - add public spawn_subprocess() (drop-in for create_subprocess_exec) that routes through _spawn_with_retry -> _spawn_async (NotImplementedError -> thread fallback + EAGAIN retry); native asyncio path unchanged on supported loops. - fix _spawn_thread_fallback to forward cwd/env/etc. to subprocess.Popen (was silently dropping them -- breaks sonitranslate's cwd= pip install). - convert raw spawns: dub_generate atempo, tools ffprobe, gallery yt-dlp (x2), sonitranslate install (x4). translation_engines already had its own fallback. - tests: NotImplementedError -> thread fallback; cwd forwarding; stdin input (atempo); native path unchanged. No behavior change off the broken loop (macOS/Linux/Windows-prod): the native asyncio subprocess is still used; the fallback only triggers on NotImplementedError. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> fix(docker): runtime API-base override so served deployments reach the backend (#174) Docker users reported Settings -> Engines failing with 'Failed to load engines: Failed to fetch'. Two problems: (1) the two API-base resolvers diverged (client.ts honored VITE_API_URL; apiBase.ts honored VITE_OMNIVOICE_API), and the docs documented VITE_OMNIVOICE_API -- which the Engines request path ignored; (2) VITE_* is inlined at BUILD time, so a prebuilt ghcr.io image has no working runtime override at all for reverse-proxy / split-origin deploys. - backend: when OMNIVOICE_PUBLIC_API_BASE is set, inject it into index.html as window.__OMNIVOICE_API_BASE__ (core/spa_inject.py; validated to a plain http(s) URL so it can't break out of the <script>). Unset (default) -> StaticFiles serves index.html untouched (same-origin, zero overhead). - frontend: both resolvers (client.ts _resolveApiBase + utils/apiBase.ts) now read the runtime global FIRST, then VITE_OMNIVOICE_API/VITE_API_URL, then fall through to same-origin. client.ts also strips trailing slashes and recognises __TAURI_INTERNALS__ (parity with apiBase.ts/external.ts). - docs: docker.md + troubleshooting.md document OMNIVOICE_PUBLIC_API_BASE as the runtime override that works on the prebuilt image (the old VITE_OMNIVOICE_API docker run -e example never worked -- build-time inlining). - tests: spa_inject helpers (inject + URL validation/breakout); resolver precedence for the runtime global + VITE_OMNIVOICE_API in both test files. Default same-origin behavior is unchanged on every platform; override is opt-in. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> fix(model): bound first-run model load/download so it never hangs forever (#173) Windows users reported 'create demo voice runs indefinitely, no audio, no error'. Root cause: the first /generate triggers OmniVoice.from_pretrained() which downloads multi-GB weights via the legacy LFS path (HF_HUB_DISABLE_XET=1), with NO timeout anywhere. A stalled socket (proxy/firewall/AV) blocks the GPU- pool worker forever inside get_model() -- before the try/except that would surface an error -- and the frontend /generate fetch had no abort, so the spinner spun forever with no toast. - backend/main.py: set HF_HUB_ETAG_TIMEOUT=15 + HF_HUB_DOWNLOAD_TIMEOUT=30 (per-read timeout: resets on each chunk, so slow-but-progressing downloads are never punished; only a dead socket trips it). Set before hf import. - model_manager: get_model()/preload_model() now load via _load_model_with_timeout(), an asyncio.wait_for backstop (OMNIVOICE_MODEL_LOAD_TIMEOUT, default 1200s) that drops the poisoned GPU pool and raises a clear, actionable RuntimeError so a retry gets a fresh worker instead of queueing behind the wedged one. - useTTS.js: AbortController backstop on /generate so the UI never spins forever even if the backend is unreachable; friendly timeout toast. - tests: watchdog raises + resets pool + releases lock; env/floor parsing. Cross-platform (no OS-specific behavior); backward-compatible with installed models; local-first preserved. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> chore: gitignore Spec Kit/GSD local tooling; track network-sharing plan (#172) Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> fix(network): remote LAN-share UI actually works (same-origin API + safe clipboard) (#171) * fix(network): remote share UI must use same-origin API, not hardcoded :3900 When a device opens the LAN-share URL, the SPA is served by the share listener on :5050 but client.ts hardcoded the API to :3900 — cross-origin (CORS-blocked) AND loopback-only/unreachable from another machine, so every fetch failed. The share listener serves the same app+API, so the remote SPA must hit its OWN origin. _resolveApiBase: Tauri→127.0.0.1; vite-dev→:3900 (CORS-allowed); else (share listener / docker / prod build)→window.location.origin. +6 unit tests. * fix(ui): safe clipboard copy over plain HTTP (LAN-share remote devices) navigator.clipboard is secure-context-only (https/localhost); on a LAN-shared instance at http://<ip>:<port> it's undefined, so unguarded navigator.clipboard.writeText(...) threw 'Cannot read properties of undefined (reading writeText)' — crashing copy buttons on remote devices. Add a copyText util (clipboard API when available, hidden-textarea + execCommand fallback otherwise) and route all 9 call sites through it. --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> feat(settings): dedicated Appearance tab + global font selection (#170) * feat(settings): global font preference applied app-wide Add a persisted, system-safe global font selection. A new `font` pref in the prefs slice overrides the root `--font-sans` CSS variable (the whole UI uses `font-family: var(--font-sans)`), so the choice applies app-wide; `default` removes the override and falls back to the :root Inter stack. - prefsSlice: FontId type, FONT_OPTIONS/FONT_STACKS tables, font + setFont - store/index: persist `font` via partialize; re-export font tables/type - App.jsx: re-apply persisted font on launch in the rehydrate effect - AppearancePanel: Font row (Select) next to UI-scale and color-theme - AppearancePanel.test: covers render, selection, and default reset All stacks are system fonts (no web-font downloads) so behavior is identical offline across macOS/Windows/Linux. * feat(settings): move Appearance into its own Settings tab Promote the Appearance panel out of the Credentials tab into a dedicated top-level tab (with the Palette icon), placed before Credentials. Add the English "appearance" label so the tab renders via t(`settings.${id}`). Remove the AppearancePanel render (and its stale comment) from CredentialsTab. --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> fix(network): footer Local toggle dead in Tauri (window.confirm no-op) (#169) The footer Local/Network pill called window.confirm() before enabling, but window.confirm is a no-op in the Tauri webview (returns false), so the enable action was silently swallowed — the button appeared to do nothing. Replace it with a reliable in-app confirm popover (Cancel/Enable). The backend endpoint was always working (verified: enable opens a real listener on the share port). Adds a regression test for the Local -> confirm -> Enable -> POST flow. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> fix(diarization): use_auth_token->token shim for pyannote on HF Hub 1.x (#167) (#168) * fix(diarization): shim use_auth_token->token for pyannote on HF Hub 1.x (#167) pyannote-audio 3.x (pipeline.py:102) calls hf_hub_download(use_auth_token=...), which huggingface_hub 1.x removed (only 'token' now) -> 'unexpected keyword argument use_auth_token', breaking speaker diarization. Wrap hf_hub_download/ snapshot_download to translate the dead kwarg, applied before pyannote's 'from huggingface_hub import hf_hub_download' binds it (+ patch already-loaded pyannote modules). Verified the real pyannote reference binds the shim. * test(diarization): use pytest.importorskip (fixes CodeQL uninitialized-local) CodeQL doesn't model pytest.skip() as no-return, so it flagged _pp as a possibly-uninitialized local (py/uninitialized-local-variable, error). Switch to pytest.importorskip — cleaner and CodeQL-clean. --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> fix(scripts): Windows (Git Bash/MSYS) support across install/run/smoke-test (#164) (#166) Follow-up to #165 — the same uname Darwin/Linux-only pattern in the other dev scripts: - smoke-test.sh: add MINGW*/MSYS*/CYGWIN* detection + Windows data paths (%APPDATA%/%LOCALAPPDATA%, per backend/core/config.py) + .exe binary suffix. - run.sh: Windows detection + %LOCALAPPDATA% log dir; backend launch already uses 'uv run' which is cross-platform. - install.sh: detect Windows and print a clear 'use the .msi / WSL' message (the brew/apt system-dep installer can't run on native Windows) instead of silently treating it as Linux. build-omnivoice-tts.sh already handled windows-x86_64; record-reference.sh already guards macOS-only with a clear message. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> fix(scripts): support Windows (Git Bash/MSYS) in desktop-prod.sh (#164) (#165) uname -s returns MINGW64_NT/MSYS_NT/CYGWIN_NT under Git Bash, which hit the catch-all 'Unsupported platform' error. Add a windows case + a Windows data- path branch (%APPDATA%\OmniVoice backend data + %LOCALAPPDATA% Tauri/HF dirs, per backend/core/config.py) + launch the debug .exe. The cross-platform 'tauri build' step is unchanged; every rm stays [ -d ]-guarded so an off path is a no-op, never a wrong delete. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> feat(network): user-configurable backend / LAN-share / UI ports (#163) * feat(ports): make backend/share/UI ports configurable via env vars Single-source the backend port from OMNIVOICE_PORT and derive the LAN-share base from it (OMNIVOICE_SHARE_PORT override). Previously network_share.py hardcoded BACKEND_PORT=3900, so a user running the backend on a custom port got LAN-share/Tailscale pointed at the wrong port. - network_share: replace BACKEND_PORT constant with backend_port() / share_port_base() helpers (env-driven, never-throw fallback to defaults); enable() probes from share_port_base(). - tailscale: serve_enable(port=None) defaults to network_share.backend_port(). - main.py: direct-run + --health-check ports and HEALTH_URL read OMNIVOICE_PORT; CORS default origins use OMNIVOICE_UI_PORT (default 3901). - /system/info + SystemInfoResponse: expose backend_port, share_port_base, ui_port (both success and never-throw except branches). - set-env: persist OMNIVOICE_PORT/SHARE_PORT/UI_PORT; validate numeric and 1024-65535, reject otherwise with 400. * feat(ports): pin child OMNIVOICE_PORT in backend spawn Push OMNIVOICE_PORT=backend_port() onto the spawned Python child's env so network_share.backend_port() always agrees with the uvicorn --port Rust passes. Without this, a user-set OMNIVOICE_PORT would move the LAN-share / Tailscale target while the listener stayed on the Rust-resolved port. * feat(ports): Ports subsection in Sharing settings + env-driven Vite port - vite.config.js: dev-server port reads OMNIVOICE_UI_PORT (default 3901). - SharingPanel: new Ports subsection reads /system/info and displays backend_port / ui_port (with their env-var names + restart-to-apply note) and makes the LAN-share port editable — Save POSTs OMNIVOICE_SHARE_PORT to /system/set-env (persisted), "applies next time you enable sharing". - Tests: extend SharingPanel.test.jsx for the ports subsection. --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> test: scrub brand name from whisper segmentation fixture (#162) The whisper_screenshot transcription fixture + its segmentation test referenced a real product/brand name. Swap it for the neutral placeholder 'Acme' (fixture text + chunks + the expected-segment assertions), keeping the test's consolidation behavior identical. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> fix(tailscale): HTTP serve fallback when tailnet lacks HTTPS certs + parallel dev launch (#161) * fix(tailscale): serve over HTTP when tailnet has no HTTPS certs Real-world failure: 'tailscale serve --https=443' on a tailnet without the HTTPS Certificates feature (CertDomains: None) fails with 'error enabling https feature: 404'. Detect cert availability from status --json and use --https only when certs exist; otherwise serve over --http (the WireGuard tunnel encrypts transport anyway). Also surface a clear note/error instead of the raw 404, and a 'run tailscale up' hint when not running. Verified the --http path live on a real tailnet. SharingPanel now shows the returned note. * chore(dev): launch app in parallel with backend (drop wait:api gate) dev/desktop no longer block the Tauri/vite launch on the API being HTTP-ready — the window appears immediately and the frontend's setup-status check already retries (30x1s) until the API answers. Matches prod, where the window shows BootstrapSplash while the sidecar boots. Dev-only; no shipped change. --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> polish(network): outermost PIN gate + non-buffering ASGI middleware + listener test (#160) * fix(network-share): mount RemoteAuthGate at outermost provider Move the <RemoteAuthGate> wrap from App.jsx's main-studio return up to main-app.jsx, inside QueryClientProvider and wrapping the entire app tree (both the dictation widget and <App />). Previously the gate only wrapped the studio return, so a remote device opening a bare URL (no ?pin=) during first-run states — the /setup/status check, SetupWizard, or BootstrapSplash early returns — would 401 with no gate rendered to collect the PIN. The QR path was fine (PIN captured pre-fetch in client.ts); only bare-URL was broken. Remove the App.jsx wrap to avoid double-gating (two PIN dialogs). No behavior change for loopback or QR users. * perf(network-share): make NetworkAccessMiddleware non-buffering ASGI Rewrite NetworkAccessMiddleware from a starlette BaseHTTPMiddleware into a pure ASGI middleware (class with __init__(app) and __call__(scope, receive, send)). BaseHTTPMiddleware buffers StreamingResponse/SSE bodies before forwarding them, so PIN'd LAN clients on streaming endpoints (dictation SSE, tts streaming, /system/logs/stream) got buffered/laggy responses. Loopback was unaffected (bypasses early), but remote-share streaming was degraded. The ASGI form forwards send untouched on every pass-through path, and only wraps send to inject Set-Cookie on the http.response.start message for the first valid-PIN request — the body keeps streaming chunk-by-chunk. request.app resolves in ASGI scope (Starlette sets scope["app"]), so the inert/loopback/ shell/PIN logic is identical to before. Registered after CORS (unchanged) so CORS stays outermost. All 5 existing behavior tests pass unchanged. Adds three tests: a guard that the middleware is not a BaseHTTPMiddleware subclass, a StreamingResponse pass-through (401 without PIN, full chunked stream with PIN, no buffered Content-Length), and a Set-Cookie-via-ASGI assertion. * test(network-share): integration test for real listener lifecycle Add tests/test_network_share_lifecycle.py exercising the real second uvicorn listener: await network_share.enable(app) on a minimal FastAPI app, assert get_state().enabled is True with a share_port set and a live TCP listener on that port (real socket connect), then await disable(app) and assert the state resets and the port stops accepting connections. Uses the returned share_port (never a hardcoded port) and tolerates teardown timing by polling for socket close. Wrapped in asyncio.run inside a sync test so it does not depend on a pytest-asyncio event-loop mode; skips gracefully if binding 0.0.0.0 is not permitted in the sandbox. Defensive cleanup resets the module-level state on any failure path. --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> feat: network sharing (PIN-gated LAN + QR) & Tailscale remote access (#125) (#159) * docs(spec): network sharing + Tailscale remote access design Same-state LAN sharing via a second in-process uvicorn listener on a dedicated share port (no restart, model/jobs preserved), PIN-gated for non-loopback clients, with QR + all-LAN-addresses panel. Tailscale serve for private remote access. Supersedes the raw 0.0.0.0 default-flip in #125. * docs(spec): control endpoints reuse existing require_loopback gate Security review of #157 confirmed the /system router is already loopback-gated via Depends(require_loopback) (non-spoofable request.client.host). The network control endpoints inherit it and /system/set-env is auto-protected from the LAN listener — no new guard needed. * feat(network): share-listener module — LAN enumeration + PIN + lifecycle * feat(network): loopback-only control endpoints + /system/info sharing fields * test(cjk): scan git-tracked files only, not untracked vendored dirs The no-hardcoded-CJK guard walked the filesystem, so local untracked vendored experiments (research/voice-pro etc. with JP issue templates) caused false local failures while CI (committed files) passed. Scan via git ls-files so local-only and CI behavior match. * feat(network): PIN middleware — gate non-loopback API access when sharing on * feat(network): inject X-OmniVoice-Pin globally + capture ?pin= from QR URL * feat(network): remote PIN gate on 401 * chore(network): add qrcode dep for share QR * feat(network): footer Local/Network toggle with LAN addresses, QR, copy/open * feat(tailscale): CLI status + serve enable/disable + endpoints * feat(network): Settings → Sharing & Remote Access panel (LAN + Tailscale) * docs(network): sharing & remote access guide (LAN PIN/QR + Tailscale) * fix(network): enable() tears down and raises if the share listener never binds Defensive guard (spec §7): if the second uvicorn server doesn't reach 'started' (e.g. the share port was taken in the race after the free-port probe), cancel the task, reset state, and raise — so the API surfaces the failure and the UI stays Local rather than reporting a dead 'Network' state. * test(network): use globalThis (not Node global) in client.test.ts for tsc CI runs 'tsc --noEmit --checkJs false', which type-checks .ts files; Node's 'global' isn't typed there (TS2304). vitest (esbuild) tolerated it locally. Use globalThis (standard, typed) + cast the mock. * fix(network): apiFetch leaves opts untouched when no PIN set The unconditional headers merge changed the request shape for callers with no headers (e.g. FormData posts), breaking the legacy 'apiPost passes FormData without Content-Type override' node test. Only spread opts + inject X-OmniVoice-Pin when a PIN is actually present; otherwise pass opts through unchanged. --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> fix(bootstrap): scrub PYTHONHOME/PYTHONPATH before uv so AppImage venv build succeeds (#144, #127) (#158) On the Linux AppImage, the bundled runtime exports PYTHONHOME / PYTHONPATH (and sometimes LD_LIBRARY_PATH) pointing at the AppImage's *own* bundled Python. When first-run bootstrap shells out to `uv` to create/sync the venv, that build subprocess inherits those vars, so the freshly-built managed interpreter resolves its stdlib against the wrong (AppImage) Python and dies with `ModuleNotFoundError: No module named 'encodings'` while compiling a transitive dep (dora-search/demucs). This surfaces downstream as "Backend process exited (never started) — no error output captured" (#144). The backend spawn in backend.rs already scrubs these vars before launching uvicorn; the uv/venv/pip subprocesses in bootstrap.rs were scrubbing them too but via five inline copies, which is drift-prone. Factor the scrub into a single `scrub_python_env(cmd)` helper documenting the #144 root cause, and apply it at every uv/venv/pip call site (uvicorn import check, repair sync, venv create, sync, ROCm reinstall). Add a unit test asserting the helper queues removals for all three vars. Safe cross-platform: those vars are normally unset on macOS/Windows and `env_remove` on an unset var is a no-op, so default behavior is unchanged everywhere. Compile-validated (cargo check + cargo test pass); no AppImage in CI to reproduce the original failure end-to-end. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> l10n(zh-CN): full Chinese localization + Windows/settings fixes (absorbed from #66) (#157) * @ fix: skip torch.compile on Windows where Triton is unavailable torch.compile with mode="reduce-overhead" depends on Triton, which has no official Windows support. On Windows the compile call succeeds but generates code paths that crash at inference time with an OOM-like error ("Cannot find a working triton installation"). Check for Triton availability before compiling so TTS gracefully falls back to eager mode on platforms without Triton. Closes #65 SummerSec @ * feat: comprehensive Chinese (zh-CN) localization for Settings and navigation Add full Chinese (zh-CN) translation support across the frontend: - NavRail, Launchpad, Clone/Design tabs, Settings (all tabs) - Sidebar navigation labels, hero text, action cards, section headings - Fix: Settings missing General tab in TABS array - Fix: i18n locale not persisted after page reload (useEffect deps) - Fix: NavRail key prop spreading into JSX elements Co-authored-by: SummerSec * fix: translate production override parameter labels (Speed, t_shift, etc.) * fix: translate voice design category labels (Gender, Age, Pitch, etc.) * feat: translate Transcriptions and Voice Gallery pages * fix: translate gallery category names (Disney, Anime, etc.) * feat: translate DubTab, personality presets, and voice design presets * fix: remove duplicated emoji in personality name translations * fix: correct preset translation keys to match actual preset IDs * fix: filter natural language from personality instruct to prevent validation error * fix: handle edge case where instruct has no valid tags * chore: remove debug logging from personality instruct filter * fix: address CodeRabbit review — importlib.util, English comment, grammar, theme, placeholder * fix: localize selected category label in VoiceGallery header * feat: translate remaining DubTab UI text (CAST, Generate Dub, Translate All, etc.) * fix: improve ffmpeg detection on Windows, error messages, and yt-dlp download timeout * feat: add proxy setting in Settings → General for downloading via proxy * fix: improve ffmpeg detection on Windows, error messages, and yt-dlp download timeout * feat: allow HTTP_PROXY/HTTPS_PROXY env vars via /system/set-env * fix: support SOCKS5 proxy, also set ALL_PROXY env var * fix: increase yt-dlp extractor retries for subtitle 429 errors * feat: translate prep overlay stage labels (download, extract, demucs, scene) * feat: translate BatchQueue, VoiceProfile, ToolsPage, Projects pages * feat: translate SetupWizard, DonatePage, EnterprisePage + fix NotImplementedError handling * feat: add ffmpeg status + manual path setting in Settings → General * fix: validate ffmpeg path exists when user sets it manually * fix: fall back to thread-based subprocess when asyncio raises NotImplementedError on Windows * fix: pin setuptools<70 — ctranslate2 requires pkg_resources removed in 70+ * fix: translate transcribing overlay text * fix: complete DubTab zh-CN localization + argostranslate preflight check * fix: add cmn-Hans language code mapping for Google Translate * fix: fall back to thread-based pip install on Windows when asyncio subprocess raises NotImplementedError * feat: add DeepL/Microsoft/LLM credential fields to Settings * feat(i18n): localize GlossaryPanel, DubSegmentTable, DubSegmentRow * fix: Windows-safe log rotation handler avoids PermissionError on rename * feat: persist proxy/FFmpeg/LLM/translation credentials, separate DeepL/Microsoft keys, add glossary max-height scroll and collapse - /system/set-env writes env.* to prefs.json via prefs.set_()/delete() - Backend startup reads env.* from prefs.json into os.environ (.setdefault) - PERSISTENT_KEYS covers proxy, FFmpeg, LLM, DeepL/Microsoft keys - DeepL uses DEEPL_API_KEY, Microsoft uses MICROSOFT_API_KEY (fallback TRANSLATE_API_KEY) - DeepL/Microsoft _build_translator reads DEEPL_BASE_URL/MICROSOFT_BASE_URL - Google/MyMemory/Microsoft translators bypass Windows registry proxy - Frontend CREDENTIAL_GROUPS splits into 4 groups with password/text fields - Glossary panel body max-height: 35vh + overflow-y: auto - Glossary panel can be collapsed via ChevronDown button - queryClient.invalidateQueries after save for immediate refresh - SystemInfoResponse adds proxy_url, ffmpeg_ok, ffmpeg_path * feat(i18n): localize ExportModal with zh-CN support - Add useTranslation + replace ~50 hardcoded strings with t() calls - Add exportModal namespace to en.json and zh-CN.json - Cover presets, tracks, tabs, video/audio/subs/package tabs, license notice, and output footer * fix: address PR #66 security review feedback - Regenerate uv.lock against pypi.org (remove TUNA mirror URLs) - Route HF_TOKEN through huggingface_hub.login() instead of prefs.json - Add os.chmod(prefs_path, 0o600) for restricted file permissions - Add warning logs to _WindowsSafeRotatingFileHandler bare except blocks * docs: add Chinese translation README_CN.md * docs: add link to Simplified Chinese translation in README.md * docs: add English/Simplified Chinese cross-links between READMEs * fix(l10n): restore clickable Discord/email footer links in EnterprisePage The i18n extraction replaced main's clickable <button onClick=openExternal> footer links with bare {t()} labels, dropping both the clickable behavior and the literal Discord URL — which broke test_discord_link_updated (EnterprisePage missing discord.gg/bzQavDfVV9). Restore both as clickable links wrapping the translated label, with the hardcoded URL/mailto (URLs are not translated). Keeps i18n, restores functional parity with main. * chore(l10n): no-hardcoded-CJK rule + enforce; clean dead LLM block; harden set-env - Add 'Localization (hard rule)' to CLAUDE.md: no hardcoded non-English UI text outside frontend/src/i18n/; functional CJK allowed via allowlist. - New tests/test_no_hardcoded_cjk.py enforces it (allowlists text-processing regexes, model/engine vocab & IDs, error matching, demo/eval data, fixtures). - Settings.jsx: remove dead saveLlm block (Chinese toasts + unused llm* state, flagged by CodeQL js/unused-local-variable); render language-picker native names from new LANGUAGES export in i18n/index.ts instead of hardcoding. - main.py: drop unused 'import shutil' (CodeQL py/unused-import). - system.py: harden FFMPEG_PATH/FFPROBE_PATH set-env (reject control chars; defense-in-depth for the py/path-injection finding). Endpoint stays loopback-only — network sharing must never expose /system/set-env. * chore: drop unused ui import (Panel) + fix implicit str-concat in cjk test Clears the two CodeQL notes introduced/attributed to this PR: - Settings.jsx: remove unused 'Panel' from the '../ui' import (js/unused-local-variable). - test_no_hardcoded_cjk.py: collapse multi-line message strings to single lines (py/implicit-string-concatenation-in-list). --------- Co-authored-by: SummerSec <summersec@qq.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> fix(windows): port-conflict kill + Triton/torch.compile disable (salvaged from #85) (#156) * fix(windows): port-conflict kill + Triton/torch.compile disable (salvaged from #85) Salvages the two safe, valuable Windows fixes from community PR #85 without the changes that would regress all users. backend.rs — implement the Windows branch of `kill_orphan_on_port`, which was a no-op (`pub fn kill_orphan_on_port(_port: u16) {}`). It now parses `netstat -ano -p TCP` for the LISTENING socket on exactly `port` (suffix-matched on ":PORT" to avoid e.g. :3900 matching 39000) and kills the owning PID via `taskkill /PID <pid> /F`. Behind `#[cfg(not(unix))]`; the unix branch is untouched. Signature now matches the unix branch. main.py — on Windows (sys.platform == "win32"), default TORCH_COMPILE_DISABLE / TORCHDYNAMO_DISABLE / TORCHINDUCTOR_DISABLE to "1" before torch is imported. Triton has no Windows wheel, so these prevent TritonMissing/dynamo errors. Uses os.environ.setdefault (never overrides an explicit user value) and is win32-guarded, so cross-platform default behavior is unchanged. Intentionally NOT salvaged from #85: - The unconditional `os.environ["HF_HUB_OFFLINE"] = "1"` in main.py and the `local_files_only=True` / HF_HUB_OFFLINE save-restore in model_manager.py. This breaks first-run model downloads for every user (downloading models on first use is the core value prop). Offline mode must stay opt-in — only when the user sets HF_HUB_OFFLINE themselves. - The model_manager.py torch.compile/_get_gpu_pool changes: already present on main in a superior form (`should_torch_compile()` gating from plan-02/#65 and the existing `_get_gpu_pool()` lazy pool), so applying #85's cruder `TORCH_COMPILE_DISABLE` env check would regress. - The 512-line README rewrite, the ~50 frontend .jsx formatting-only diffs, and the personalities.py attrs additions (out of scope). Refs #85. Co-Authored-By: caaaaaleb <caaaaaleb@users.noreply.github.com> * fix: move win32 torch-disable block below sys.path preamble test_main_py_bootstrap_adds_backend_dir asserts the first 15 lines of main.py contain sys.path.insert + _backend_dir. The win32 block was placed above the preamble, pushing them out of range. The torch env vars only need to precede torch's (lazy) import, so moving the block below the sys.path bootstrap keeps behavior identical and restores the test. --------- Co-authored-by: caaaaaleb <caaaaaleb@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> docs(install): clarify macOS Gatekeeper "damaged" workaround (#134) (#155) Reword the macOS install doc's Gatekeeper section so it's findable by the exact symptom ("App is 'damaged' / can't be opened"), and spell out both the GUI path (right-click -> Open, or System Settings -> Privacy & Security -> Open Anyway) and the Terminal path (xattr -dr com.apple.quarantine ...). Explains WHY macOS shows "damaged" (the build isn't notarised yet, so it gets quarantined) and why the workaround is safe (downloaded from the official repo/Releases). Proper fix remains Apple Developer signing + notarisation, already wired in release.yml behind the documented secrets. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> feat(bootstrap): opt-in AMD ROCm torch install (#124) (#154) Detection already routes ROCm through torch.cuda (get_best_device + _configure_rocm_if_needed), but the default install ships the CUDA torch build, so AMD-only machines fall back to CPU. Add an opt-in post-sync step: when OMNIVOICE_TORCH_VARIANT=rocm is set, the bootstrap reinstalls torch/torchaudio from the ROCm wheel index (default https://download.pytorch.org/whl/rocm6.2, overridable via OMNIVOICE_TORCH_INDEX). Strictly gated — default (unset) leaves the CUDA/CPU path untouched — and non-fatal: a failed ROCm reinstall keeps the working default build and points the user at docs/install/linux.md. rocm_opt_in() + rocm_torch_reinstall_args() are pure and unit-tested (gating + index override + arg shape). cargo test green. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> fix(onboarding): hide DictationDemo when sample assets are absent (#119 follow-up) (#153) DubbingDemo and DemoPresetGrid already degrade gracefully (hide) when their assets / is_demo profiles are missing, but DictationDemo always rendered its three hardcoded cards — which fail on click without the bundled sample WAVs (rendered by scripts/build_demos.sh; absent in a plain source checkout). Add a mount-time HEAD probe of the first sample; if it's not present, hide the whole demo (mirrors DubbingDemo's missing-manifest behavior). When assets are present, behavior is unchanged. Test: HEAD 404 → demo renders nothing. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> fix(dub): async-ify _pitch_preserving_stretch (#133 Greptile P1) (#152) _pitch_preserving_stretch ran a blocking subprocess.run() inside the `_stream` async generator (on the event loop). Each ffmpeg atempo call is ~50-100 ms, so on a multi-segment time_stretch dub job it froze health checks, status SSE, and every other concurrent request for seconds. Convert to asyncio.create_subprocess_exec + await communicate() (same pattern as run_proc_streaming_stderr); await the call site in _stream. Drop the now-unused `import subprocess`. Tests: async coroutine + target-length + no-op cases (real ffmpeg). Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> docs(#124): document AMD GPU (ROCm) install path (#151) Detection already works (get_best_device + HSA_OVERRIDE_GFX_VERSION); the gap was that the default install ships CUDA torch, so AMD users fell back to CPU with no guidance. Document the opt-in ROCm wheel swap (rocm6.2), the device-verify one-liner, and the HSA override for unsupported GFX. Linux-only, opt-in — default cross-platform behavior unchanged. An installer-integrated env-var-driven wheel selection is a tracked follow-up. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> feat(dub): audio-only dubbing mode (#119) (#150) * feat(dub): audio-only dubbing mode (#119) Add an audio→audio dubbing path: upload an audio file, get dubbed audio out, with no video processing. The transcribe → translate → TTS core is unchanged; only the video-coupled stages are skipped. Backend: - dub_core /dub/upload: new `input_type` form field ("video"|"audio"). Audio mode validates the upload is a known audio container (else 400) and threads input_type into the ingest source dict. - dub_pipeline ingest: for audio input, skip scene detection + thumbnail ffmpeg passes (still emits scene_done count=0 so the prep SSE contract the frontend waits on is unchanged); stores input_type on the job. - dub_export /dub/download: for audio jobs, branch to an audio-only export (_build_audio_export_cmd) — no video input/map/codec/subtitle pass. Outputs dubbed_audio_{lang}_{stamp}.{wav|m4a|mp3|flac} via `out_format` (default m4a), optionally mixed with the separated background. Unknown formats fall back to AAC. Frontend: - dubSlice: dubInputType state + setter (default 'video'). - DubTab: auto-select audio-only mode when an audio file is dropped/picked. - dub.ts/useDubWorkflow: pass input_type on upload. Tests (11): _build_audio_export_cmd format/mix matrix; end-to-end audio-only export produces an audio file (no video mux); unknown-format fallback; upload rejects a video extension in audio mode. Closes #119. * harden(#119): allowlist-sanitize lang_code in audio export path The track id is already constrained to an existing track key, but allowlist-sanitize it before it reaches the output path (same pattern as the existing safe_name) so a path component can never carry separators — clears the CodeQL path-injection flag on the new audio-export branch. * polish(#119): address Greptile P2s on audio-only dubbing - dub_pipeline: emit scene_start before scene_done(count=0) for audio so the prep SSE stage sequence is symmetric with the video path. - useDubWorkflow: 'Preparing audio…' pill for audio jobs (was always 'Preparing video…'). - DubTab: widen the drop-accept regex + file-input accept to the full supported audio set (aac/opus/wma) so it matches the input-type detection and the backend allowlist. * fix(#119): drop unused dubInputType read in DubTab (CodeQL) Only setDubInputType is used; the value read was dead. Clears the CodeQL unused-variable alert. --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> feat: onboarding demos, opt-in bug reporting, error-docs deeplinks + issue triage (#133) * feat: onboarding demos, opt-in bug reporting, error-docs deeplinks + issue triage Working-tree snapshot bundling several in-flight workstreams (v0.3.0): - Onboarding/demo system: DemoPresetGrid, DictationDemo, DubbingDemo components + tests, render scripts (render_demos_omnivoice.py, build_demos.sh, build_dub_demo.sh), personalities preview URLs, alembic 0002 voice-profile demo fields. - Opt-in bug reporting: ReportBugButton (prefilled GitHub-issue URL path). - Error transparency UX: errorDocsMap deeplinks + BootstrapSplash/error wiring. - Dub workspace: DubSegmentRow/Table, WaveformTimeline, dubSlice tweaks. - Issue triage: .planning/issue-clusters/ (plan-01..05 root-cause masters, GH #128-#132). - CLAUDE.md: hard rule — everything ships on v0.3.0, no version bumps. KNOWN GAP (why this is a draft): the generated demo audio assets are NOT in this tree, and backend/assets/samples/demo_voice.wav is deleted. onboarding.py guards the missing file (skips seeding the demo profile with a warning), so no crash — but first-run Launchpad will be empty and /demo_audio/ preview URLs 404 until assets are regenerated via scripts/build_demos.sh. Do not merge before regenerating + committing the demo assets. * feat(dub): timing strategies — kill audio compression, add Concise + Stretch Video Replaces the current audio time-compression default (atempo squeeze to fit slot) that produced chipmunk/alien output on high-density target languages like Bengali. Two new user-selectable modes; legacy behaviour kept behind an explicit "Strict slot" choice. New `DubRequest.timing_strategy` enum (default "concise"): - "concise" Translator trims text to fit at natural rate; if it still overflows, hard-trim at slot with a fade so we never overlap the next speaker. Surface overflow_s per segment so the user can shorten the text. - "stretch_video" Audio plays at natural 1.0× rate. Backend computes a per-segment new timeline; persists a video_stretch_plan on the job. Mux step (dub_export) builds an ffmpeg trim+setpts+concat filter graph that stretches each segment's video portion to match the natural-rate dub audio. Gaps/pre-roll/tail pass through at 1.0×. Sub burn under stretch_video is skipped in one pass (cues would drift). - "strict_slot" Legacy atempo squeeze. Retained for back-compat. Director rate-bias side-effect (seg_speed *= bias) now gated on strict_slot only, so "urgent"/"slow" direction tokens keep their instruct effect in the new modes without chipmunking. Per-segment fit_status emitted in the SSE done event: {status: "fits" | "overflows" | "video_stretched", overflow_s?, stretch_ratio?} DubSegmentRow's "Sync: 100%" badge (which was lying — sync_ratio was always ~1.0 because the TTS loop pre-trimmed to slot) is replaced with a truthful "Fits / Overflows +Ns / Video 1.18×" label. Frontend: - prefsSlice.timingStrategy (persisted, store v3→v4 with safe migrate). - DubTab footer Segmented control: "Concise · Stretch Video · Strict slot". - useDubWorkflow passes timing_strategy on /dub/generate; consumes fit_status. Tests: tests/test_dub_timing_strategy.py — 13 cases covering schema defaults/validation, _build_video_stretch_filter_graph (pre-roll, gap, tail, empty-plan early return, post-subtitle chain-in), and _video_stretch_plan_for guards. 30/30 existing dub tests still pass. * fix(waveform): surface missing source as "Source media missing" instead of code-4 black box When a project's underlying media file is gone (moved or deleted between save and reload) the <video> element fires MediaError code 4 and the companion audio fetch returns HTTP 404 — both were silently warned to the console while the user stared at an unresponsive black panel and an empty waveform. - WaveformTimeline now flips loadError when the video element rejects code 3 (decode) or 4 (src not supported), and tracks `sourceMissing` separately so the error UI can name the actual problem. - The audio decode fallback chain catches HTTP 404 specifically and treats it as source-missing instead of loading silent empty peaks — an empty waveform on a deleted source is more confusing than a clear "Re-upload the video to continue" message. * fix(tray): "Show OmniVoice" reloads when the webview is blank When the dev Vite server restarts (or the main window is created before the backend is ready), the webview load fails and the window is left with `<body></body>` plus a "Could not connect to the server" console error. Clicking "Show OmniVoice" from the tray menu just re-showed the broken window — there was no recovery path short of quit+relaunch. Now the show handler runs a tiny eval after `show()`/`set_focus()` that calls `location.reload()` only when `document.body.childElementCount === 0`. A healthy window doesn't blink (body is non-empty); a blank one self-recovers as soon as the user clicks Show. * fix(#133): bug-report diagnostics field mapping + drop unused imports Address PR #133 review: - ReportBugButton: /system/info exposes `platform` + `device`, not `os`/`torch_device`/`gpu` — those reads silently dropped OS/GPU from every bug report. Map to the real fields (CodeRabbit). Also remove the dead `home` local in stripHome (CodeQL unused-variable). - DictationDemo: drop unused `Loader` import (CodeQL unused-import). --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> feat(settings): configurable models directory (#64) (#149) * feat(settings): configurable models directory (#64) Let users pick where model weights download (the HuggingFace / Torch cache) instead of being pinned to ~/.cache/huggingface — useful when the system drive is small or slow. Backend: - core/user_env.py: durable per-user env file (~/.config/omnivoice/env) helper with upsert/unset that preserves other keys and writes 0600. main.py already loads this at startup before importing torch/HF, so the value takes effect on the next launch. Path resolves at call time via an OMNIVOICE_ENV_FILE override so it's robust to module re-import in tests. - settings.py: GET/PUT /api/settings/storage/models-dir — validates the dir is writable (mkdir + write-probe → 400 if not), persists the choice, and writes OMNIVOICE_CACHE_DIR to the durable env. Empty path clears → reverts to default. Returns restart_required since an in-use cache can't be safely moved mid-process. Loopback-gated like the other settings. Frontend: - StoragePanel: Models tab panel to view/set/reset the directory, shows effective vs configured vs default + a restart note. Cross-platform default parity preserved (default cache path is the HF default on every OS); local-first (no network); backward-compatible (absent setting → existing behavior). No version bump. * fix(#64): harden models-dir input + clear CodeQL hygiene flags - settings.py: reject control/NUL chars in the path with a 400 before any filesystem call (an embedded NUL otherwise raised ValueError → 500). Also serves as the explicit input-validation barrier for the user-chosen path (loopback-gated same-user local file picker — no cross-privilege boundary). - test_user_env.py: use `with open(...)` so the file is closed and the assert has no side effects. - user_env.py: comment the best-effort chmod except clause. * refactor(#64): single source of truth for models dir + review fixes Address CodeRabbit + Greptile review on PR #149: - P1 (both bots): the settings_store copy of the models dir was only ever read by this GET endpoint, so it was a redundant cache that could diverge from the durable env file (the value main.py actually reads). Drop it — the per-user env file (OMNIVOICE_CACHE_DIR) is now the single source of truth: PUT writes it, GET reads it back. No divergence possible. - XDG-aware default (CodeRabbit): _default_models_dir now honors XDG_CACHE_HOME, matching huggingface_hub's real default on Linux. - Atomic 0600 write (Greptile, security): user_env writes via an os.open opener that creates the file 0600 from the start — no world-readable window before chmod for a file that can hold HF_TOKEN. - _read_lines only swallows FileNotFoundError; other OSErrors propagate so an upsert can't silently drop existing keys on a transient read failure. - Guard makedirs("") when the env path is a bare filename (no parent). - Best-effort write-probe cleanup in a finally; raise ... from e. - a11y: label the models-dir input via aria-labelledby/aria-describedby. - OS-neutral unwritable-dir test (mock makedirs) instead of Unix-only /dev/null path semantics. 12 tests green. --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> fix(bootstrap): surface why the backend "never started" (refs #144, #127) (#148) * fix(bootstrap): surface why the backend "never started" (#144, #127) AppImage users hit "Backend process exited (never started) — no error output captured" with nothing to act on. Root gap: when `Command::spawn()` of the venv Python fails (the common Linux/AppImage case — interpreter can't exec, missing system lib, stale venv), spawn_backend logged the OS error but returned None silently, so the bootstrap reported "no error output captured". Now the spawn failure writes a diagnostic (the interpreter path, whether it exists on disk, the OS error, and an actionable "Clean & Retry / run from a terminal" hint) to backend_err.log, which the bootstrap's read_error_log_tail already surfaces. The "no output" dead-end becomes the real launch error. This makes #144/#127 diagnosable (the underlying AppImage cause then routes from the now-visible error). Pure message builder is unit-tested; cargo test + cargo check clean. Refs #144, #127. * fix(bootstrap): platform-specific spawn-failure hint (Greptile #148) The diagnostic tail said "run the AppImage from a terminal… dynamic-loader error" — meaningless on macOS/Windows (spawn can fail on any OS). Pick the hint by build-target OS via cfg!: AppImage/loader wording on Linux, venv/quarantine on macOS, missing-Python/AV-block on Windows. "Clean & Retry" stays universal. --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> ci: gate omnivoice-tts build to pin changes; drop hanging Intel-Mac leg (#147) * ci: gate omnivoice-tts build to pin changes; drop hanging Intel-Mac leg The omnivoice-tts C++ runtime is pinned to a commit SHA in quant_map.json, so it only needs rebuilding when that pin (or the build script) changes — not on every PR/push. Running it per-push left the heavily-contended hosted macOS runners (esp. Intel macos-13) sitting in "Waiting for a runner…" for hours as a perpetual queued check (the UNSTABLE state on every PR). - Moved the build out of ci.yml into its own workflow, .github/workflows/build-omnivoice-tts.yml, gated to: paths [quant_map.json, scripts/build-omnivoice-tts.sh, the workflow] + workflow_dispatch. Normal PRs no longer trigger (or hang on) it. - Dropped the Intel darwin-x86_64 (macos-13) matrix leg: that hosted pool is unusably contended and Apple's momentum is on arm64; Intel-Mac users get the in-process OmniVoiceBackend fallback (already the documented behavior). Kept linux-x86_64, windows-x86_64, darwin-arm64. Re-add macos-13 here if first-class Intel binaries are ever needed. Both workflows YAML-validated. Matches ci.yml's stated philosophy of keeping heavy platform builds off the per-PR path. * ci: timeout-minutes + injection-harden the omnivoice-tts build (bot review) - Greptile: add `timeout-minutes: 45` so a hung leg (esp. experimental darwin-arm64 Metal) can't run to GitHub's 6h ceiling — same resource-drain class this PR addresses. - CodeRabbit: stop interpolating the pinned SHA / platform directly into the run block. Validate the SHA is a git hash in the pin step, then pass it + platform via quoted env vars (no shell-injection surface from quant_map.json). Declined: SHA-pinning actions@v4 — matches the repo's floating-tag convention (ci.yml/release.yml); belongs in a repo-wide hardening pass + Dependabot. --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> chore: set version to 0.3.0 across all sources (+ drop v0.4 references) (#145) * chore: drop stray v0.4 references — everything ships on the v0.3.0 line Per the project's versioning rule (no v0.4, no unprompted version chatter): - backend/main.py + marketplace.py: the app reported version "0.4.0" (ahead of even pyproject's 0.2.7 and referencing a forbidden version). Aligned to "0.2.7" to match pyproject.toml / tauri.conf.json — a consistency fix, not a bump. - errorDocsMap.ts / indextts/bootstrap.py / _secret_key.py: reworded "v0.4" deferral comments to version-agnostic "deferred / later hardening pass". - docs/install/troubleshooting.md: the "tracked for v0.4" notarization line now matches macos.md (signing is wired; activates on the Apple cert secrets). Note: historical planning records under .planning/ still contain "defer to v0.4" notes; left as-is (a record of superseded decisions) — CLAUDE.md + the constitution are the live source of truth. * chore: set version to 0.3.0 across all sources (current dev line) The current/upcoming version is v0.3.0 (0.2.7 is the prior stable). Bump every version source so the codebase consistently reports 0.3.0 — the in-code dev version; the git *tag* still happens later per the release cadence. - pyproject.toml, frontend/src-tauri/Cargo.toml, tauri.conf.json, frontend/package.json: 0.2.7 → 0.3.0 - backend/main.py (FastAPI) + marketplace.py export metadata → 0.3.0 (these had drifted to a phantom "0.4.0") - CHANGELOG.md: "[0.2.7] — Unreleased" → "[0.3.0] — Unreleased" - uv.lock + Cargo.lock reconciled (1-line each) so `--frozen` installs hold. * refactor(version): read app version from package metadata (no more drift) Greptile (#145): the FastAPI version + marketplace bundle metadata were bare string literals — they'd go stale-wrong again at the next bump (the exact class of bug this PR fixes; that's how "0.4.0" happened). Read once from importlib.metadata.version("omnivoice") via core.version.APP_VERSION, with a "0.3.0" fallback only for a non-installed source checkout. pyproject.toml is now the single source of truth for the runtime version. Tests: tests/test_app_version.py (semver + equals installed metadata). 2 pass. --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> feat(macos): wire Developer-ID signing + notarization; fix "app is damaged" docs (#134, #72) (#143) The unsigned DMG triggers macOS Gatekeeper's misleading "app is damaged" block (#134, #72). Two parts: - release.yml: pass APPLE_CERTIFICATE / _PASSWORD / APPLE_SIGNING_IDENTITY / APPLE_ID / APPLE_PASSWORD / APPLE_TEAM_ID to tauri-action. It signs + notarizes the macOS bundle when these repo secrets are set, and is a no-op (today's unsigned build) when they're absent — so this is safe to merge now and "activates" the moment the maintainer adds an Apple Developer cert. - docs/install/macos.md: explain the "damaged" message is Gatekeeper (not corruption), give the `xattr -cr` + right-click→Open workarounds, and add a "For maintainers" table of the required secrets. Removed the stale "tracked for v0.4" line (versioning rule: everything's on v0.3.0). The in-app error→docs deeplink (GATEKEEPER_QUARANTINE) already targets the #gatekeeper-quarantine anchor. Refs #134, #72. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> fix(bootstrap): always try only-system fallback; drop the too-strict gate (#142) Verification of #140 (driving real uv) found system_python_ge_311() was stricter than uv's own interpreter discovery: it probed only `python3`/`python`, so on a machine where `python3` is the macOS 3.9 but a Homebrew 3.14 exists, the gate returned false and the only-system fallback was skipped — even though `UV_PYTHON_PREFERENCE=only-system uv venv` resolves 3.14 fine. Fix: drop the pre-gate (and the now-unused parse_py_version/system_python_ge_311 helpers + the parse test) and always add the system-python attempt as the last resort. uv's discovery is the authority; with `requires-python = ">=3.11"` it resolves any compatible system interpreter or fails fast → remediation. Verified live: `only-system uv venv` created a venv from system CPython 3.14.5 on this host (no 3.11.x present). cargo test + cargo check clean. Refs #130. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> fix(voice-design): validator-safe instruct builder (plan-05, closes #114 #115) (#141) * fix(voice-design): build validator-safe instruct on the frontend (#132) plan-05 (option A — frontend guard). The engine validator is whitelist-strict by design; the #114/#115 failures came from useTTS.js merging the free-text instruct field with the category dropdowns, producing unsupported items (#115) or two items in one category (#114). - voiceInstruct.js buildDesignInstruct(vdStates, freeText): dropdowns win their category; free-text accepted only as a known tag in an open category; unknown/duplicate items are dropped and returned so the UI can warn. Derives TAG_TO_CATEGORY from CATEGORIES (single source of truth). - useTTS.js design mode uses it instead of the raw merge; toasts dropped items. Engine validator (_resolve_instruct) untouched — whitelist contract preserved, no vendored-engine change. Tests (TDD, vitest): voiceInstruct.test.js (6). Full frontend suite 72 passed; typecheck + build green. Closes #114, #115. Addresses #132. * fix(voice-design): split unsupported vs duplicate instruct; warn on dropdown drift (Greptile #141) - buildDesignInstruct now returns { instruct, unsupported, duplicates }: `unsupported` = free-text prose (not a known tag, #115); `duplicates` = a valid tag whose category was already set (e.g. dropdown low pitch outranks a typed high pitch, #114). useTTS shows an accurate toast per bucket instead of calling a valid-but-outranked tag "unsupported". - console.warn when a *dropdown* value isn't in CATEGORIES (option-list ↔ whitelist drift) instead of silently dropping it. Tests updated + 1 added (7/7); typecheck + build green. --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> fix(bootstrap): mirror cascade + system-Python fallback for blocked networks (plan-03, closes #60) (#140) * fix(bootstrap): mirror cascade + system-Python fallback for blocked networks (#130) plan-03. First-run bootstrap downloaded managed Python from GitHub with no mirror and a short retry budget, so a GitHub-blocked/unresolvable network killed the install dead-on-arrival (#60). bootstrap.rs (Rust/Tauri): - apply_uv_http_env(): UV_HTTP_TIMEOUT=120 / CONNECT_TIMEOUT=30 / RETRIES=5 on both `uv venv` and `uv sync`. - `uv venv` cascade: default GitHub → gh-proxy mirror (UV_PYTHON_INSTALL_MIRROR) → system Python (UV_PYTHON_PREFERENCE=only-system, only if a system Python >=3.11 is detected). First success wins. - Actionable failure messages (install python.org Python / set a mirror / Clean & Retry) instead of a raw uv exit code. Frontend: BootstrapSplash hint for the GitHub-blocked / can't-download-Python case. Docs: troubleshooting.md restricted-network section (mirror env vars, China PyPI index, honest VPN note) — referenced by the remediation text. Tests: Rust #[cfg(test)] for parse_py_version + apply_uv_http_env (cargo test: 2 passed, crate compiles); docs-drift validator + frontend build green. NOTE: the restricted-network E2E paths (mirror install, only-system fallback) need MANUAL verification on a real GitHub-blocked network — not reproducible in the dev/CI harness. cargo + the unit tests cover compile + the pure helpers only. Closes #60. Addresses #130, #57, #127. * fix(bootstrap): drop --python 3.11 pin on system-Python fallback (Greptile #140) system_python_ge_311() accepts 3.12/3.13, but the fallback passed `--python 3.11`, forcing uv to find a 3.11.x interpreter exactly — so a machine with only 3.12/3.13 failed the fallback and wrongly hit the remediation. Drop the pin; `only-system` + the project's `requires-python = ">=3.11"` lets uv resolve any compatible system interpreter. cargo test: 2 passed. --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> fix(windows): gate torch.compile on Triton + ASR critical-path smoke (plan-02, closes #65) (#138) * fix(windows): gate torch.compile on Triton availability (#129, closes #65) plan-02. torch.compile(mode="reduce-overhead") needs Triton at runtime; Triton has no Windows wheel, so the old `device=="cuda"`-only guard in model_manager.py failed on Windows+CUDA and surfaced as a confusing "OOM" (#65). Inference-time, hard to diagnose. - engine_env.should_torch_compile(device): requires CUDA + find_spec("triton") + the existing perf.torch_compile_disabled setting being off; logs the skip reason at INFO and falls back to eager. - model_manager.py call site uses it instead of the bare cuda check. - smoke-test.sh INST-02: import torch + ctranslate2 + whisperx (full ASR path) so a missing transitive dep fails the build instead of crashing mid- transcription (#116). Runs in the CI smoke-matrix on Win/macOS/Linux. setuptools>=75.0 (fix-sequence step 1) already pinned (#58). Linux/CUDA+Triton behaviour unchanged. Tests (TDD): tests/test_torch_compile_gate.py (4). Closes #65; addresses #129/#116. * fix(windows): also gate subprocess torch.compile on Triton (Greptile #138) Greptile flagged that the in-process gate left a parallel gap: engine subprocesses honour TORCH_COMPILE_DISABLE, but build_engine_env() only set it on the user's Performance toggle — so a Triton-absent host (Windows, or macOS) still exposed subprocess engines to the same crash this PR fixes in-process. - build_engine_env(): set TORCH_COMPILE_DISABLE=1 when the user disabled compile OR Triton is unavailable (find_spec), cross-platform — mirrors should_torch_compile(). Drops the Windows-only scoping (and the now-unused `import sys`). - Refreshed the stale module docstring. - 3 new tests cover the subprocess gate (triton-missing, triton-present, user-opt-out). 7/7 pass. * revert(engine_env): keep subprocess TORCH_COMPILE_DISABLE user-driven Reverts the build_engine_env() broadening from the previous commit. Auto- disabling subprocess torch.compile on Triton-absence conflicts with a deliberate, tested contract (test_perf_settings: Windows + flag-off ⇒ no injection; non-Windows ⇒ never inject) — the subprocess var is intentionally under the user's explicit control. The #65 fix is the in-process should_torch_compile() gate (unchanged here), which IS automatic and fully tested. Pushing back on the subprocess auto-gate as a separate, deliberate contract change rather than forcing it through by rewriting established tests. --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> feat: bundle Claude Code agent skill at .claude/skills/omnivoice/ (#113) * fix(mcp): drop unsupported FastMCP kwargs (mcp SDK >= 1.10) The MCP server passes `version=` and `description=` to FastMCP(), but neither kwarg exists on mcp >= 1.10 — the protocol version is now managed internally and `description` was renamed to `instructions`. Symptom on a fresh install (uv sync && pip install 'mcp[cli]'): TypeError: FastMCP.__init__() got an unexpected keyword argument 'version' Tested locally end-to-end: - create_mcp_server() now constructs cleanly - All 5 tools register and are listable via FastMCP.list_tools() - generate_speech round-trip returns base64 WAV; ~24s server-side for 4.2s of audio at steps=16 on Apple Silicon MPS - pytest backend/ -x -q: 45 passed * feat: bundle Claude Code agent skill at .claude/skills/omnivoice/ CLAUDE.md already invites contributions at .claude/skills/: "No project skills found. Add skills to any of: .claude/skills/, .agents/skills/, .cursor/skills/, .github/skills/, or .codex/skills/ with a SKILL.md index file." But the existing .gitignore blanket-ignored .claude/ (line 41), making the invited path un-trackable. This commit narrows the ignore so ad-hoc Claude state stays out while deliberate skill bundles are tracked: -.claude/ +.claude/* +!.claude/skills/ +!.claude/skills/** Once merged, any compatible agent client running `npx skills add debpalash/OmniVoice-Studio` gets immediate context on: - What the MCP server exposes (5 tools + 2 resources) - When to pick OmniVoice vs other engines - How to wire the stdio MCP server into a client config - Backend lifecycle: start / health / stop scripts - Common failure modes + fixes (port collision, model download stall, missing HF_TOKEN, MPS fallback, voice-profile-not-found, etc.) Conforms to Anthropic skill-creator conventions: frontmatter description under 1024-char limit, body under 500 lines, references/ for detail, scripts/ for deterministic ops, no README/CHANGELOG inside the skill, validates clean against quick_validate.py. Verified locally that `npx skills list` discovers the bundled skill automatically once cloned. End-to-end tested through MCP: - generate_speech (English, demo voice, steps=16) -> 4.2 s WAV - generate_speech (voice design via instruct only, steps=8) -> 6.3 s WAV - generate_speech (Spanish, demo voice, steps=16) -> 2.8 s WAV Depends on #112 (FastMCP API fix). Without it, every MCP tool call fails with TypeError at server construction. * feat(skill): add voice-clone end-to-end recipe + record-reference.sh helper Two additions to the bundled skill, closing the gap where agents had no procedural knowledge for creating a voice profile (the previous SKILL.md said "use the UI or POST /profiles" but didn't include the recording + trimming + verification workflow). 1. scripts/record-reference.sh — macOS-only helper that records a clean reference clip with **audible** countdown + start/stop cues via `say` + /System/Library/Sounds/Ping.aiff. Solves the buffering bug where text-mode "speak now" prompts arrive after recording starts. Captures a longer raw window then trims to ~10 sec of speech via silenceremove + atrim. Plays back for verification. Prints the next-step `curl` command for POST /profiles. 2. SKILL.md "Voice clone — end-to-end recipe" section (replaces the stub one-liner). Covers: - Path A: the bundled helper (one command, audible cues) - Path B: manual ffmpeg flow if the helper doesn't fit - POST /profiles multipart/form-data fields (required: name + ref_audio; optional: ref_text, language, instruct, seed, personality) - Reference clip quality factors that materially affect output (single speaker, natural prosody, 3-10 sec sweet spot, ref_text alignment, language correctness, loudness ≥ -15 dB peak) Tested locally: recorded a 10-sec Spanish reference + 3-sec English reference, created two profiles via the helper + curl flow, generated 14.1 sec of Spanish + 10.2 sec of English audio in the user's cloned voice. Round-trip works end-to-end at steps=16 on Apple Silicon MPS. Frontmatter description unchanged (860 chars, under the 1024 limit). Body grew from ~120 to 169 lines (still well under the 500-line skill ceiling). * fix(skill): address P20 cross-review findings on PR #113 Adversarial multi-agent review (code + comment + silent-failure analyzers on parallel reviewers) surfaced one blocker, one critical silent-failure class, two medium-severity bugs, and two minor doc inaccuracies. All addressed in this commit. Blocker (cited 3x by both code-reviewer and comment-analyzer): - SKILL.md linked references/engines-comparison.md three times (lines 44, 153, 160) but the file was never copied into the upstream skill tree. + Added the file (engine decision tree across OmniVoice / kokoro / Voicebox / Edge TTS / ElevenLabs / cloud APIs). Critical — record-reference.sh (was 4/10): - Mic-permission silent failure: macOS denies the mic by sending a silent stream; ffmpeg exits 0 with a valid silent WAV. The script printed "✓ raw captured" and produced a degenerate reference clip that would train a broken voice profile. + Parse mean_volume from volumedetect; exit 3 with a diagnostic pointing the user to System Settings → Privacy → Microphone if the recording is below -50 dB. - afplay backgrounded with no exit check; if /System/Library/Sounds/*.aiff is missing the user gets no audible cue. + beep() helper falls back to printf '\a' (terminal bell) when the system sound file is missing. - silenceremove silent corruption: silent input → near-empty output WAV, exit 0. + ffprobe duration check after trim; exit 4 if < 2.0 sec. - trap only covered EXIT; Ctrl-C / SIGTERM mid-recording leaked tmp file. + trap '...' EXIT INT TERM HUP. - macOS guard ran after mktemp + trap. + Moved guard to first executable line. - afplay verification swallowed stderr. + Drop 2>/dev/null; surface failure as a warning. - Documented exit codes in header (0/2/3/4). Medium — start-backend.sh (was 6/10): - TOCTOU race: lsof check → uvicorn start could lose the port to another process; only signal was a 60s health timeout. + Added `kill -0 $PID` check inside the probe loop; immediate exit 5 with log tail if uvicorn died. - lsof check couldn't tell "stale us" from "third party" — same exit 3 for both. + ps -o command attribution; the message now tells the user whether it's a stale uvicorn (suggest stop-backend.sh) or unknown process. - Documented exit codes (0/2/3/4/5). Medium — stop-backend.sh (was 7/10): - No post-SIGKILL verification — script exited 0 even if process still bound. + Added current_pids() helper; re-query after SIGKILL; exit 1 if still bound, with lsof dump for diagnostics. - 2>/dev/null || true on kill swallowed EPERM silently. + Capture stderr; classify EPERM vs ESRCH; exit 2 on EPERM with actionable hint (try sudo). - Documented exit codes (0/1/2). Minor docs (comment-analyzer): - SKILL.md line 120 claimed profiles persist as `<id>.wav`. Actual backend (profiles.py:48-50) preserves uploaded extension. + Reworded to `<id>.<ext>` with explanation. - mcp-setup.md line 68 cited HF cache path as Linux/macOS only. Windows redirects via backend/core/config.py:38 to %LOCALAPPDATA%\OmniVoice\hf_cache. + Added Windows row + reference to config.py. Re-validated: all 6 files compile under set -euo pipefail; SKILL.md frontmatter description stays at 860 chars (under 1024 cap); skill body under 500 lines. Diff: 6 files changed, ~+269/-47. fix(windows): HF cache disk-fallback for WinError 448 (plan-01, closes #117 #118) (#137) * fix(models): disk fallback when scan_cache_dir raises WinError 448 (#128) plan-01 fix-sequence step 2. On Windows, huggingface_hub's scan_cache_dir() raises WinError 448 "untrusted mount point"; the three call sites in setup/models.py swallowed it and reported "not cached", so the app re-downloaded models it already had — looping 5× and giving up (#117/#118). - _is_cached_on_disk / _scan_cache_on_disk: walk the canonical HF layout <cache>/models--<org>--<name>/snapshots/<rev>/ directly (honours HF_HUB_CACHE/HF_HOME, so a relocated models dir works too). - is_cached / list_models / recommendations now fall back to the disk scan when scan_cache_dir() raises. An empty snapshot dir is not counted. Symlink-disable env + local_dir_use_symlinks=False were already shipped (main.py, setup/download.py); this closes the remaining failure path. Tests (TDD, fail-before/pass-after): tests/test_hf_cache_fallback.py (4). No regression on the non-Windows path (fallback only triggers on raise). Closes #117, #118. Addresses #128 (#64 configurable-dir is the follow-up). * fix(models): probe HF /hub subdir + close scandir handle (bot review) Addresses #137 review: - CodeRabbit (critical): hf_cache_dir() returns HF_HOME when HF_HUB_CACHE is unset, but repos live under $HF_HOME/hub/models--…. Added _hub_cache_roots() so the WinError-448 fallback probes both <dir> (HF_HUB_CACHE-set case) and <dir>/hub (HF_HOME-only case); previously it could miss the cache and re-download. Regression test added (HF_HOME-only layout). - Greptile: wrap os.scandir() in `with` so the dir handle closes even when any() short-circuits (avoids handle leaks on repeated /models polls). - CodeQL: drop unused `os` import in the test. 5 tests pass, incl. -W error::ResourceWarning. --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> fix(client): use window.location.hostname for remote/Docker deployments (#123) When running in Docker and accessing OmniVoice from a remote machine, the frontend was hardcoded to call 127.0.0.1 for all API requests, causing every endpoint to fail with ERR_CONNECTION_REFUSED. Fix: detect Tauri context via window.__TAURI__ and use 127.0.0.1 only for native desktop builds. In browser/Docker deployments, fall back to window.location.hostname so remote access works correctly. Fixes #120 feat: pipeline error transparency — no more silent "unknown error" (plan-04, closes #131) (#136) * docs(plan-04): spec + plan for pipeline error transparency (#131) speckit spec/plan/research/data-model/contract/quickstart for plan-04. Grounds the fix in the real code map: shared failure-event builder (backend/core/failure.py) feeding tasks.py + dub_pipeline.py + dub_core.py, non-empty reason guarantee, sanitized diagnostic block, frontend renderer with docs deeplink. Closes-target: #131 (children #122, #63). Design only — no code changes yet. * feat(pipeline): structured, non-empty failure events + logged tracebacks (#131) plan-04 backend: no more silent "unknown error". A shared failure helper guarantees a non-empty reason at every emit site and a sanitized, copyable diagnostic block. - backend/core/failure.py: build_failure()/build_failure_event() (reason falls back to the exception class name), sanitize() (reuses the logging_filter HF-token regex + redacts *TOKEN*/*KEY*/*SECRET* env values + home→~), diagnostic() (reuses the env capture), classify() reusing the error_docs_map 5-class taxonomy for the docs deeplink + hint. - core/tasks.py worker: structured event instead of bare str(e); keeps the logged traceback. - services/dub_pipeline.py: enrich download/extract error yields; ADD the missing outer `except Exception` (the #122 path — unhandled ingest errors were never surfaced with stage context); surface the previously-silent demucs/scene/thumbnail degradations as non-fatal `warning` events. - api/routers/batch.py: guaranteed non-empty batch failure reason. SSE payload is additive (legacy `error`/`stage`/`detail` keys preserved), so existing frontends keep working and already show the specific reason. Tests (TDD, fail-before/pass-after): 14 cases — non-empty-reason guarantee, redaction, diagnostic sanitization, and the 3 Test-matrix triggers (worker / extract / url). 483 passed, 0 regressions. Closes #131. Refs #122, #63. * feat(dub-ui): show specific cause + docs deeplink + copyable diagnostic (#131) plan-04 frontend. The backend now sends a structured, non-empty failure; surface it to the user instead of "extract: unknown error". - dubSlice: DubFailure type + dubFailure state/setter. - useDubWorkflow: capture the structured failure on the SSE error event (reason/error_class/stage/hint/docs_topic/diagnostic); clear on new runs. - DubTab: DubFailureNotice renders the actionable hint, an "Open docs" deeplink (via the existing errorDocsMap classifier), and a "Copy diagnostic" button — shown beneath the error badge in both failure banners. typecheck + build clean; 66 frontend tests pass. Refs #131, #122, #63. * fix(failure): annotate intentional best-effort excepts (CodeQL) The new security workflow's CodeQL flagged 5 bare `except: pass` blocks. All are deliberate best-effort guards (sanitize/diagnostic must never throw on the failure path; the test cancels the worker to tear it down). Added explanatory comments per CodeQL's py/empty-except rule. No behavior change. --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> ci(security): scanning workflow + CodeRabbit config + sweep design (PR 0) (#135) * ci(security): add scanning workflow + CodeRabbit config + sweep design PR 0 of the v0.3.0 stabilization sweep — establishes the automated review + security gate every subsequent plan PR flows through. - .github/workflows/security.yml: gitleaks (gating secret scan), CodeQL (Python + JS/TS), bandit (SARIF), pip-audit + bun audit. Only the secret scan gates; dep/SAST findings are reporting-only to stay consistent with the no-ceremony, continuous-to-main cadence. - .coderabbit.yaml: path filters + constitution constraints encoded as review instructions (local-first, cross-platform parity, alembic, no secret/home-path leakage). Drafts excluded from auto-review. - SECURITY.md: document the automated scanning + bot review. - docs/specs: program design for the full sweep (plan-01..05 + PR triage). CodeRabbit and Greptile apps are already installed and will review on PR open. * ci(security): install bandit[sarif] extra; pin JS actions to Node 24 The bandit SARIF formatter ships in the `bandit[sarif]` extra; plain `bandit` rejects `-f sarif` (exit 2), so no SARIF was written and the upload step failed. Install via `pipx run --spec 'bandit[sarif]'`. Also add FORCE_JAVASCRIPT_ACTIONS_TO_NODE24 (mirrors ci.yml) to silence the Node 20 deprecation warning on checkout/setup-python/upload-sarif. * ci(security): harden per bot review — persist-credentials, upload guard, bun pin Addresses CodeRabbit + Greptile findings on #135: - persist-credentials: false on all checkout steps (don't leave GITHUB_TOKEN in git config; none of these jobs need authed git after clone). [CodeRabbit] - continue-on-error on the bandit SARIF upload so a missing SARIF can't fail this reporting-only job. [Greptile P1] - pin bun-version "1.2" — `bun audit` only exists in bun >=1.2.x. [Greptile P2] Declined: full-SHA action pinning. Meets the major-tag bar set in .coderabbit.yaml and matches ci.yml/release.yml convention; SHA pinning belongs in a repo-wide hardening pass with Dependabot, not one file. --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Phase 4 Plan 04-01: SPIKE-01 GGUF — GO + integration (#100) * Phase 4 Plan 04-01: SPIKE-01 GGUF — GO + Wave 1 integration Integrates Serveurperso/OmniVoice-GGUF as a hardware-adaptive default voice-cloning engine, with overridable fallback to the in-process OmniVoiceBackend. Spike confirmed GO: the model is a clean quantization of k2-fsa/OmniVoice (Apache-2.0 + MIT runtime, `omnivoice-lm` custom architecture so it does NOT load in vanilla llama.cpp). Pinned SHAs: * Serveurperso/OmniVoice-GGUF revision: 361609388ae572a820d085185bbbe2a2aac4b30e * ServeurpersoCom/omnivoice.cpp master: 886fc079838ca7400cb2b42b36e2a65aa1daabe8 Implements GGUF-01 (hardware probe) through GGUF-05 (default-engine resolver with graceful fallback). The four `bin/omnivoice-tts-*` artifacts are committed as zero-byte placeholders; the new CI matrix job builds the real binaries per platform from the pinned commit SHA and appends a SHA-256 manifest used by `is_available()` for tampering detection (T-04-01). The macos-14 (Apple Silicon) slot is marked `continue-on-error: true` because omnivoice.cpp publishes no `buildmetal.sh` (Pitfall 1 / Assumption A1) — failure feeds into Task 3's GO/NO-GO call. Quant override is allow-listed against quant_map.json entries only (T-04-05). Argv is composed from typed Path objects rooted in HF_HUB_CACHE; never uses `shell=True`. HF token redaction applies to captured stderr before logging (AUTH-05 / T-04-04). Tests: 36 new (8 hardware-probe + 13 GGUF engine + 6 settings_store quant override + grep gate); 428 passed in full suite vs 402+ baseline. ADR Status stays "Proposed (research-supported)" — Task 3 (human checkpoint) flips to Accepted after CI produces real binaries and a reviewer signs off on the GGUF-06 cross-hardware smoke. * ci: install libopenblas-dev on linux-x86_64 omnivoice-tts build The pinned omnivoice.cpp commit (886fc079...) ships a `buildcpu.sh` that passes `-DGGML_BLAS=ON`. ubuntu-latest has no BLAS implementation preinstalled, so the cmake configure step fails with `Could NOT find BLAS (missing: BLAS_LIBRARIES)` and the job exits in 13 s before producing the linux-x86_64 binary. macOS (Accelerate, built in) and Windows (BLAS off by default in the ggml CMakeLists for non-APPLE platforms — the build script doesn't invoke buildcpu.sh on those slots) are unaffected and stay green. Adds a Linux-gated apt step to install libopenblas-dev + pkg-config before the build, restoring cross-platform parity per the CLAUDE.md "default features must work on every platform" rule. * fix(gguf): constrain ref_audio to project roots — block /etc/shadow on Linux The GGUF engine's `_build_argv` previously validated ref_audio only via `ref_path.is_file()` — i.e. "does this path exist?" That check is platform-dependent: `/etc/shadow` doesn't exist on macOS (rejected naturally), but it IS a real system file on Linux, so the validation silently accepted it. CI's ubuntu-22.04 runner exposed the gap via `test_generate_blocks_freeform_ref_audio`, which exists precisely to guard the "freeform ref_audio path" attack surface. Fix: confine ref_audio to one of three allowed roots before existence checks: - VOICES_DIR (user-saved voice profiles) - DUB_DIR (per-job auto-clones extracted from source video) - tempfile.gettempdir() (browser-upload temp files; existing `cleanup_ref` flow in generation.py) Anything outside those roots → FileNotFoundError, matching the existing failure-mode contract callers handle. Existence check still runs after, so the test's mocked subprocess.run is never reached and the test passes deterministically on all three platforms. Cross-platform parity (per CLAUDE.md 2026-05-20 rule): identical behaviour on macOS / Windows / Linux — the allow-list is computed from core.config which uses platform-specific path resolution but yields the same logical "project tree" on every OS. * ci(gguf): mark darwin-x86_64 binary build as experimental GitHub's macos-13 (Intel) runner pool is heavily contended — PR #100 queued for 30+ minutes waiting on darwin-x86_64 while every other platform finished in ~1m. Intel Macs are also fading hardware (Apple's platform momentum is entirely on Apple Silicon), and the GGUF engine's runtime already handles a missing binary gracefully (`is_available()` returns False on Intel Mac with a "binary not bundled for this platform" message, same path used for first-launch before any binaries build). `experimental: true` mirrors what darwin-arm64 (Metal) already has — slot still runs and uploads its binary when successful, but a failure or runner backlog no longer blocks merges. Keeps the GGUF engine shippable across the dominant arm64 / Linux / Windows surface without holding the inbox on a slow-runner queue. --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> fix: speaker detection — gated pyannote license surfaces a docs deeplink (closes #78) (#110) * fix: speaker detection — gated pyannote license surfaces a docs deeplink (closes #78) Issue #78 ("Speaker detection fails — speakers blend together or aren't detected correctly") was the user-visible symptom of the dub pipeline silently falling back to the silence-gap heuristic in `backend/api/routers/dub_core.py::_diarize`. The heuristic alternates Speaker 1 ↔ Speaker 2 on >1.2s gaps only, so two real speakers with similar pacing get merged or swapped — and once the auto-clone step extracts a reference voice for the wrong label, downstream dubs make "person A speak like person B" (the reporter's exact phrasing). The structural cause is that pyannote-3.1 is gated on HuggingFace: a valid HF_TOKEN by itself isn't enough — the user must also click "Agree and access repository" on both pyannote/speaker-diarization-3.1 AND pyannote/segmentation-3.0. We can't fix that for the user, but we CAN make the failure actionable instead of silent. Changes: - `backend/services/model_manager.py`: `get_diarization_pipeline()` gains an opt-in `return_error=True` shape that returns `(pipeline | None, error_sentinel)`. Sentinels distinguish NO_TOKEN / PYANNOTE_LICENSE_REQUIRED / LOAD_FAILED. A new `_classify_diarization_error()` sniffs the exception's class name + message for 401/403/gated/"accept license" signals — kept as a string heuristic so it survives huggingface_hub major-version churn. Bare-`None` default return preserved for the legacy `_transcribe` call site at dub_core.py:781. - `backend/api/routers/dub_core.py::_diarize`: now emits a structured SSE warning `{detail, source, error_class, docs_url}` instead of plain `{detail, source}`. The new fields let the front-end render a "See docs" button that deeplinks directly to the `License acceptance flow` section of `docs/features/diarization.md` (landed in PR #94) — the page with the click-by-click instructions for fixing this exact failure mode. - `backend/core/error_docs_map.py` + `frontend/src/utils/errorDocsMap.ts`: add a 5th taxonomy class `PYANNOTE_LICENSE_REQUIRED` pointing at the diarization docs section. Distinct from `HF_AUTH_FAILED` (which is the more general "token missing or invalid" case). The TS `classifyError` heuristic also picks up pyannote / gated / "speaker diarization" keywords so a thrown error in the boundary routes to the right deeplink too. - `tests/backend/core/test_error_docs_map.py`: bump locked-keys set to 5 classes; add an explicit assertion that the new class points at the `license-acceptance-flow` anchor. - `frontend/src/utils/errorDocsMap.test.ts`: bump locked-keys set to 5 classes; add classifier tests for pyannote / gated / accept-license keyword routing. - `tests/test_diarization_error_class.py`: regression test (20 cases) covering `_classify_diarization_error`, the new `get_diarization_pipeline(return_error=True)` shape, backward- compatible bare-`None` return for the legacy call site, and the error_docs_map deeplink target. Uses sys.modules patching so pyannote / torch are never actually imported. HF token plumbing: unchanged. The new code continues to route through `token_resolver.resolve()` per the AUTH-01 contract — no new bare `os.environ.get("HF_TOKEN")` reads. Cross-platform: identical behaviour on macOS / Windows / Linux — the only platform-touching change is a docs URL string, which is opened via the existing `openExternal()` helper that already abstracts Tauri's `shell.open` on all three platforms. Verification: .venv/bin/python -m pytest tests/test_diarization_error_class.py \ tests/backend/core/test_error_docs_map.py -v # 20 passed in 0.03s bun run test src/utils/errorDocsMap.test.ts # 13 passed (1 test file) .venv/bin/python -m pytest tests/test_segmentation.py \ tests/test_dub_transcribe.py \ tests/backend/services/test_token_resolver.py \ tests/test_model_manager_preload.py # 40 passed, 10 xfailed (pre-existing), 1 xpassed * test: add regression test for diarization error classification (issue #78) Companion to the fix ind6e6586. 20 test cases covering: - `_classify_diarization_error` — the string heuristic that buckets pyannote/HF exceptions into NO_TOKEN / LICENSE / LOAD sentinels. Pinned for 401/403/gated/accept-license/accept-user-conditions signals so it survives huggingface_hub major-version churn. - `get_diarization_pipeline(return_error=True)` — the new 2-tuple return shape that lets the dub pipeline's SSE warning carry an error_class. - Backward compatibility — the bare-`None` return on the default signature is preserved so dub_core.py:781's legacy `_transcribe` call site doesn't break. - The error_docs_map deeplink — the new PYANNOTE_LICENSE_REQUIRED class points at `docs/features/diarization.md#license-acceptance-flow`. Uses sys.modules patching for pyannote.audio.Pipeline + token_resolver so the real torch + pyannote + HF API are never imported. * test(diarization): dotted-path monkeypatch to survive Wave 1 sys.modules purge The new `test_diarization_error_class.py` tests pass in isolation but fail in the full suite — Wave 1's `fresh_resolver` fixture aggressively purges all `services.*` and `core.*` modules from `sys.modules` mid-suite. When this file's tests later did `from services import token_resolver` then `monkeypatch.setattr(token_resolver, "resolve", ...)`, the local `token_resolver` reference bound to a stale module identity. The function under test does `from services import token_resolver` at call time, which re-resolves through the (post-purge) `sys.modules['services.token_resolver']` — a different object — so the monkeypatch was applied to one ID and the function read from another. Two fixes in this commit: 1. Don't pop `services.token_resolver` in this file's `model_manager` fixture — the test body's import and the function's import must agree on identity. Popping forces re-import that can create two distinct modules. 2. Use the dotted-path form `monkeypatch.setattr("services.token_resolver.resolve", ...)` instead of the object-attribute form. Pytest's dotted form re-resolves the path through `sys.modules` at setattr time, so the binding is always on the live module object regardless of which identity the test imported earlier. Verified: `pytest tests/ -q` → 442 passed, 0 failures (was 1 failed before this commit on PR #110). --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> fix: personality preset crash in Design tab (closes #89) (#111) Selecting any personality preset in the Design tab (e.g. "News Anchor") made the next Synthesize call fail with a 400 ValueError that took the generation pipeline down — the user had to restart the app. Root cause ========== backend/core/personalities.py shipped human-readable prose as each preset's `instruct` value, e.g.: "Speak clearly and professionally like a television news presenter" OmniVoice's model.generate(instruct=...) runs every instruct string through _resolve_instruct (omnivoice/models/omnivoice.py:1351), which splits on commas and validates each item against a fixed taxonomy in omnivoice/utils/voice_design.py (gender, age, pitch, accent, dialect, "whisper"). Prose like "Speak clearly..." has zero tokens in that vocabulary, so the model raises: ValueError: Unsupported instruct items found in Speak clearly and professionally like a television news presenter: 'Speak clearly and professionally like a television news presenter' -> ... (unsupported) The frontend (CloneDesignTab.jsx applyPersonality) writes the preset's `instruct` straight into the synth form, so every one of the six personalities triggered the crash — verified all six raise ValueError. Fix === Map each personality to a comma-separated bundle of valid taxonomy tokens that _resolve_instruct accepts. Kept the original prose as a new `description` field for any future UI tooltips and so the design intent isn't lost. Verified personalities now round-trip cleanly: narrator -> "middle-aged, low pitch" casual -> "young adult, moderate pitch" news_anchor -> "middle-aged, moderate pitch, american accent" storyteller -> "middle-aged, moderate pitch, british accent" corporate -> "middle-aged, moderate pitch" energetic -> "young adult, high pitch" Regression test =============== tests/backend/core/test_personalities.py exercises the exact failing path: every personality is fed through the same _resolve_instruct that the runtime calls. The test would have failed on every shipped personality before this commit. Cross-platform / data compatibility =================================== Pure-Python data change — same on macOS / Windows / Linux. No DB schema or omnivoice_data/ migration: voice_profiles.instruct is untouched. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Per-segment audio effects DSP preset selector (closes #67, rebased from #68) (#109) * Add per-segment audio effects DSP preset selector to dub pipeline * Add shape assertions to podcast, warm, and bright preset tests * Fix raw preset semantics, add preset validation, update docs, remove duplicate sys.path * Narrow OOM catch to model.generate only in dub_generate * Preserve original OOM exception context in dub_generate * Bind effect_preset to _gen via explicit parameter to avoid loop capture * Catch RuntimeError instead of torch.mps.MPSError for MPS OOM --------- Co-authored-by: 4shil <166588383+4shil@users.noreply.github.com> fix(ui): move UI scale + theme picker from footer to Settings → Appearance (#108) The LogsFooter bar carried two always-visible appearance controls in the left edge — \`S M L\` UI-scale toggle and 6 color theme dots. Both duplicated the "Settings" affordance: rarely-used display preferences shouldn't live in always-on chrome competing with logs / error counts. Moved both into a new \`AppearancePanel\` rendered as a Settings section: - New: frontend/src/components/settings/AppearancePanel.{jsx,css} - Wired into Settings.jsx alongside ApiKeysPanel + PerformancePanel - Footer no longer renders UiScaleToggle / ThemePicker / their dividers Store state (uiScale, theme, setUiScale, setTheme) is unchanged — they still persist via the same Zustand persist whitelist, just rendered in the new location. Users who toggled to L-scale or Catppuccin keep their prefs across the move. User-visible effect: footer left edge now starts with the collapse chevron + "Logs" title, then the source pills. No S/M/L. No color dots. A user who wants to change either opens Settings. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> fix(ui): gate A/B Compare on having ≥2 profiles to actually compare (#107) The "A/B Compare" button always rendered in the Launchpad chrome, even on a fresh install with zero or one profile — clicking it just opened an empty CompareModal. Visible-but-non-functional chrome is exactly the kind of UI annoyance the calm-chrome pass is targeting. Gate the render on `profiles.length >= 2`. The button appears only when A/B comparison is meaningfully available; until then it's hidden and the header stays clean for the "Make voices that sound like you" hero. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> fix(ui): hide RAM/CPU/VRAM in header by default; opt in via Settings → Performance (#106) The header's live metrics block (`RAM 12.8/16G CPU 25% VRAM 3.2G ●Idle Flush`) was loud chrome — a user picking "Voice Clone" doesn't need a resource monitor competing with the OmniVoice brand. The Idle/Ready/Loading status badge + Flush button stay visible because both are action-relevant; only the three numeric counters are gated. Behind a Zustand-persisted `showHeaderLiveStats` flag, default `false`. Power users can flip it on via Settings → Performance → "Show live system metrics in header" — same panel where the torch.compile toggle already lives, so all "Performance" controls cluster. Why opt-in (not opt-out): the project's stated core value is "a first-run that actually works" — successful state should be invisible. Telemetry chrome is the opposite of that. Defaults must work on every platform per the CLAUDE.md rule landed earlier today; "Show metrics by default" is fine-on-developer-laptops noise on every other machine. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> fix(ui): calmer launchpad — hide readiness when green, drop duplicate notif pill (#105) Two small surfaces of "annoying chrome on the welcome screen" identified during a UI review pass: 1. **System Readiness card stayed visible on the Launchpad even when every check was pass-or-warn.** The component already had self-hide logic for that case, but `Launchpad.jsx` was passing `showWhenAllPass` which defeated it. Removed the prop — the card now only appears when there's an actual issue worth surfacing. Compact "All systems ready" pill (line 249) still shows when there *are* projects, so the readiness affordance isn't gone, just quieter. 2. **Footer "Notifications" pill duplicated the header bell.** The `SOURCES` array in `LogsFooter.jsx` declared a 4th source ("notifications") that rendered as a separate pill in the always-visible footer chrome. The header bell + badge in `NotificationPanel` is the canonical surface; showing the same count twice was just noise. Dropped the SOURCES entry (and the now-unused `Bell` import). The footer is logs-only now. User-visible effect: on a healthy install, the welcome screen shows just the hero + 3 capability cards. No "✅ System Readiness" panel, no "Notifications (1)" footer pill. If something does need attention, the bell shows it (top right) and the readiness card surfaces with the specific failure. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> fix(widget): hide on app load, bottom-center position, exclude from window-state restore (#104) Three issues with the dictation pill (Whisper-Flow / Ghost-Pepper style): 1. Pill appeared on app load even though `.visible(false)` and the global shortcut hadn't been pressed. 2. When shown, it positioned at top-center instead of bottom-center. 3. The "Ready — hold shortcut to speak" idle label rendered inside the pill even when no recording was active. Root causes & fixes: **(1) `tauri-plugin-window-state` was restoring widget visibility.** If the user had the widget visible when they quit the app (mid-dictation, or by clicking the tray's "Start Dictation" while a window was up), the plugin saved `visible: true` and restored it on next launch — overriding the `WebviewWindowBuilder.visible(false)`. Fix: add the widget label to the plugin's denylist, so its state is never persisted. Belt-and-braces: explicit `win.hide()` on the widget during studio-mode and pill-mode startup, so any other plugin or stale state can't sneak the window in. **(2) Position was hard-coded to top-center.** Changed `LogicalPosition::new(x, 60.0)` (top) to a computed bottom-center position: `y = logical_screen_height - 64 - 80` (80 px margin clears macOS dock + Windows taskbar + most Linux panels). Same math in all three places it's set (global-shortcut handler, tray dictate, pill-mode pre-position) — identical behavior on macOS/Windows/Linux per the new CLAUDE.md "default features work on every platform" rule. **(3) Idle label rendered visually.** `CaptureWidget.jsx` now returns `null` when `state === 'idle'`. Listeners stay mounted (hold-to-talk wiring is preserved), only the visual pill DOM disappears. The slide-in animation triggers on the natural unmount→mount when state flips out of idle. Also: lock in two durable rules surfaced in this session: - CLAUDE.md: "Default features must work on every platform" — platform- divergent defaults are a P0 bug; platform-only features must go behind explicit opt-in. - CLAUDE.md: "No RC, no ceremony" — v0.3.0 ships continuous-to-main; tag when actually useful; no v0.4 deferrals while v0.3.0 is open. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> fix(desktop-prod): also clean backend data dir for actual fresh-install emulation (#103) The script was cleaning Tauri's APP_ID dir (~/Library/Application Support/com.debpalash.omnivoice-studio) but the Python backend writes to ~/Library/Application Support/OmniVoice — a separate hardcoded path in backend/core/config.py::get_app_data_dir(). Result: "🧹 Cleaning all OmniVoice data for fresh prod emulation" was deleting an empty directory while the real user data (SQLite db, voice profiles, dub jobs, outputs, logs) sat untouched. Developers running desktop-prod thought they were testing a clean install path, but were actually testing on accumulated state. Fix: add a BACKEND_DATA variable and a 1b cleanup step targeting the backend's actual data dir. Per platform: - macOS: ~/Library/Application Support/OmniVoice - Linux: ~/.omnivoice - Windows: %APPDATA%/OmniVoice (not in this script; Windows uses .bat) Surfaced while running `bun desktop-prod` for the first time today on a clean tree — the .app launched fine but Settings showed a pre-existing voice profile from a prior session, contradicting the "fresh" claim. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> fix(tauri): inject-apprun path relative to frontend/ (where beforeBundleCommand runs) (#102) Tauri's `beforeBundleCommand` runs from the directory containing the frontend `package.json` (i.e. `frontend/`), not from `frontend/src-tauri/`. The Phase 1 Wave 3 work wired the AppRun injector with the wrong relative prefix — `../../scripts/inject-apprun.sh` goes one level *above* the project root, so `bun desktop-prod` failed at the bundle step with "bash: ../../scripts/inject-apprun.sh: No such file or directory" on every developer machine. Fix: drop one `../`. The script itself was already correct (used absolute paths internally), so on macOS where there's no AppDir staging it cleanly exits 0 with "no AppDir staging found (skipping)". Verified: full `bun desktop-prod` cycle now reaches "✅ Build complete" and launches the .app bundle. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Fix NameError: '_gpu_pool' is not defined in get_model() (#90) The `_gpu_pool` variable is a lazy module attribute that's only accessible via `__getattr__` or the `_get_gpu_pool()` accessor. Line 326 was using `_gpu_pool` directly, causing a NameError when `get_model()` was called. Line 357 already correctly uses `_get_gpu_pool()`. This fix aligns line 326 with the rest of the codebase by using the proper accessor function. Co-authored-by: Nexlabz <contact@nexlabz.com> Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com> Phase 3 Plan 03-01: Supertonic-3 engine on SubprocessBackend (#101) * Phase 3 Plan 03-01: Supertonic-3 engine on SubprocessBackend Adds Supertonic-3 as a 7th opt-in TTS engine on the Phase 2 SubprocessBackend primitive. Closes TTS-01..06 (REQUIREMENTS.md): * TTS-01 — _REGISTRY["supertonic3"] resolves to Supertonic3Backend, a SubprocessBackend subclass. * TTS-02 — `supertonic==1.3.1` lives under [project.optional-dependencies]; default `uv sync --no-dev` does NOT install it. Exactly one `onnxruntime` row in `uv pip list` after `--extra supertonic`. * TTS-03 — Model revision pinned by 40-char commit SHA (724fb5abbf5502583fb520898d45929e62f02c0b — the "Initial Supertonic 3 release" SHA, same as the SDK's own pin). Resolver script for intentional bumps: scripts/resolve_supertonic3_sha.py. * TTS-04 — Honest CPU-only reporting. `is_available()` message says "ready (CPU-only via onnxruntime)" and never mentions "cuda" or "mps". `gpu_compat = ("cpu",)`. * TTS-05 — License gate via settings_store helpers (get/set_license_accepted) + Loopback-only /api/settings/license endpoint + SupertonicLicenseDialog frontend modal showing MIT (code) and OpenRAIL-M (model). Wired into EngineCompatibilityMatrix as an "Accept license" button on rows whose `reason` mentions "license not accepted". * TTS-06 — 3 langs (en/ja/ru) × 3 sec smoke test in tests/test_supertonic3.py::test_smoke_3langs_3sec (OMNIVOICE_SMOKE-gated; asserts no onnxruntime-gpu row post-synthesize). Package legitimacy gate (Task 1 in plan): supertonic on PyPI verified to be published by Supertone Inc. (ato@supertone.ai), repo github.com/supertone-inc/supertonic, wheel is pure-Python with no postinstall scripts. Same publisher ships supertonic-js on npm under the same maintainer email. Test results: * tests/test_supertonic3.py — 10 passed, 3 skipped (network-gated). * tests/smoke/ — 4 passed. * tests/ (full, --ignore=tests/manual) — 412 passed, 0 failed. * ci(tests): uv sync --all-extras so optional-engine tests can import their package Phase 3 added `supertonic` as an optional dependency. The CI Tests job runs `uv sync` (no extras), so `test_cpu_only_honest` and `test_license_gate` in tests/test_supertonic3.py hit the "supertonic package not installed" fallback instead of the real import path, and fail. Bare `uv sync` is the right default for users (engines are opt-in), but the test environment should exercise the full surface. `--all-extras` keeps the smoke job lean (still bare `uv sync`) while letting Tests verify the integrated behavior of every optional engine. Future-proofs against the same failure mode in Phase 4 (GGUF) and any later optional engines. --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Phase 2 Plan 02-04: Engine Compatibility Matrix API + UI (#99) * Phase 2 Plan 02-04: GET /engines/{id}/health + gpu_compat + HF mask ENGINE-06 backend half. Adds the data + spawn-on-demand endpoint the new Engine Compatibility Matrix UI will consume: * `gpu_compat: tuple[str, ...]` class attribute on `TTSBackend`, overridden per backend with reasonable defaults (cuda+mps+cpu for OmniVoice/VoxCPM2; cpu-only for KittenTTS; mps+cpu for MLX-Audio; etc.). `list_backends()` serializes it as a list. * `_HF_TOKEN_MASK_RE` (`hf_[A-Za-z0-9]{30,}`) scrubs the `reason` and `last_error` fields before they leave the registry — Phase 1's HFTokenRedactor logging filter does not run on FastAPI response bodies, so this closes T-02-12. * `GET /engines/{engine_id}/health` — loopback-gated route that resolves the backend across tts/asr/llm registries, then either calls `SubprocessBackend.health_check()` (spawn-and-ping) for subprocess engines or falls back to `is_available()` for in-process engines. Returns `{ id, ok, message, latency_ms }`. Engine instances are cached per-class so repeated checks don't leak atexit hooks or spawn extra sidecars. The masked-redactor is reapplied on the way out. Test coverage (tests/backend/api/test_engines_route_shape.py, 11 tests): * Response shape includes the new fields for every TTS entry * IndexTTS2 isolation_mode == "subprocess", OmniVoice == "in-process" * Health route round-trips with mocked SubprocessBackend success * Health route falls back to is_available for in-process backends * Unknown engine id → 404 * Non-loopback origin → 403 * Engine instance cache reuses the singleton across calls * HF tokens leaked into is_available() / health_check() are masked in both the /engines and /engines/{id}/health response bodies Existing tts_backend_registry shape test updated to include `gpu_compat`. Full suite: 402 passed, 0 failures (up from 391+ baseline). * Phase 2 Plan 02-04: EngineCompatibilityMatrix UI + Settings wiring ENGINE-06 frontend half. Mounts a new component on Settings → Engines that surfaces, end-to-end, the data shape Plan 02-01 + Plan 02-03 added to the backend registry: * `frontend/src/components/EngineCompatibilityMatrix.jsx` (270 lines) — semantic <table> with role=row/cell so RTL queries work; one row per registered backend. Columns: - Engine name + install hint + Last error line - Install state badge (Available / Unavailable + inline reason) - GPU compat chips (CUDA / MPS / ROCm / CPU with colored variants) - Isolation mode badge (subprocess for IndexTTS, in-process for the rest — makes the Phase 2 architectural shift legible to users) - "Test engine" button → `/engines/{id}/health` round-trip; renders latency in ms inline next to the button; disabled while inflight; 5 s cooldown to prevent click-storms. Mount does NOT auto-test any engine — per the plan's Open Question #2, spawning sidecars is gated on user action. * `frontend/src/components/EngineCompatibilityMatrix.css` — minimal styling that reuses chrome tokens; chip colors per GPU target. * `frontend/src/api/engines.ts` — `getEngineHealth(id)` client function wraps the new backend route through the shared apiJson helper. * `frontend/src/api/types.ts` — extends EngineBackend with optional `isolation_mode`, `last_error`, `install_hint`, `gpu_compat` so the TypeScript surface tracks the backend wire shape, and adds EngineHealthResponse. * `frontend/src/pages/Settings.jsx` — replaces the hand-rolled Engines table inside EnginesTab with `<EngineCompatibilityMatrix family="tts" onSelect={...} />`. selectEngine still wires up the picker; the matrix's onSelect prop renders the Use button per row when provided. Removes the now-unused FAMILY_META local map. Test coverage (`frontend/src/test/EngineCompatibilityMatrix.test.jsx`, 8 tests via vitest): * Renders one row per backend with documented columns * isolation_mode badge: subprocess for IndexTTS2, in-process for OmniVoice / KittenTTS * GPU compat chips: omnivoice → cuda/mps/cpu; kittentts → cpu only * Unavailable rows render the failure reason inline * last_error line renders below status when populated; masked HF token sentinel survives verbatim * Test engine click fires getEngineHealth(id) and renders latency_ms * Test button disabled while inflight; second click is a no-op * Failure path (ok=false) renders a failure marker Frontend suite: 65 passed (8 new). Lint: 0 new errors. typecheck:ci: clean. * Phase 2 Plan 02-04: SUMMARY Recap of Engine Compatibility Matrix delivery — backend route + gpu_compat metadata + HF-token redaction, frontend EngineCompatibility- Matrix component, full test counts, deviations, gpu_compat confidence matrix, frontend test-runner command notes for Phase 6 CI. --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Phase 2 Plan 02-03: IndexTTS on SubprocessBackend (closes #42) (#98) Migrates IndexTTS-2 off the in-process import path and onto the SubprocessBackend primitive shipped in Plan 02-01. Closes issue #42 with a structural fix — the parent's transformers>=5.3 and IndexTTS's transformers<5 now live in separate OS processes and can never collide. * New: backend/engines/indextts/ — sidecar package (__init__.py hosts IndexTTS2Backend, main.py is the sidecar entrypoint, bootstrap.py owns the 3-step venv probe + lazy uv-based bootstrap). * services.tts_backend: IndexTTS2Backend's in-process body removed; registry resolves the class lazily via a _LazyRegistry indirection + PEP 562 __getattr__ re-export. This breaks the import cycle that arose when both subprocess_backend and tts_backend tried to import each other at module load. * docs/engines/indextts.md: install walkthrough + venv resolution order + common errors (linked from is_available()'s unavailable message). * tests: - test_indextts_backward_compat.py (8) — probe priority, no-spawn discipline, HF cache marker preservation (ENGINE-07). - test_indextts_sidecar.py (17) — subclass shape, isolation_mode, parent-side emotion arbitration (vector/audio/text/description), coexist-with-OmniVoice (headline #42 closure), env forwarding. - tests/fixtures/mock_indextts_sidecar.py — stdlib-only sidecar mimicking the production wire protocol; emits 1 s sine wave. - test_issue_fixes.py: two obsolete in-process-conflict tests rewritten to assert the new subprocess contract (no indextts.* import in the parent). Hard constraints honored: backend/services/sonitranslate.py and gpu_sandbox.py are untouched (D1 / D4). Existing v0.2.7 users with OMNIVOICE_INDEXTTS_DIR and a populated HF cache reach a working generation with zero re-download and zero re-install. 44 tests pass across the four exercised files. Full suite: 391 passed, 10 skipped, 13 xfailed, 1 xpassed in 57 s. Smoke: 4 passed. Closes #42. Requirements: ENGINE-02, ENGINE-03, ENGINE-04, ENGINE-07. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Phase 2 Plan 02-01: SubprocessBackend primitive (Wave 1 of Phase 2) (#97) * Phase 2 Plan 02-01: SubprocessBackend primitive + echo sidecar + ENGINE-05 wrap Lands the durable SubprocessBackend primitive — the architectural keystone that Plans 02-03 (IndexTTS migration), Phase 3 (Supertonic-3), and Phase 4 (GGUF / Singing) plug into. Files added: - backend/services/subprocess_backend.py — base class owning spawn, shutdown, _send/_recv (length-prefixed JSON), GPU-slot acquire-release, atexit teardown, stderr drain, op allowlist (T-02-04), and 64 MB frame cap (T-02-01). No multiprocessing — subprocess.Popen exclusively so subclasses can target a *different* venv's interpreter (Locked Decision D4 / Pitfall 1). - backend/engines/_echo/main.py — permanent CI regression sidecar. Stdlib-only, runs under the parent's sys.executable. Implements ready/ping-pong/synthesize/shutdown plus test-only probe_env and emit_unknown ops for env-forwarding and op-allowlist tests. DO NOT DELETE — the round-trip test depends on this file. - tests/backend/services/test_subprocess_backend.py — 13 tests: round-trip, health_check, no-zombie, shutdown idempotency, env forwarding (HF_TOKEN/HF_HOME/HF_ENDPOINT/HF_HUB_CACHE), oversize frame, short read, op-allowlist drop, op-allowlist constant shape, sidecar-crash recovery, no-multiprocessing grep gate, MAX_FRAME_BYTES. - tests/backend/services/test_tts_backend_registry.py — 6 tests for list_backends() resilience + shape + isolation_mode + last_error caching + existing-engines preservation + install_hint passthrough. Files modified: - backend/services/tts_backend.py: * Adds module-level _LAST_ERRORS dict for ENGINE-06. * Rewrites list_backends() to wrap each is_available() in try/except so one broken engine cannot blank the picker (ENGINE-05). * Adds last_error + isolation_mode keys to each response entry (ENGINE-06 UI in Plan 02-04 consumes via the same /engines route). * Uses a duck-typed _is_subprocess_isolated marker rather than issubclass(cls, SubprocessBackend) because test fixtures (token resolver suite) purge sys.modules["services"] between tests and the re-imported SubprocessBackend would be a different class object. Threat-model mitigations (Plan 02-01 frontmatter): T-02-01 DoS via length-prefix → MAX_FRAME_BYTES = 64 * 1024 * 1024 T-02-02 GPU slot leak on sidecar death → try/finally in generate T-02-03 token bytes in stderr → drained via parent logger (HFTokenRedactor from Phase 1 already on root) T-02-04 unknown ops from compromised sidecar → PARENT_INBOUND_OPS allowlist, unknown frames logged and dropped T-02-05 Tauri group-kill scope → start_new_session=True on Unix / CREATE_NEW_PROCESS_GROUP on Windows Verification: - 337 passed, 6 skipped, 12 xfailed, 1 xpassed (full suite, `uv run pytest tests/ --ignore=tests/manual`) - All 19 new tests pass on macOS Apple Silicon - Smoke tests still pass: `uv run pytest tests/smoke/ -q` → 4 passed - SoniTranslate untouched (D1 locked decision) - Zero new Python dependencies Closes part of ENGINE-01 + ENGINE-05. * docs(02-01): plan summary — public API, invariants, deviations Documents the SubprocessBackend public API so Plan 02-03 (IndexTTS) and Phase 3 (Supertonic-3) authors don't need to re-read the source. --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Phase 2 Plan 02-02: audio I/O hardening + WAV-export correctness (#96) * Phase 2 02-02: add _safe_torchaudio_save + _safe_soundfile_write helpers Centralizes WAV/audio writes through a single audited path that defends against the four documented torchaudio.save failure modes (CUDA/MPS tensor, non-contiguous, out-of-range, wrong dtype) AND the torchaudio 2.9+ TorchCodec-delegation behavior drift. * services/audio_io.py:_safe_torchaudio_save now performs: - .cpu() move (torchaudio cannot serialize CUDA/MPS) - dtype coercion to torch.float32 - .clamp(-1.0, 1.0) (out-of-range = silent clipping on some backends) - .unsqueeze(0) for 1D (mono) inputs - .contiguous() (torch.cat of slices = non-contig = silent corruption) - explicit encoding="PCM_S/PCM_F" + bits_per_sample so future torchaudio backend selection cannot drift the on-disk format - format passthrough for wav/flac/mp3/ogg with encoding-kwarg fallback for older codec builds * services/audio_io.py:_safe_soundfile_write — sibling helper for the one sf.write call site (dub_core.py). Applies the same dtype/contig/ range checks before delegating to soundfile.write. * services/audio_io.py:atomic_save_wav (existing P0 helper) now delegates the actual encode to _safe_torchaudio_save so atomicity and correctness compose: every byte that lands at the target path was produced by the audited helper. * tests/backend/services/test_audio_io.py — 29 tests (25 pass + 4 skipped for MPS dtype incompatibility): parametric round-trip across dtype x device x contiguity, plus out-of-range clamp, format passthrough, in-memory buffer, empty-tensor rejection, 1D auto- unsqueeze, and a smoke check that atomic_save_wav inherits the safety guarantees. No new Python dependencies. SoniTranslate untouched (D1 locked). Refs BUG-01 / #48. * Phase 2 02-02: migrate router audio writes through audited helpers Migrates all 12 grep-audit bare audio-write call sites in backend/api/routers/ to route through services.audio_io. Closes the last surface area of BUG-01 / #48 that the P0 atomic-write commit (fb52140) did not cover. Sites migrated (grep before → after): generation.py:148 torchaudio.save → _safe_torchaudio_save generation.py:162 torchaudio.save → _safe_torchaudio_save openai_compat.py:155 torchaudio.save → _safe_torchaudio_save openai_compat.py:160 torchaudio.save → _safe_torchaudio_save openai_compat.py:168 torchaudio.save → _safe_torchaudio_save openai_compat.py:172 torchaudio.save → _safe_torchaudio_save openai_compat.py:178 torchaudio.save → _safe_torchaudio_save openai_compat.py:182 torchaudio.save → _safe_torchaudio_save openai_compat.py:193 torchaudio.save → _safe_torchaudio_save dub_generate.py:509 torchaudio.save → _safe_torchaudio_save batch.py:341 torchaudio.save → atomic_save_wav (track assembly) dub_core.py:438 sf.write → _safe_soundfile_write batch.py:341 specifically swapped to atomic_save_wav (not just the safe helper) because it writes the final track to disk — same shape as dub_generate.py:390 — and needs atomic publication, not only audited encoding. atomic_save_wav already delegates internally to _safe_torchaudio_save (per the Task 1 commit) so it inherits both guarantees. openai_compat.py:185 pcm branch produces raw int16 bytes (no container), so it can't go through _safe_torchaudio_save; it now inlines the same .cpu/.float32/.clamp/.contiguous sanity steps the helper enforces. tests/backend/test_dub_pipeline_wav.py: - test_no_bare_audio_writes_in_routers (in-process grep gate) - test_no_bare_audio_writes_via_subprocess_grep (CI-shell parity gate) - test_track_assembly_handles_non_contig_after_torch_cat (the #48 smoking-gun reproduction — torch.cat of out-of-range non-contig slices saved through the helper) - test_atomic_save_wav_assembly_pattern (same shape, via atomic_save_wav) - test_safe_soundfile_write_dub_core_pattern (ASR transcribe-chunk pattern from dub_core.py) - test_dub_pipeline_produces_valid_wav (xfailed — Phase 0 fixture sample_5s.mp4 not present; structural reproduction tests above already cover the helper code path #48 went through) Grep gate is green: grep -nE '(torchaudio\.save|soundfile\.write|sf\.write)\(' \ backend/api/routers/ -r --include='*.py' \ | grep -v '_safe_torchaudio_save\|_safe_soundfile_write' \ | grep -v '^[^:]*:[[:space:]]*#' \ returns 0 lines. Full suite green: 348 passed, 10 skipped, 13 xfailed, 1 xpassed. SoniTranslate untouched (D1 locked). Closes BUG-01 / #48. * Phase 2 02-02: add execution summary --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Phase 1 Wave 2: per-OS install docs + Settings UI + error→docs deeplinks (#94) * docs(install): per-OS install pages + drift validator + CI gate Splits the 600-line README install section into self-contained per-OS docs under docs/install/{macos,windows,linux,docker}.md plus a Top-10 troubleshooting index. Each OS doc is end-to-end: a user opens it and reaches a working app following only commands inside that file. Adds: - docs/install/{macos,windows,linux,docker}.md (OS-specific install paths) - docs/install/troubleshooting.md (top 10 install errors) - docs/engines/cosyvoice.md (closes #55 docs half) - docs/features/diarization.md (pyannote license flow) - docs/setup/huggingface-token.md (3-source cascade guide) - scripts/validate-install-docs.py (INST-06 docs-drift gate) - tests/scripts/test_validate_install_docs.py (B-5: validator self-tests) - .github/workflows/ci.yml step running the validator on every PR Implements INST-02 (README routing), INST-03 (macOS Gatekeeper anchor), INST-12 docs half (Windows torch-compile-oom anchor), DOCS-01..05. The validator is a one-way diff: every `<!-- validate -->`-tagged line in docs must appear in scripts/desktop-prod.sh after normalisation (prompt-prefix strip, CRLF, trailing whitespace, blank-and-comment skip). A `<!-- validate: skip -->` marker opts out for human-readability blocks. Its own 10 unit tests catch regressions in the gate itself. * feat(deeplinks): links.py + error_docs_map (Python + TS mirror) Adds the single source of truth for the project repo URL and the 4-class error → docs taxonomy that both the in-app ErrorBoundary deeplink button (Wave 2 Task 3) and the Phase 5 bug reporter will consume. New: - backend/core/links.py — PROJECT_REPO_URL + BLOB_MAIN resolver (Tauri config first, pyproject fallback) - backend/core/error_docs_map.py — lookup(error_class) → docs URL - frontend/src/utils/errorDocsMap.ts (TS mirror with classifyError helper) - tests/backend/core/test_links.py + test_error_docs_map.py - frontend/src/utils/errorDocsMap.test.ts Resolves checker B-6 (links.py ownership) and Open Question #3 (which fork the deeplinks resolve to — the Tauri updater endpoint wins, which points at the desktop app fork debpalash/OmniVoice-Studio). The TS BASE constant is documented as the second hardcoded URL drift site; the keys-sync test (`test_keys_match_python_map` equivalent) guards the 4-class taxonomy contract between Python + TS halves. * feat(ui): Settings → API Keys panel + ErrorBoundary docs deeplink Wave 2 AUTH-03 UI half + ErrorBoundary deeplink wiring. ErrorBoundary fallback now renders an "Open docs for this error" button that classifies the thrown Error message (heuristic: pkg_resources → 401 / HfHubHTTP → WebKit / white screen → quarantine / Gatekeeper) and opens the matching docs anchor via Tauri shell.open (with a window.open fallback in browser dev mode). ApiKeysPanel consumes the Wave 1 resolver state endpoint: - 3 source rows (App / Env var / HF CLI) with set/unset indicator, masked token preview, whoami username + green check - "Active" badge on whichever source is currently serving the cascade - App-row only: Save (POST /api/settings/hf-token) + Clear (DELETE with optional "also clear HF CLI" confirm dialog) - "Test now" button refetches state (invalidates the resolver's validation cache via the same endpoint hit) Panel mounted in the existing Settings → Credentials tab; the legacy HF_TOKEN row from CREDENTIAL_FIELDS is filtered out so the two paths don't fight over the same key. Threat T-02-02: the panel never displays the full token. The masked value comes from the resolver state endpoint; the full token only crosses the IPC boundary on Save (POST) and is cleared from local state on success. Closes AUTH-03 fully (Wave 1 backend + this Wave 2 UI). * feat(perf): INST-12 Disable torch.compile (Windows) toggle (backend + UI) Wave 2 Task 4 — full INST-12 delivery per checker B-2/B-7 v0.3.0 fat-release decision. Both the docs half (windows.md anchor, shipped in earlier commit) and the runtime toggle are now in Phase 1. Backend: - backend/services/settings_store.py: adds get_text/set_text helpers for non-secret config (refuses to write to the encrypted hf_token key). - backend/api/routers/settings.py: GET + PUT /api/settings/perf/torch-compile-disabled, both under the existing loopback guard (threat T-02-04). - backend/services/engine_env.py: new `build_engine_env()` helper that centralises HF_TOKEN/YOUR_HF_TOKEN injection from the 3-source resolver AND injects TORCH_COMPILE_DISABLE=1 when the flag is set on win32. Phase 2 SubprocessBackend launchers should adopt the same helper. - backend/services/sonitranslate.py: migrated to engine_env.build_engine_env() while preserving the source-level `env["HF_TOKEN"]` sentinel that test_sonitranslate_module_uses_resolver checks. Frontend: - frontend/src/components/settings/PerformancePanel.{jsx,css,test.jsx}: toggle UI with the explainer for #65; renders disabled with a "not applicable" badge on macOS/Linux. - frontend/src/pages/Settings.jsx: mounts the panel into the Credentials tab alongside the API Keys panel. Tests: - tests/backend/test_perf_settings.py: 7 backend tests (default state, PUT persistence, T-02-04 non-loopback rejection, settings_store round- trip, env injection on win32, NO injection on macOS/Linux, NO injection when disabled). - frontend PerformancePanel.test.jsx: 5 tests (renders from GET state, PUT on toggle, disabled on non-Windows platforms, pre-enabled state). * docs(planning): Wave 2 SUMMARY + REQUIREMENTS status updates - .planning/phases/01.../01-02-SUMMARY.md: full implementation report per template (truths, commits, tests, deviations, drift-site acknowledgments per W-3, launcher seam name for Phase 2, taxonomy keys for Phase 5). - .planning/REQUIREMENTS.md: flips Wave 2 closures to Done: AUTH-03, INST-02, INST-03 (docs half), INST-06, INST-12, DOCS-01..05. --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> fix(smoke): force-override OMNIVOICE_DATA_DIR + purge cached backend modules (#95) The smoke test used `os.environ.setdefault()` to point at the frozen fixture, which silently skipped when a prior test in the suite had already set the env var. Combined with `core.config` caching `DB_PATH` at module import time, this left smoke tests pointed at the wrong DB once Wave 1's services tests pre-imported `main` with their own temp state. Exposed by Wave 2's additional tests (PR #94) pushing collection order past the tipping point, but the underlying pollution existed since Wave 1 merged — Wave 3 CI passed only by collection-order luck. Fix mirrors the `sys.modules` purge pattern that `tests/backend/services/conftest.py` already uses. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Phase 1 Wave 3: AppImage launcher + .deb ffprobe + Docker LAN + Gatekeeper probe (closes #54, #56, #76, #80) (#93) * fix(appimage): conditional WEBKIT_DISABLE_COMPOSITING_MODE launcher (#56) WebKitGTK 2.44.x and 2.46.x have a compositing-path regression on Wayland that blanks the AppImage's first paint on Fedora 44 / Ubuntu 24.04. Setting WEBKIT_DISABLE_COMPOSITING_MODE=1 forces the software fallback that works, but blindly setting it on healthy WebKit versions (2.48+) regresses those. This wave adds a conditional AppRun launcher that detects the WebKit version via pkg-config and only sets the env var on the broken ranges (plus a fail-safe when pkg-config is absent or the version is unknown). The launcher is injected into Tauri's AppImage staging dir via a beforeBundleCommand hook — see .planning/decisions/apprun-strategy.md for the spike outcome and rationale (Strategy B chosen). Phase 1 Wave 3 — Plan 01-03 Task 1. Closes #56 frontend half. * fix(deb): relocate bundled ffprobe out of /usr/bin to avoid conflicts (#76) Prior versions placed the bundled ffprobe at /usr/bin/ffprobe via Tauri's externalBin, which overwrites the system ffprobe on Ubuntu 26.04 and collides with apt-installed media-package ffprobe. Relocate the .deb-bundled ffprobe to /usr/lib/omnivoice-studio/bin/ffprobe via bundle.linux.deb.files, plus defensive maintainer scripts: - preinst: ensure target dir exists for upgrade flows - postinst: remove legacy /usr/bin/ffprobe ONLY when dpkg confirms our package owns it (never touches a user's distro ffprobe) - postrm: clean up the relocated path tree on purge/remove Rust side (tools.rs::resolve_ffprobe) now probes the new path on Linux, and backend spawn (backend.rs) carries both FFPROBE_PATH (legacy alias) and OMNIVOICE_FFPROBE_PATH (canonical) into the backend env. Python side (ffmpeg_utils.resolve_ffprobe) reads OMNIVOICE_FFPROBE_PATH first, falls back to FFPROBE_PATH, then to shutil.which("ffprobe"). 6 new unit tests cover the env-cascade resolution. Phase 1 Wave 3 — Plan 01-03 Task 2. Closes #76. * fix(frontend): centralised apiBase resolver for Docker LAN access (#80) Docker / LAN browser users hit the preview API at the LAN host's IP, not their local machine — the prior frontend/src/utils/media.js:20 hardcoded http://localhost:3900, which from a LAN client resolved to the client machine itself. Centralise via frontend/src/utils/apiBase.ts: 1. VITE_OMNIVOICE_API override (Docker compose / dev) always wins. 2. Tauri webview → http://localhost:3900 (unchanged behaviour). 3. Plain browser → ${window.location.protocol}//${window.location.hostname}:3900 (follows the page's origin — closes #80). 4. SSR / no-window → http://localhost:3900 (safe fallback). Grep-sweep confirmed media.js:20 was the only hardcode site (Assumption A4 in 01-RESEARCH.md verified). 6 new vitest cases cover the resolver. Phase 1 Wave 3 — Plan 01-03 Task 3. Closes #80 frontend half. * feat(backend): macOS Gatekeeper quarantine probe + INST-01 guard (#54) Adds backend/core/gatekeeper_detect.py which walks up from sys.executable to find the .app bundle and runs `xattr -l` to check for the quarantine extended attribute (com.apple.quarantine). On detection, the lifespan startup probe logs a structured warning and emits a system_error event through the existing event bus with error_class="GATEKEEPER_QUARANTINE", which Wave 2's React ErrorBoundary turns into a docs deeplink. Detection is informational only — we never auto-run `xattr -cr` (the app itself is quarantined and cannot fix its own state per Anti-Pattern in 01-RESEARCH.md). Users get a clear pointer to the workaround docs. GET /system/quarantine-status exposes the structured payload so the frontend can poll on first load. INST-01 (setuptools>=75.0 pin from PR #62) gains a PR-time guard in tests/backend/test_pyproject.py + a user-observable smoke check in scripts/smoke-test.sh (pkg_resources + whisperx import). 7 gatekeeper tests + 1 pyproject test added — all pass. Phase 1 Wave 3 — Plan 01-03 Task 4. Closes #54 backend half (Wave 2 owns the docs page + ErrorBoundary deeplink wiring). --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> docs(planning): Phase 0 VERIFICATION + flip GATE/AUTH status to Done (#92) Phase 0 was verified PASS against `main` (7/7 truths, 9/9 artifacts, 6/6 GATE requirements, 5/5 success criteria; live smoke `tests/smoke/` green in 1.73s). Add the verifier's report and reconcile the REQUIREMENTS tracker — GATE-01..06 and AUTH-01..06 now show Done now that PR #71 (Phase 0) and PR #91 (Phase 1 Wave 1) are both on `main`. AUTH-03 is split: backend endpoints landed in Wave 1, UI ships in Wave 2. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Phase 1 Wave 1: HF token persistence + redactor (closes #35) (#91) * feat(01-01): encrypted settings store + alembic migration (AUTH-02, T-01-01) Adds the SQLite-backed encrypted settings store that Phase 1 token resolver will read from. Closes the at-rest plaintext risk for HF tokens (T-01-01). - backend/services/settings_store.py: get_hf_token / set_hf_token / clear_hf_token using Fernet symmetric AEAD. Stored value column never contains the literal "hf_" substring. - backend/services/_secret_key.py: per-install Fernet key derived via scrypt(machine-id + 16-byte random salt). machine-id resolution covers macOS (ioreg IOPlatformUUID), Linux (/etc/machine-id and dbus fallback), Windows (HKLM Cryptography MachineGuid via winreg). Final fallback to hostname+user with a warn log. - backend/migrations/versions/0001_phase1_settings_table.py: alembic migration adding `settings(key, value, updated_at)`. Idempotent — checks for an existing table so fresh installs (where _BASE_SCHEMA already created it) and v0.2.7 upgrades both succeed. - backend/core/db.py: _BASE_SCHEMA grows the settings table for fresh installs; init_db() now runs `alembic upgrade head` after the CREATE. - backend/migrations/env.py: honours an externally-set sqlalchemy.url so tests can point alembic at a fixture DB; falls back to core.config DB_PATH for production. - pyproject.toml: cryptography>=41 added explicitly (RESEARCH.md Assumption A1 was checked at execute-time and proved false; the dep was not present transitively, so the install would fail without this). Tests (10 cases, all green): - Round-trip encryption + plaintext-leakage check (T-01-01 invariant) - Salt persistence across clear/set cycles - InvalidToken decrypt path returns None (Open Question #5 resolution) - Concurrent reads consistent under sqlite WAL - Alembic upgrade on a hand-built v0.2.7 fixture DB preserves all existing tables + seeded rows (CLAUDE.md backward-compat constraint) - Alembic downgrade -1 drops only the settings table Refs #35. * feat(01-01): 3-source HF token resolver + log redactor + 5 read sites patched Closes the #35 bug class (bare os.environ.get('HF_TOKEN') reads) by routing every backend HF-token consumer through one resolver, and mitigates T-01-02 (info disclosure via logs) by stripping `hf_[A-Za-z0-9]{30,}` substrings from every log record at the root logger. backend/services/token_resolver.py: - resolve(skip) — 3-source cascade (App → Env → HF-CLI), each source validated via huggingface_hub.whoami(); first valid wins. - on_401(active) — invalidate cache and re-resolve skipping the source that just 401'd (AUTH-06). - state() — three SourceState rows for the Settings UI: set, masked preview (hf_…<last 3>), whoami_user, whoami_ok. - save_app_token / clear_app_token — wraps settings_store + calls huggingface_hub.login(add_to_git_credential=False) per Pitfall #2. - 300-second whoami cache so repeated Settings-page renders don't hit the HF API. backend/core/logging_filter.py: - HFTokenRedactor(logging.Filter) — regex `hf_[A-Za-z0-9]{30,}` so real tokens are masked but `hf_hub` / `hf_token` literals survive. - install_redaction_filter() — idempotent attach to root + every handler. backend/main.py: install the redactor at startup, BEFORE the file handler is added. Re-installed after the file handler attaches so the handler-attached filter list includes it too. Read-side call sites patched (per Pitfall #1 — every HF token read must flow through token_resolver.resolve()): - backend/api/routers/dub_core.py:540 (the original #35 site) - backend/api/routers/system.py:38 (_has_hf_token notification) - backend/services/model_manager.py:480 (diarization pipeline auth) - backend/services/sonitranslate.py:143 (Popen env for SoniTranslate child) - backend/services/sonitranslate.py:217 (gradio_client predict call) New endpoint: - GET /system/hf-token/state — returns the 3-source cascade state with masked tokens for the Wave 2 Settings UI panel. Grep gate confirmed clean: zero `os.environ.get("HF_TOKEN")` reads remain outside token_resolver.py. Tests (17 new cases, all green): - tests/backend/services/test_token_resolver.py: priority cascade, 401 skip mid-resolve, on_401 fallback, state() shape, save+login invariant (add_to_git_credential=False), HUGGING_FACE_HUB_TOKEN alias acceptance. - tests/backend/core/test_logging_filter.py: msg + args redaction, multi-token redaction, non-string args pass-through, short-token literals preserved, install_redaction_filter idempotence. Refs #35. * feat(01-01): Settings hf-token API endpoints + subprocess env injection (AUTH-03/04) Backend half of the Wave 2 Settings → API Keys UI plus the AUTH-04 subprocess env-injection invariant. backend/api/routers/settings.py: - POST /api/settings/hf-token — body {token: str} → save_app_token - DELETE /api/settings/hf-token — also_clear_hf_cli query → clear_app_token - GET /api/settings/hf-token/state — same shape as token_resolver.state() All three are gated by `Depends(require_loopback)` at the router level (threat T-01-03 mitigation; non-loopback Host → 403). backend/main.py: router mounted alongside existing API routers. Subprocess env injection (AUTH-04, threat T-01-04 disposition=accept): - backend/services/sonitranslate.py already updated in Task 2 to read via token_resolver.resolve() and inject HF_TOKEN + YOUR_HF_TOKEN into the SoniTranslate child env block. - backend/services/gpu_sandbox.py: NOT patched — the GPU sandbox runs in-process TTS generation that uses the parent's already-loaded HF state. Adding env injection there is a no-op (parent and child share state via multiprocessing.Pipe before any HF API call). - backend/services/model_manager.py:480 (Task 2): resolves in-process, no subprocess crosses here. - backend/api/routers/exports.py: subprocess.Popen calls only spawn `open` / `explorer` / `xdg-open` — file-manager launchers with no HF needs. Skipped per Task 3 conservative-patching rule. So the canonical AUTH-04 site for this milestone is sonitranslate.py. Future SubprocessBackend work in Phase 2 will inherit the same pattern. Tests (8 new cases, all green): - tests/backend/test_engine_spawn_token.py * POST /hf-token loopback → 200 + state.active == "app" * POST /hf-token non-loopback → 403 ("loopback origin required") * DELETE /hf-token clears settings_store + state.active == None * GET /hf-token/state returns 3 source rows in priority order * GET /hf-token/state non-loopback → 403 * env block contains HF_TOKEN + YOUR_HF_TOKEN when resolver returns one * env block does NOT contain an injected empty HF_TOKEN when resolver returns None * source-level check that backend/services/sonitranslate.py still reads via token_resolver.resolve() (regression guard against silent reverts of the AUTH-04 wiring) Full Wave 1 test suite: 35/35 green. Phase 0 smoke tests still green. Refs #35. * docs(01-01): SUMMARY + STATE update for Phase 1 Wave 1 completion Records execution outcome of the 3-task plan: 10 files created, 9 modified, 35 new test cases, 5 read sites patched, grep gate clean. Documents the two Rule-3/Rule-2 deviations applied (cryptography dep, env.py URL override), the subprocess-launcher inventory for Phase 2, and the known stray edit to the main repo's pyproject.toml that needs a one- line user action to revert. Updates STATE.md current-position table, progress bar, and open TODOs to point at Wave 2 (Plan 01-02) and Wave 3 (Plan 01-03) as the next steps. P0 wave-1: security + correctness + Phase 2 foundation (#88) P0 security + correctness fixes plus Phase 2 foundation work. 7 atomic commits, all CI green (Smoke + Tauri shell on macOS/Win/Linux + Tests). Code commits: -92f716e: P0 security — loopback guard on /ws/transcribe before accept() -fb52140: P0 dub — atomic WAV writes (closes #48 partial; Phase 2 plan 02-02 covers remaining sites) -9545640: P0 supply-chain — pin BtbN ffmpeg URL via FFMPEG_BTBN_VERSION -e414665: P0 security — remove torch.load monkey-patch in asr_backend -6b49290: docs — Phase 4 plan <action> blocks on checkpoint tasks -e764fdb: Phase 2 prep — TTSBackend.unload() foundation -71c10dc: test — fix capture_ws TestClient host for loopback guard 243 pytest passing, 0 failures. All 18 phase plans now validate. docs(v0.3.0): research + 18 plans for fat-milestone planning (#87) * docs(phase-5): research opt-in bug reporting Phase 5 research: prefilled-URL GitHub Issues pattern, default-deny payload, redaction layer, two-step consent UX, rate/dedup/recursion safeguards, aggregation across Python/Rust/React error producers. Builds on Phase 1's links.py + errorDocsMap deeplink infrastructure; uses already-installed @tauri-apps/plugin-opener (^2.5.4). No new packages required. Covers REPORT-01..12 with confidence levels, 8 pitfalls, subprocess-engine error capture handoff to Phase 2, security domain mapped to ASVS, and 3-wave delivery plan (redactor + payload, consent UI, aggregation + pre-submit search). * docs(phases): research for Phases 2, 3, 4, 6 (Engine + Supertonic + Spikes + Release) * docs(stack): bump supertonic pin 1.2.3 → 1.3.1 (Phase 3 research finding) * docs(phases): plan Phases 2-6 for v0.3.0 fat-milestone release 15 new plan files + 2 ADR decision docs across 5 phases. Combined with Phase 1's 3 plans, the v0.3.0 milestone now has 18 PLAN.md files covering all 7 phases (Phase 0 already complete via PR #71). PHASE 2 (Engine Isolation — 4 plans): - 02-01: SubprocessBackend primitive + echo sidecar POC + graceful is_available wrap (ENGINE-01/05) - 02-02: _safe_torchaudio_save helper + migrate 11 WAV write sites + #48 regression (BUG-01) - 02-03: IndexTTS sidecar entry + venv-probe bootstrap + IndexTTS2Backend rewire (ENGINE-02/03/04/07, closes #42) - 02-04: Engine Compatibility Matrix UI + /engines/{id}/health route (ENGINE-06) PHASE 3 (Supertonic-3 + Mirror — 2 plans): - 03-01: Supertonic-3 engine on SubprocessBackend + SHA pin + license gate (TTS-01..06) - 03-02: bootstrap.rs mirror cascade + UV_DEFAULT_INDEX migration + frozen enforcement + docs (INST-07..11) PHASE 4 (Spike-first Adaptive & Specialty — 2 plans + 2 ADRs): - 04-01: OmniVoice-GGUF hardware-adaptive engine + quant_map + bundled binaries (SPIKE-01, GGUF-01..06) - 04-02: OmniVoice-Singing subclass + dub pipeline singing mode + segment detector (SPIKE-02, SING-01..05) - SPIKE-01-gguf.md + SPIKE-02-singing.md ADRs in .planning/decisions/ PHASE 5 (Opt-in Bug Reporting — 3 plans): - 05-01: Redactor + BugReporter + URL builder + rate/dedup/recursion safeguards + FastAPI router (REPORT-01/02/03/05/06/07/08/10/11) - 05-02: BugReportDialog two-step consent + PrivacyPanel + ErrorBoundary integration + Rust panic hook (chained) (REPORT-01-Rust/04/09/12) - 05-03: Dry-run vs 3 historical issues + cross-platform openUrl smoke + Phase 2 subprocess-errors handoff (REPORT-02 smoke, REPORT-03 expansion, REPORT-09) PHASE 6 (Release + Retro — 4 plans): - 06-01: rc1 prep — version bump across 4 sources + CHANGELOG + retro stub + PR-73-strategy doc (REL-01/03/06) - 06-02: CI guards — workflow-parity actionlint + tag-shaped dry-run (Phase 0 retro options B + C; closes release-engineer gap) - 06-03: PR #73 reimplementation (NOT rebase) — backend-split installer with mirror-cascade integration + pill-mode regression checkpoint - 06-04: Execute the release — pre-tag gates + 4-OS clean-VM + 48h soak + tag + retro + 3 v0.4 deferral tracking issues (REL-01/02/03/04/05/06) Scope decisions locked in plans (council session): - SoniTranslate refactor DEFERRED to v0.4 (Phase 2 ships SubprocessBackend without migrating Soni) - macOS notarization DEFERRED to v0.4 (Phase 6 ships xattr -cr automation per CLAUDE.md Key Decision #7) - supertonic pin 1.2.3 → 1.3.1 (already committed inba63733) - SPIKE-01 and SPIKE-02 both GO; 13/13 Phase 4 reqs stay in scope - PR #73 reimplemented, not rebased (93 commits behind main) All 18 plans validated via gsd-sdk frontmatter.validate + verify.plan-structure. --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> P0: release.yml typecheck + bind audit + loopback middleware (#84) Three P0 fixes bundled — foundation cleanup before v0.3.0 phase work. Closes release.yml drift (PR #51's tabs broke v0.3.0 tag releases), production bind exposure (Critic F1), and 9-endpoint LAN gap on /system/* (Critic F2+F3). 5 new tests; 243 full pass. fix: add PYTHONPATH to docker-compose for pre-built images (#77) Community contribution from @fishandsheep. Adds PYTHONPATH=/app/backend to docker-compose env for both omnivoice (CPU) and omnivoice-gpu service blocks, so the pre-built Docker image can import backend modules correctly on first boot. Complements PR #74 (Docker GPU detection) — different sections of docker-compose.yml. Thanks @fishandsheep! fix(docker): GPU detection in containers + compose profiles + sonitranslate cuDNN sub-repo (#74) Docker GPU support hardening + documentation. - Restores docker compose --profile gpu up path; documents NVIDIA Container Toolkit setup in README - Splits CPU vs GPU compose services cleanly (deploy/docker-compose.yml) - backend/api/routers/setup/wizard.py: GPU detection in containerized environments uses torch.cuda fallback - New scripts/setup.py replaces deleted scripts/setup_cudnn.py - New test: tests/test_setup_preflight.py - CHANGELOG.md + README.md updated Complementary to PR #77 (community PYTHONPATH fix) — different sections of docker-compose.yml. fix: stabilize dub/diarization UI + production deployment + sonitranslate plumbing (#75) Production deployment hardening, dub OOM recovery, new SoniTranslate sidecar engine, ASR backend expansion. - Dub generation OOM recovery: backend/api/routers/dub_generate.py:163-209 adds OOM detection + one retry with reduced nstep - New SoniTranslate sidecar engine: backend/api/routers/sonitranslate.py + backend/services/sonitranslate.py (subprocess-based dubbing pipeline, opt-in) - ASR backends expansion: backend/services/asr_backend.py adds NeMo Parakeet TDT, Moonshine, additional Whisper variants; new GET /system/asr-backends endpoint - Dub UI polish: tighter spacing in DubSegmentRow.css, DubTab.css Issue #78 (speaker diarization mis-assignment) NOT addressed by this PR — the bundled diarization changes are in the new SoniTranslate sidecar, not the existing pyannote pipeline. Keeping #78 open. No DB schema changes, no migration. Backward-compatible for existing user data. fix(widget): hide dictation pill when idle, show only when activated (#83) Dictation pill widget no longer displays the idle "Ready — hold shortcut to speak" state by default. The widget now appears only when actively used (global shortcut press or tray "Start Dictation" click). Two surgical edits to frontend/src-tauri/src/lib.rs: 1. Pill-mode setup: removed win.show() + win.set_focus() on the widget. Kept positioning so the first show appears at top-center without animation flicker. 2. Tray "dictate" handler: now positions + shows + focuses widget BEFORE emitting tray-dictate, mirroring the global-shortcut handler. Previously tray-initiated dictation would record silently with no visible UI. Trade-off accepted: the original auto-show was intended to prevent a "looks-launch-failed" first-run experience for users without Accessibility permission. The tray icon + "OmniVoice Dictation" tooltip provide app-running signal; first-launch onboarding toast can be added later if support requests indicate confusion. Cross-platform bug bash + Stories tab + VRAM-aware GPU pool (#51) First v0.3.x release on the Phase 0 cross-platform CI baseline. ## Cross-platform bug fixes (375ea4e) User-reported bugs from a Pinokio/Windows session: - Docker `compose --profile gpu up` no longer port-conflicts on 3900 — restored `profiles: ["cpu"]` that #49 wrongly reverted on CodeRabbit's advice. - Argos / pip install from the UI now works inside Docker — added `_in_virtualenv()` runtime check; `run_pip` injects `--system` automatically when on system Python. - Speaker diarization warning toast — when pyannote silently falls back to the silence-gap heuristic (missing HF_TOKEN, license not accepted, network blocked), `_diarize()` now returns `(segments, warning)`; `useDubWorkflow` renders an 8-second toast. ## Dub editor UX (d5df454) Six fixes per annotated screenshots: - Editable segment start times (`m:ss.s` or raw seconds; Esc reverts, Enter commits; rejects overlap with end). - Click a transcript row → seek the waveform/video (`WaveformTimeline` now forwardRef's `seekTo(time)`). - Speaker is datalist-backed (pulls from detected speaker clones; free text still allowed). - Scissors menu splits at cursor — uses live caret, then last caret, then sentence-boundary fallback. - Mouse-wheel scrolls the waveform; Cmd/Ctrl left alone for browser pinch-zoom. - Menu popover collision: added `avoidCollisions` + `collisionPadding=8` to Radix Content; removed `position: fixed` from `.ui-menu`. ## VRAM-aware GPU pool (73dbe18) `_gpu_pool` was hardcoded `ThreadPoolExecutor(max_workers=1)` since introduction — every TTS forward serialized through one thread. - CUDA / ROCm: `workers = clamp(1, free_GB // 2.5, 4)`. 16 GB card with ~14 GB free → 4 workers → ~4× throughput on multi-segment dubs. - MPS / CPU / unknown: 1 worker. - `OMNIVOICE_GPU_WORKERS` env var override (clamped 1..16). - Module `__getattr__` preserves the public `_gpu_pool` symbol for existing callers. ## Stories tab — wire-up + UX (f6bbc7a) The 264-line `StoriesEditor` component existed but was mounted nowhere. Now wired into NavRail + lazy-loaded on `mode === 'stories'`. Added Paste & Split panel (sentence-boundary chunking) and per-track `[pause 0.5s]` insertion. ## Stories — pauses + inline voice (edd3a1d) `frontend/src/utils/storyTokens.js` — tokenizer for `[pause X.Ys]` and `[voice:X]…[voice:default]` markers. Voice switches are stateful (carry forward). 13 new vitest cases (vitest now 24/24). ## Verified - 214 backend tests pass (3 skipped, 10 xfailed, 3 xpassed) - 23 router-smoke tests pass - 24/24 vitest cases pass (13 new) - All 7 Phase 0 CI checks green (Tauri shell + Smoke on macOS/Windows/Linux + Tests) 🤖 Generated with [Claude Code](https://claude.com/claude-code) security: add loopback origin check to /system/set-env (#81) Adds `request.client.host` allow-list check (`127.0.0.1` / `::1` / `localhost`) to `POST /system/set-env`. Non-loopback callers receive `403` instead of being able to mutate `os.environ` for HF_TOKEN / TRANSLATE_API_KEY. Surfaced during security review of PR #66, which widens the pre-existing window by persisting these keys to disk via prefs.json. This fix closes the underlying vulnerability so PR #66's revision lands onto a clean base. Defensive `request.client is None` branch handles ASGI middleware that strips client info. Three new tests cover non-loopback reject, loopback allow, and allow-list still validated on loopback. Follow-up: `260518-ivy-deferred-items.md` enumerates 5 sibling POST routes in `system.py` that share the same gap — separate PR. Phase 0 — Gates: cross-platform CI matrix + regression fixture + release smoke (#71) * docs: initialize OmniVoice stabilization milestone project * chore: add project config (yolo + balanced) * docs: domain research for stabilization milestone * docs: define v1 requirements for stabilization milestone * docs: add GGUF + singing engine spike requirements (Phase 4 new) * docs: roadmap revision + CLAUDE.md (7 phases, 62 reqs, +GGUF/SING spikes) * docs(phase-0): add Gates phase RESEARCH.md Phase 0 research synthesizes the cross-platform CI matrix, frozen omnivoice_data fixture, installer post-build smoke, SHA-256 checksum publishing, and PR-template extension into copy-paste-ready YAML and Python snippets composed entirely from existing in-repo patterns. * docs(phase-0): add Gates phase CONTEXT, PATTERNS, and PLAN Phase 0 — Gates is the hard pre-condition for v0.3.x stabilization. Lays cross-platform CI matrix (macos-14/windows-2022/ubuntu-22.04), regression fixture (≤200 KB), installer smoke on tag push, SHA-256 checksums in release body + per-OS SHA256SUMS-*.txt assets, PR template with RC cadence + fixture line, and the open-PR landing for #51. Plan covers GATE-01..06; structured into 7 slices (A–G) with explicit Slice C → Slice G dependency reordering so the new smoke-matrix lands on main before PR #51 (CONTEXT.md L86 interleave decision). Plan-checker iteration 2: APPROVED — all 3 BLOCKERs + 3 MAJORs from iteration 1 resolved (file truncation/Slice-G missing, GATE-06 sibling PR verification, Slice C ordering, Truth #5 wording, macOS Tauri WebView avoidance per Pitfall #5, Windows taskkill per Pitfall #2). * test(00-gates): seed regression fixture (GATE-01) - scripts/seed-test-fixture.py — deterministic builder for tests/fixtures/omnivoice_data/ - wipes + rebuilds; fixed created_at=1700000000.0; all-zero PCM for byte-deterministic diffs - calls backend.core.db.init_db() directly (alembic versions/ is empty — see CONTEXT.md) - checkpoints WAL → DELETE on close so no -shm/-wal sidecars pollute git status - exits non-zero if fixture > 200 KB - tests/fixtures/omnivoice_data/{omnivoice.db, README.md} — 8-table empty DB + 1 voice_profiles row - tests/fixtures/omnivoice_data/voices/test-voice/{profile.json, sample.wav} — 1-sec 24 kHz mono silence - .gitignore — explicit allow-list (!tests/fixtures/omnivoice_data/**) so the existing omnivoice_data/, *.db, *.wav patterns don't hide the fixture from git Verifies: du = 144 KB on disk; sqlite_master lists 8 init_db tables + sqlite_sequence; voice_profiles has exactly 1 row id='test-voice'; 0 rows in generation_history. * test(00-gates): add tests/smoke/test_boot_smoke.py (GATE-01) - tests/smoke/__init__.py — package marker so pytest treats tests/smoke/ as a module - tests/smoke/test_boot_smoke.py — 4 in-process FastAPI TestClient smoke tests: * test_health_returns_ok — /health returns 200 + {status:ok, device:...} * test_profiles_endpoint_lists_fixture_voice — /profiles surfaces the seeded test-voice row (validates OMNIVOICE_DATA_DIR wiring → DB_PATH → init_db schema) * test_system_info_includes_data_dir — /system/info resolves data_dir * test_history_endpoint_empty — /history reaches DB and returns [] Test isolation env vars (OMNIVOICE_MODEL=test, OMNIVOICE_DISABLE_FILE_LOG=1) set at module top BEFORE any backend import — pattern from tests/test_router_smoke.py. Fixture is copied to a per-session temp dir so the test never mutates the checked-in artifact (SQLite file-change counter + runtime subdirs like dub_jobs/ would otherwise dirty `git status` after every run). Failure mode: if tests/fixtures/omnivoice_data/ is missing, pytest.fail at import time with the regenerate command. - .gitignore — tighten the GATE-01 allow-list to ONLY the seed-produced files (README.md, omnivoice.db, voices/test-voice/profile.json, sample.wav). Prevents future runtime subdirs the backend may create under the fixture from being accidentally committed. Verifies: `uv run pytest tests/smoke/ -q --tb=short` → 4 passed in 1.31 s (target was < 30 s). `git status` clean after a test run. * docs(triage): record post-planning GitHub state — PR #62, new issues, OOS deferrals - GATE-06: mark #53 + #61 merged (2026-05-16); add #62 (Wave 1 quick wins) to gate set - INST-01: note PR #62 implements setuptools pin (closes #58) - INST-04: note PR #62 lands README docs for #56 workaround - INST-12: new requirement for #65 Windows Triton/torch.compile OOM (filed post-planning) - Out of Scope: defer #67/PR #68 (audio effects), #64 (custom model dir), PR #66 zh-CN (i18n milestone), #63 (empty-template bug) PR #62 is the user's own Wave 1 work landed as a separate PR while GSD planning ran in parallel. Merging it eliminates duplicate work in Phase 1. * ci(00-gates): add cross-platform smoke matrix (GATE-02) - New smoke-matrix job on macos-14, windows-2022, ubuntu-22.04 - needs: test, fail-fast: false, timeout-minutes: 10 - Pinned actions: checkout@v4, setup-python@v5, setup-uv@v3 (cache enabled) - Per-OS ffmpeg + libsndfile install (brew/choco/apt via awalsh128 cache) - UV_HTTP_TIMEOUT=120, UV_HTTP_RETRIES=5 for restricted-network resilience - Narrow scope: uv run pytest tests/smoke/ -q --tb=short - Existing `test` and `tauri-cross-platform` jobs untouched * ci: add workflow_dispatch to ci.yml so smoke-matrix can run on feature branches * feat(00-gates): add --health-check CLI flag to backend entrypoint (GATE-03) - argparse on __main__ block; --health-check boots uvicorn in a daemon thread and polls http://127.0.0.1:3900/health every 5s for up to 60s. - Prints 'OK — /health responded 200 after Ns' and exits 0 on first 200. - Prints 'FAIL — /health did not respond 200 within 60s' to stderr and exits 1 on timeout. Default invocation behavior unchanged. - No new deps (stdlib argparse/threading/time/urllib.request/sys + uvicorn). - Consumed by per-OS installer-smoke step in .github/workflows/release.yml. Verified locally: exits 0 in 5s against tests/fixtures/omnivoice_data/. * ci(00-gates): add per-OS installer smoke to release.yml (GATE-03) Adds three matrix-leg-specific steps after 'Build + release (Tauri)', each gated by runner.os with timeout-minutes: 5: - macOS (macos-14): hdiutil attach DMG → locate bundled Python backend inside *.app/Contents (NOT the Tauri WebView shell — RESEARCH Pitfall #5: WebView hangs on headless runners) → invoke --health-check → hdiutil detach. Falls back to *.app/Contents/Resources and hard-fails with a directory listing if no backend binary found. - Windows (windows-2022): msiexec /quiet install → find backend.exe under 'C:/Program Files/OmniVoice Studio' → invoke --health-check in background, wait, then taskkill //F //T //PID to cleanup orphaned PyInstaller child processes on port 3900 (RESEARCH Pitfall #2). - Linux (ubuntu-22.04): --appimage-extract (no FUSE on GH runners), locate binary or AppRun, run under xvfb-run -a. Bundle-only regressions (PyInstaller missing-module, Tauri sidecar path mismatch) are invisible to ci.yml's in-process smoke matrix — this step closes that gap before any release is published. Verified: YAML parses; all three steps present; gating + timeout correct; Pitfall #2/#5 mitigations preserved. * ci(00-gates): publish SHA-256 checksums in release body + as asset (GATE-05) - Add 'Compute SHA-256 checksums' step writing SHA256SUMS-<label>.txt per matrix leg using native shasum/sha256sum (Git Bash on Windows). - Add 'Append checksums to release + attach SHA256SUMS file' step using softprops/action-gh-release@v2 with append_body: true so the hashes land in the release body alongside tauri-action's content (not replacing it) and the file is uploaded as a release asset for 'shasum -c SHA256SUMS-<label>.txt' verification. - Both steps gated by 'github.event_name == push && refs/tags/v*' so workflow_dispatch dry-runs do not attempt to attach to a non-existent release (per CONTEXT.md L70 + RESEARCH Pitfall #7 deferral of any aggregate cross-leg SHA256SUMS job). - fail_on_unmatched_files: true to surface path-resolution errors loudly. * docs(00-gates): document RC cadence + regression-fixture check in PR template (GATE-04) * docs(setup): add HF token persistence guide for macOS/Windows/Linux (DOCS-05) Covers two persistent paths: - Method A — canonical ~/.cache/huggingface/token via huggingface-cli login - Method B — shell env var (~/.zshrc / ~/.bashrc / Windows User scope) Documents the v0.2.7 "session only" in-app behavior + notes that Phase 1 AUTH-03 will make in-app pastes write to the canonical file. Bundled with Phase 0 PR per user request. Strictly DOCS-05 scope — zero code changes, no engine touches. * spec(auth): redesign HF token resolution as 3-source cascade with fallback (AUTH-01..06) Replaces the env_store.py file-based design with a SQLite-backed app store + cascade resolver that checks app → env var → ~/.cache/huggingface/token in priority order, with automatic fallback to next source on HTTP 401. User-explicit design decision: - App-stored token (SQLite settings table, AES-GCM encrypted) wins - Env var ($HF_TOKEN) second - Global huggingface-cli login file third - All three sources visible in Settings → API Keys with "Active" badge - Save action populates BOTH app store AND canonical HF file (defense in depth) New requirement: - AUTH-06 — on 401, auto-retry next source in cascade before erroring Also: traceability count corrected (62 → 74 — undercount at planning + INST-12 + AUTH-06 added post-planning). All 74 v1 reqs mapped. * fix(auth): backend recognizes HF token from canonical file, not just env var Two call sites were only checking $HF_TOKEN env var, missing the canonical ~/.cache/huggingface/token file written by `huggingface-cli login` (or the app's future Save action): - system.py `/system/info` `has_hf_token` flag — UI showed "No HF token" even when `huggingface-cli login` had populated the file. - model_manager.get_diarization_pipeline — pyannote diarization silently returned None when only the canonical file was set. This is the bug behind issue #35 (speaker diarization setup failure). Both fixes use the same pattern: env var > huggingface_hub.get_token() (which reads the canonical file). Adds a local _has_hf_token() helper to system.py with a comment marking it as prelude to the AUTH-01..06 cascade (Phase 1 token_resolver.py will layer SQLite app-store on top). Closes #35 sub-issue (canonical token invisible to diarization). Cross-cuts AUTH-02 + AUTH-06 design for Phase 1. * feat(dictation): make pill-widget mode reachable from GUI + scripts (INST-13) The dictation widget infrastructure shipped in PR #40 but was only reachable via the undocumented --pill CLI flag. Adds three discovery paths: 1. Tray menu: "Switch to Dictation Widget" (studio mode) — saves launch_as_widget=true to config, relaunches with --pill, exits current. Mirrors the existing "Open Studio" path in pill-mode tray. 2. Persistent config: AppConfig.launch_as_widget (bool, default false). Read at startup via load_config_pre_app() (uses dirs-next, no AppHandle required). CLI --pill still takes precedence when explicitly passed. 3. Tauri commands: get_launch_as_widget / set_launch_as_widget for the Phase 2 Settings UI to bind a checkbox to. 4. Scripts: bun desktop-prod:pill / desktop-prod:run:pill — forward --pill to the bundled app launch. macOS uses `open -n --args` to spawn fresh instance with the flag. Closes the GUI half of INST-13. Phase 2 closes the Settings UI half. * fix(dictation): show widget unconditionally on pill-mode launch + visible Suspense fallback Before: pill mode set up correctly but the widget window stayed hidden until ⌘⇧Space was pressed. New users saw absolutely nothing on launch (no main window, no dock icon, hidden widget) and assumed the app failed. If global-shortcut Accessibility permission wasn't granted, they had no path to discover the widget at all. Two changes: 1. lib.rs: in pill_mode_setup, explicitly show + position + focus the widget window after hiding main. With per-call error logging so we can diagnose failures (and a clear error log if widget window wasn't created at all — points at tauri.conf.json regression). 2. main-app.jsx: Suspense fallback was `null`, which combined with widget's transparent+decorations:false config made any lazy-import delay or failure invisible. Now renders a dark pill saying "Loading dictation…" so even if CaptureWidget lazy-import stalls, the user sees the window exists. Studio mode behavior unchanged — widget stays hidden until hotkey or tray click triggers it (existing show() call in the shortcut/ menu handlers is preserved). * fix(dictation): create widget window programmatically; Tauri 2 silently dropped config-array creation Root cause: declaring the widget window in tauri.conf.json's app.windows[] silently failed in Tauri 2 — get_webview_window("widget") returned None even though the config was syntactically valid. Probable culprit was the transparent + decorations:false + visible:false combo, but Tauri offered no error message either at startup or via webview_windows() enumeration. Diagnosed by adding webview_windows() enumeration logging at setup start (only ["main"] ever appeared) and a programmatic WebviewWindowBuilder fallback that surfaces real Result errors. Fix: - tauri.conf.json: widget entry now has `create: false` to make the config-vs-programmatic handoff explicit. - lib.rs setup(): call WebviewWindowBuilder::new(app, "widget", ...).build() with the exact same surface attributes the config used to declare. - capabilities/default.json: include "widget" in windows array so the new window inherits the same Tauri permissions as main. - tauri.conf.json: remove the invalid `"url": "/?window=widget"` field — WebviewUrl::App takes a path only, query strings aren't supported. Both windows now load index.html. - main-app.jsx: replace URL-query-based widget detection with getCurrentWindow().label === 'widget' via @tauri-apps/api/window. This is the Tauri 2-recommended pattern for multi-window apps and works regardless of URL routing. Closes the immediate UX bug behind the dictation widget being invisible. Builds cleanly + manually verified: pill widget visible on screen at top-center after `bun desktop-prod:pill`. --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> fix: pin setuptools>=75.0, add Linux/Russia troubleshooting (#62) - Pin setuptools>=75.0 in pyproject.toml to ensure pkg_resources is available on Python 3.12+ (fixes #58) - Add Linux white-screen workaround for Fedora 44 / Ubuntu 24.04 (#56) - Add firewall/Russia install guide for uv venv failures (#60, #57) Closes #58 fix: lazy-load ASR during desktop startup * fix: lazy-load ASR during desktop startup * fix: handle ASR preload edge cases * fix: unload ASR backend on failure feat: import .srt subtitles to bypass Whisper (closes #52) Closes #52. Users who already have correct, pre-synced subtitles can now skip ASR entirely — they upload a video as normal and then hit "Import .srt" instead of "Upload & Transcribe". The .srt cues populate the dub segment list directly, so the rest of the pipeline (translate, dub, export) just works. Backend - services/srt_parser.py: lenient SubRip parser. Tolerates BOM, CRLF, missing index numbers, dot-vs-comma ms separator, and overlap (shifts the later cue's start to the earlier's end rather than dropping). Skips cues with non-positive duration or empty bodies; reports counts so the UI can warn. - dub_core.py: new POST /dub/import-srt/{job_id} accepts the .srt file, parses it, clamps cues that run past the source media's duration, and replaces job["segments"]. Tries UTF-8 with BOM first, falls back to latin-1 for legacy Windows subs. Frontend - api/dub.ts: dubImportSrt helper with a typed response. - hooks/useDubWorkflow.js: handleDubImportSrt — sets segments, flips dubStep to 'editing', shows a toast with per-bucket counts (imported / skipped / overlap-shifted / clamped) so the user sees what happened. - pages/DubTab.jsx: "Import .srt" button next to "Upload & Transcribe" once a job exists, plus a smaller "Import .srt instead" affordance in the transcription-failure banner — the exact recovery path the reporter asked for. Tests - tests/test_srt_parser.py: 12 cases covering well-formed input, multi-line cues, dot-as-separator, BOM, CRLF, malformed cues, empty bodies, overlap shift, overlap-becomes-zero-drop, missing indices, empty input, and segment shape (sequential ids, speaker filler). pytest is now 226 passed (was 214); vitest unchanged at 11. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Post-refactor cleanup: wire fingerprints, drop dead code, scope pytest (#50) * chore: post-refactor cleanup — wire fingerprints, drop dead code, scope pytest Follow-up to PR #49. Fixes residual issues from the App.jsx hooks split and tightens repo hygiene so a bare `pytest` doesn't foot-gun. Real bug - frontend/src/hooks/useDubWorkflow.js: setLastGenFingerprints lives in useSegmentEditing, not on the store. The previous code called useAppStore.getState().setLastGenFingerprints?.(...) — the optional chain swallowed the missing method, so the "N segments changed" badge never updated after a fresh generate until a project save+reopen. Thread setLastGenFingerprints in from App.jsx; useSegmentEditing() now runs before useDubWorkflow() to make the setter available. Dead code from the refactor - frontend/src/App.jsx: drop unused `showAllProjects` useState and `pushUndo` from the useSegmentEditing destructure. - frontend/src/hooks/useDubWorkflow.js: drop 5 unused selectors (preserveBg, defaultTrack, exportTracks, dualSubs, burnSubs) — the dub-download logic that needs these lives in App.jsx, not the hook. Repo hygiene - backend/api/routers/setup.py.bak: delete 38 KB tracked-in-git backup. The setup/ subpackage replacement has been in place for a while. - pyproject.toml: add [tool.pytest.ini_options] with testpaths + norecursedirs. Previously a bare `pytest` would INTERNALERROR walking into research/ (1.2 GB of vendored upstream projects with their own test_*.py files that call sys.exit at module level). - .github/workflows/ci.yml: run backend/tests/ as a second pytest invocation. The 23 tests there stub core.config in sys.modules to avoid the heavy main app import chain — that pollutes import state for other tests, so they need their own session. Previously these tests existed in the repo but never ran on CI. Net effect on lint: 60 → 52 problems (-8) from dead-code removal. Test counts unchanged: pytest 214 + 23, vitest 11. * chore: log silent catch failures that mask real bugs CodeRabbit nitpick on #50: empty catch on the incremental-plan fallback swallows errors. Extending the fix to the catches in this area that have the same problem (a real failure would be invisible) while leaving the genuinely non-actionable cleanup catches alone (EventSource.close(), localStorage.setItem, fire-and-forget UI promises). Logged: - useDubWorkflow.js:97 — transcribe SSE message handler - useDubWorkflow.js:347 — incremental-plan fallback (the CR finding) - useDubWorkflow.js:352 — dub generate SSE event dispatch - App.jsx:552 — exportRecord on Tauri save path - App.jsx:580 — exportRecord on browser download path Left silent (cleanup / non-actionable): - useDubWorkflow.js:68, 112 — evt.close() in SSE teardown - useDubWorkflow.js:102 — SSE error-event payload parse fallback - App.jsx:124 — localStorage.setItem (quota / privacy mode) - App.jsx:789, 901 — fire-and-forget UI promise tails --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Stability pass: DB leaks, App.jsx hooks refactor, desktop bootstrap (#49) * fix: eliminate DB connection leaks, race conditions, and deprecated asyncio API ## DB Connection Leaks (P0) - Convert 38 raw get_db() calls to db_conn() context manager across 14 router files - Connections are now guaranteed to close even when exceptions are raised - profiles.py create_profile: clean up orphaned audio file if DB insert fails - profiles.py lock_profile: consolidate 3 separate conn.close() error paths ## Race Condition (P1) - Add _dub_jobs_lock (threading.Lock) to protect _dub_jobs dict in dub_pipeline.py - get_job/put_job now thread-safe for concurrent dub sessions ## asyncio Deprecation (P2) - Replace 23 asyncio.get_event_loop() calls with asyncio.get_running_loop() - Prevents DeprecationWarning on Python 3.12+ and future breakage on 3.14 ## Quick Fixes - gallery.py preview_voice: remove filesystem path from error response (P2) - dub_pipeline.py parse_vtt_segments: remove redundant `import re` inside loop (P3) - gallery.py _init_gallery_db: use db_conn() context manager (P2) * refactor: extract hooks, centralize isTauri, add pytest-cov ## Frontend - Extract useTTS hook (150 LOC) — TTS generation, streaming, audio ingestion - Extract useProfiles hook (219 LOC) — voice profile CRUD, lock/unlock, preview - Centralize isTauri detection: dialog.js, VoiceGallery.jsx, Settings.jsx now import from utils/media.js instead of 4 different detection patterns ## Backend - Add pytest-cov to dev dependencies - Baseline coverage: 39% across backend/ (214 tests pass) - Add .coverage to .gitignore * feat: add Vitest + checkJs, extract useDubWorkflow + useAppData hooks ## Frontend Testing (new) - Set up Vitest with jsdom environment + @testing-library/react - 11 tests: utils (isTauri, formatTime, constants) + Zustand store (mode, text, dubStep, pill) - Scripts: 'test' (vitest run), 'test:watch' (vitest), 'test:legacy' (node runner) ## App.jsx Decomposition (continued) - Extract useDubWorkflow hook (387 LOC) — upload, ingest, transcribe SSE, translate, generate SSE, abort, stop, cleanup - Extract useAppData hook (181 LOC) — data loading, localStorage persistence, WebSocket real-time updates, model-status pill management ## TypeScript checkJs - Enable checkJs: true in tsconfig.json for IDE-level type checking - 947 existing errors (informational, not blocking builds) - noImplicitAny remains false to avoid blocking * ci: add Vitest step, fix useProfiles duplicate state ## CI - Add 'Run Vitest (frontend)' step — runs 11 unit tests - Override --checkJs false in CI typecheck to avoid 947 pre-existing errors - Rename legacy test step for clarity ## Hooks - Fix useProfiles to accept loadProfiles from parent (useAppData) instead of managing its own duplicate profiles array * refactor: wire hooks into App.jsx — 2067 → 1129 LOC (-45%) App.jsx now delegates to extracted hooks instead of inline logic: - useAppData: data loading, localStorage, WebSocket, model pill - useProfiles: voice profile CRUD, lock/unlock, preview - useTTS: generation, streaming, audio ingestion - useDubWorkflow: upload, transcribe SSE, translate, generate SSE 988 lines removed. All handler logic lives in focused, independently testable hooks. Store selectors and render JSX stay in App.jsx as the shell. Verified: vite build clean, 11 frontend + 214 backend tests pass. * feat: show real-time percentage on model loading pill Backend: register hf_progress listener during _load_model_sync() so download/weight-loading tqdm events update _loading_detail with a progress percentage (0-99%). get_model_status() now includes a 'progress' field that the frontend polls. Frontend: useAppData reads msQuery.data.progress and calls setPillProgress() — the FloatingPill already renders the percentage text and progress bar width from this value. * fix: prevent FileNotFoundError in desktop bundle during model init transformers >=4.52 calls _can_set_experts_implementation() and _can_set_attn_implementation() during PreTrainedModel.__init__, which open the class source file via open(class_file). In a Tauri desktop bundle, module.__file__ points to a path that doesn't exist on disk, causing: FileNotFoundError: .../omnivoice/models/omnivoice.py Override both classmethods on OmniVoice to return static values without filesystem access. OmniVoice doesn't use MoE experts (return False), but does support flex/flash attn (return True). * fix: sync source dirs on every bootstrap, not just first run The Tauri bootstrap previously only copied omnivoice/ and backend/ to Application Support on the first run. Subsequent app updates kept using stale source files, preventing bug fixes from landing. Now ensure_venv_ready() always syncs both directories from the bundle resources before returning, even when the venv is healthy. This fixes the FileNotFoundError crash where the old omnivoice.py lacked the _can_set_experts_implementation override. * ui: premium setup wizard polish - Primary button: solid gradient fill with hover glow + lift + press - Stepper nav: connected pills with glow ring on active step - Welcome cards: glassmorphism with stagger-in animations, lucide icons, left-border accent strip, hover translate - Preflight panel: colored icon pill backgrounds, stagger-slide entrance - Step transitions: fade+slide animation via keyed wrapper - Footnote: shortened paths (~/ notation), Reveal in Finder button - Recommendation banner: gradient background with accent glow - Compact spacing throughout for denser, professional layout * fix: kill zombie backend on clean+retry bootstrap When clean_and_retry_bootstrap removes the project dir, any old uvicorn process still running from the deleted paths remains alive on port 3900. The subsequent retry_bootstrap sees the port is healthy and attaches to the zombie instead of re-bootstrapping. Now explicitly kill any process on the backend port after cleaning, before calling retry_bootstrap. * feat: integrate speaker clones into dubbing interface, sanitize system environment variables for subprocesses, and improve FFMPEG binary path resolution. * fix: restore docker compose default + drop dead setSeed call - deploy/docker-compose.yml: remove profiles: ["cpu"] from the default service so `docker compose up` matches the comment on line 5. With the profile present, no service auto-started. - frontend/src/App.jsx: drop the setSeed call in restoreHistory. The selector was never reintroduced after the App.jsx hooks split, and there is no seed state in the store — seeds are generated fresh per call in useTTS and only read from history items for display. * fix: address CodeRabbit review — async detection, dub stream, bootstrap fail-fast - backend/services/tts_backend.py: invert async-context detection in _ensure_loaded. The previous code unconditionally caught its own diagnostic RuntimeError and then called asyncio.run() inside a running loop, masking the intended error message. - frontend/src/hooks/useDubWorkflow.js: require a terminal `done` event before reporting dub success. Without this, a dropped stream after partial progress would flip the UI to `done`, refresh history, and play the completion ping as if generation finished. - frontend/src/hooks/useDubWorkflow.js: restore the previous step when tasksCancel() fails. The UI was getting stuck in `stopping` forever on cancel errors. - frontend/src-tauri/src/bootstrap.rs: fail-fast when source sync fails after the existing directory has already been removed. The previous warn-and-continue path could leave the install with no backend/ or omnivoice/ sources and defer the failure to backend startup with a cryptic error. - backend/api/routers/generation.py: add `from e` to the ValueError → HTTPException re-raise (Ruff B904). * fix: preserve % suffix in TTS generation timer The 100ms timer in useTTS was rewriting generationTime to a plain elapsed-seconds string, which immediately wiped the "(xx%)" download suffix written on the next iteration of the response-body loop. The real-time percentage was flickering on/off as a result. Read the previous value inside the setter and reattach any existing percent suffix. --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> fix: resolve open issues — Discord link, Docker crash, IndexTTS compat, engine tooltips (#47) * fix: resolve 7 open GitHub issues (#46 #43 #42 #45 #44 #35 #4) #46 — Discord invite expired: - Replace discord.gg/aRRdVj3de7 with discord.gg/bzQavDfVV9 across README, CONTRIBUTING, EnterprisePage, LogsFooter #43 — Docker image crashes with 'No module named core': - Add PYTHONPATH=/app/backend to Dockerfile so bare imports resolve - Add sys.path safety net in backend/main.py (belt-and-suspenders) #42 — IndexTTS not compatible (transformers version conflict): - Catch ImportError + generic Exception in IndexTTS2Backend.is_available() - Return actionable error explaining transformers<5 vs >=5.3 conflict - Update install docs: recommend 'uv pip install -e .' not 'uv sync --all-extras' #45 — Improve pip install tooltips: - Add install_hint field to list_backends() API response - Show hints as tooltips on engine rows in Settings > Engines - Add models-row__hint CSS with hover reveal #44, #35, #4 — Response-only issues (need GitHub comments) * test: add 20 unit tests for issue batch fixes (#46 #43 #42 #45) Coverage: - Discord link sweep: parametrized per-file + repo-wide glob - Docker fix: sys.path insertion in main.py, PYTHONPATH in Dockerfile - IndexTTS: is_available() tuple shape, conflict detection mock, docstring - install_hint: presence, non-empty, registry coverage, backward compat - Regression: minimum engine count, all backends return (bool, str) * fix: address CodeRabbit review — voxcpm package name, bootstrap test isolation - Fix _INSTALL_HINTS: 'pip install voxcpm2' → 'pip install voxcpm' (correct PyPI name) - Replace test_core_config_importable with test_main_py_bootstrap_adds_backend_dir that validates main.py's preamble directly instead of relying on conftest.py - Add test_voxcpm_install_hint_uses_correct_package_name regression guard * fix: align install hints with backend reality (MOSS not on PyPI, VoxCPM supports CPU/MPS) - MOSS-TTS-Nano: not on PyPI, must install from GitHub repo - VoxCPM2: CPU/MPS supported, CUDA recommended (not required) feat: Scalar API docs, community health files, Quickstart cards (#41) * feat: Scalar API docs, community health files, Quickstart cards, GHCR Docker Backend: - Replace Swagger UI with Scalar at /docs (scalar-fastapi) - Add OpenAI-compatible /v1/audio endpoints (openai_compat router) - Add TTS streaming endpoint (tts_stream router) - Add voice marketplace router (marketplace) - Update TTS backend registry Frontend: - Refine CaptureWidget, WaveformTimeline, App layout - CSS polish and index.css updates Community health: - SECURITY.md — vulnerability reporting policy - CODE_OF_CONDUCT.md — Contributor Covenant v2.1 - .github/FUNDING.yml — GitHub Sponsors - .github/ISSUE_TEMPLATE/ — bug report + feature request - .github/pull_request_template.md — PR checklist README: - Quickstart redesigned as 3-column progressive cards - Docker section updated with GHCR pull instructions - API Docs row added to service table Infra: - scalar-fastapi added to pyproject.toml + uv.lock - research/ added to .gitignore * refactor: clean up documentation and logging while enhancing desktop packaging dependencies and capture UI performance. * fix: address CodeRabbit review — streaming, escaping, thresholds Backend: - marketplace: stream zip entries via ZipFile.open()/copyfileobj, add 100MB upload cap, fix raise-from exception chaining (OOM prevention) - openai_compat: _encode_audio returns actual file ext so Content-Disposition matches real format; forward non-profile voices when DB row not found - tts_stream: send 'start' frame after generation so sample_rate is real; forward non-profile voices on DB miss - capture_ws: split MIN_BUFFER_BYTES into separate partial/final thresholds so short utterances (<2s) still get transcribed Frontend (Tauri): - lib.rs: tray 'dictate' now toggles start/stop based on widget visibility - commands.rs: XML-escape exe path in LaunchAgent plist, shell-quote in .desktop Exec line to prevent injection from special-char paths - CaptureWidget.css: fix Stylelint violations (empty lines, font-family quotes) Merge feat/frameless-dictation-widget: v0.2.7 — frameless dictation widget, GHCR Docker # Conflicts: # README.md feat: add GHCR Docker workflow, update README with container registry instructions - New .github/workflows/docker.yml publishes images to ghcr.io on tag push - README Docker section now leads with 'docker pull' from GHCR - docker-compose.yml defaults to GHCR image with build-from-source fallback - Dockerfile: copy README.md for hatchling metadata resolution feat: implement frameless OS-level floating dictation widget (#40) * feat: implement frameless OS-level floating dictation widget - Refactor CaptureButton into standalone CaptureWidget - Add secondary transparent Tauri window configuration - Map global hotkey to show/hide widget instead of focusing main app - Implement auto-hide post-paste - Add social preview image * docs: up the game with enhanced README - Use the high-quality social preview image as the hero image - Bump download release links to v0.2.7 - Highlight the new Frameless Dictation Widget feature * docs: complete README overhaul for maximum virality - Add Highlights section with 2-column feature grid - Move Quickstart to top with one-command install - Collapse technical details into expandable sections - Add 'Up Next' roadmap with concrete upcoming features - Add star call-to-action banner - Tighten navigation links and section hierarchy * docs: add beta warning banner * docs: add star request to beta banner * docs: rewrite README with cognitive hooks, remove redundant CTAs - Remove 2 premature star asks (beta banner + highlights) - Rewrite highlights with loss-aversion framing - Keep single earned CTA at the very bottom - Use action-oriented headings that describe outcomes * docs: rename section to 'Why OmniVoice Studio?' * docs: rename 'What you get' to 'Features' * docs: concise scannable features, remove duplicate section - Each feature is one punchy emoji-led line - No verbose paragraphs, no redundant collapsibles - Removed duplicate Features section from merge * docs: 3-column feature card grid for visual impact Replaces flat bullet list with 4x3 HTML table grid. Each feature gets its own visual cell with emoji header, bold keywords, and 2-line description. Pops on dark mode. * docs: fix feature grid vertical alignment * chore: bump version to 0.2.7, add changelog entry * fix: apply CodeRabbit auto-fixes Fixed 1 file(s) based on 1 unresolved review comment. Co-authored-by: CodeRabbit <noreply@coderabbit.ai> --------- Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Co-authored-by: CodeRabbit <noreply@coderabbit.ai> chore: bump version to 0.2.7, add changelog entry docs: fix feature grid vertical alignment docs: 3-column feature card grid for visual impact Replaces flat bullet list with 4x3 HTML table grid. Each feature gets its own visual cell with emoji header, bold keywords, and 2-line description. Pops on dark mode. docs: concise scannable features, remove duplicate section - Each feature is one punchy emoji-led line - No verbose paragraphs, no redundant collapsibles - Removed duplicate Features section from merge docs: rename 'What you get' to 'Features' docs: rename section to 'Why OmniVoice Studio?' docs: rewrite README with cognitive hooks, remove redundant CTAs - Remove 2 premature star asks (beta banner + highlights) - Rewrite highlights with loss-aversion framing - Keep single earned CTA at the very bottom - Use action-oriented headings that describe outcomes docs: add star request to beta banner docs: add beta warning banner docs: complete README overhaul for maximum virality - Add Highlights section with 2-column feature grid - Move Quickstart to top with one-command install - Collapse technical details into expandable sections - Add 'Up Next' roadmap with concrete upcoming features - Add star call-to-action banner - Tighten navigation links and section hierarchy docs: up the game with enhanced README - Use the high-quality social preview image as the hero image - Bump download release links to v0.2.7 - Highlight the new Frameless Dictation Widget feature feat: implement frameless OS-level floating dictation widget - Refactor CaptureButton into standalone CaptureWidget - Add secondary transparent Tauri window configuration - Map global hotkey to show/hide widget instead of focusing main app - Implement auto-hide post-paste - Add social preview image chore: bump version to 0.2.7 feat: add CosyVoice 3 TTS backend, engine platform matrix, CONTRIBUTING.md (#39) * feat: add CosyVoice 3 TTS backend, engine platform matrix, CONTRIBUTING.md - Add CosyVoiceBackend adapter to tts_backend.py (9 langs + 18 dialects, zero-shot voice cloning, instruct mode, Apache-2.0) - Fix VoxCPM2Backend.is_available() — remove incorrect hard CUDA gate; VoxCPM2 supports MPS (Apple Silicon) and CPU fallback - Add unified TTS Engines table to README with features + platform compat - Update FAQ to reflect current 6-engine Plugin SDK (was 'not yet') - Add CONTRIBUTING.md with dev setup, PR workflow, TTS plugin guide, code style conventions, and testing commands - Move Contributing section above FAQ in README * fix(pr): address CodeRabbit review feedback - README: Update 'Plugin SDK' to 'built-in backend registry' for clarity - tts_backend.py: Preserve full language codes for CosyVoice cross-lingual lookup before fallback refactor: codebase cleanup & root folder reorganization (#38) refactor: codebase cleanup & root folder reorganization feat: ASR model preload at startup — eliminate 25s first-dictation cold start feat: ASR model preload at startup — eliminate 25s first-dictation cold start Fix transcription stream drops, IndexError, BrokenPipeError, FK constraint, and Tauri CSP Fix transcription stream drops and Tauri CSP feat: enhance ASR performance and reliability with binary bundling, model warmup, sub-stage progress tracking, and optimized polling. Fix transcription stream drops, IndexError, and Tauri CSP feat: enhance ASR performance and reliability with binary bundling, model warmup, sub-stage progress tracking, and optimized polling. refactor: bundle uv binary per-platform as Tauri sidecar and remove redundant ffmpeg bootstrap download fix(0.2.6): WS first-chunk drop, mic permissions, release-body from CHANGELOG WS dictation pipeline was producing exit-183 from ffmpeg on every partial because MediaRecorder.start(250) ran before the WebSocket handshake finished — the first chunk (WebM EBML header) was queued only into chunksRef and never pushed to the WS, so concatenated chunks 1..N decoded as malformed WebM. Fix: - Construct the WebSocket BEFORE starting the recorder so wsRef is set when the first ondataavailable fires. - ondataavailable now queues every chunk through wsPendingRef when the socket isn't OPEN; ws.onopen drains the queue. - ws.onmessage('error'): fire HTTP fallback immediately instead of waiting the full fallback-timeout window. - ws.onclose without prior `final`: same — kick the HTTP path now if the recorder has already stopped. Mic permissions: - New frontend/src-tauri/Info.plist with NSMicrophoneUsageDescription + NSCameraUsageDescription. Tauri 2 auto-merges the file at bundle time (path is the same dir as tauri.conf.json — schema documents this fallback). Without it, getUserMedia silently fails on macOS 10.14+ TCC. - Mic-denial toast now includes platform-specific recovery (Settings paths for macOS/Windows, audio-group check for Linux). CI / release notes: - release.yml extracts the matching `## [X.Y.Z]` section from CHANGELOG.md and feeds it into tauri-action's releaseBody, so v0.2.6+ tag pushes produce real release notes instead of the placeholder "Auto-generated release. See commit log for changes." chore(license): switch Studio to FSL-1.1-ALv2; commercial pricing TBD - LICENSE replaced with the canonical Functional Source License, Version 1.1, ALv2 Future License (auto-converts to Apache 2.0 two years after each release). - Scope clarified: Studio (frontend + backend + tauri shell + scripts) is FSL. Bundled `omnivoice/` Python TTS model package by Han Zhu stays Apache-2.0 — not relicensed here. - README license section + license badge updated to reflect FSL + future-Apache; replaced "30-day free evaluation" copy with the FSL Permitted Purposes wording. - Enterprise page: drop hard-coded pricing tiers (Startup/Business/ Enterprise) since pricing is still being finalized. Replaced with a "Pricing tiers coming soon — request a quote" panel. FAQ rewritten around FSL semantics (internal use is permitted, source converts to Apache 2.0 in 2yr). Drop now-unused TIERS const + TierCard component + .ent-tier* CSS. - CHANGELOG entry under 0.2.6 records the relicense. feat(0.2.6): tray-aware shell, hotkey customization, WS dictation dedupe Tray + lifecycle: - tauri-plugin-single-instance — second launch focuses existing window instead of racing for port 3900. - Window close hides instead of destroying; backend shutdown moved to RunEvent::ExitRequested so only the tray "Quit" item (or Cmd+Q on macOS) actually exits. - Tray icon flips to red-dot variant during dictation recording. Hotkey customization: - Settings → Capture tab. Records any modifier+key combo, persists to app config, re-registers on launch. - set_dictation_shortcut rolls back to the previous binding on register failure so a bad combo never leaves the user with no shortcut. Dictation latency / correctness: - WS-final treated as source of truth; HTTP POST /transcribe runs only as fallback (WS error / timeout / no-WS path). Audio transcribed once instead of twice. Server accepts an "EOF" text frame (or empty binary frame) so the socket stays open for `final` to be delivered before the client closes. - MediaRecorder chunks queued during the WS handshake are drained in ws.onopen — the server's final transcript no longer drops the first ~250 ms of audio. - Fallback timeout scales with recording length (max(15s, recordedMs+10s)) so long-form dictations don't trip duplicate transcription. Donate page: - Drop Patreon, Bitcoin / Ethereum / Solana cards. Drop qrcode.react. - Move "Commercial License" CTA from page bottom to top-right header bar. Docker hygiene: - docker-compose binds 127.0.0.1 by default. README documents the LAN exposure trade-off + recommends a reverse proxy with auth. CI: - New cross-platform `tauri-cross-platform` job runs `cargo check` against the Tauri shell on macOS / Windows / Linux per PR. Catches platform cfg-gate regressions without paying the full ~15min/platform bundle cost (full bundling stays in release.yml on tag push). Tests: - tests/test_capture_ws.py (3 cases) covers EOF text-frame, empty-binary EOF, and legacy disconnect-finalize paths. Includes the user's previously-staged 0.2.5 polish: cross-platform desktop-prod.sh, Dockerfile base-image fix, bun.lock churn. merge fix/preflight-and-progress into main fix: resolving heartbeat, fmtBytes(0), smarter build error handling - Backend emits 'resolving' heartbeat every 2s during HF metadata resolution so UI shows 'Resolving repo metadata...' instead of being stuck on 'Connecting to HuggingFace…' indefinitely - fmtBytes(0) now returns '0 B' instead of '—' - desktop-prod.sh only tolerates signing errors, surfaces real build failures with exit code - Handle install_retry phase in frontend with attempt number fix: desktop-prod now builds fresh .app bundle, not stale cached one The --no-bundle flag caused the script to build only the raw binary while launching the OLD stale .app bundle from a previous build. Now builds the full bundle (tolerating the signing error which is non-fatal) and deletes the old bundle first to prevent stale code. fix: disk space check walks up to existing parent when cache dir wiped shutil.disk_usage() throws on non-existent paths, causing the preflight to report 0.0 GB free after a fresh wipe. Now resolves up to the nearest existing ancestor directory so it probes the actual volume free space correctly. fix: download progress shows realtime speed/ETA at every stage - 'Connecting to HuggingFace…' when no file events yet - 'Resolving N files…' when tqdm init fired but total unknown - Speed shows immediately from backend tqdm rate (no 2s warmup) - '0 B / …' instead of '— / ?' for early progress - 1s tick timer forces re-render so speed/ETA updates smoothly - ETA shortened to ~3m instead of ~3m left for compactness fix: desktop-prod now builds fresh .app bundle, not stale cached one The --no-bundle flag caused the script to build only the raw binary while launching the OLD stale .app bundle from a previous build. Now builds the full bundle (tolerating the signing error which is non-fatal) and deletes the old bundle first to prevent stale code. fix: disk space check walks up to existing parent when cache dir wiped shutil.disk_usage() throws on non-existent paths, causing the preflight to report 0.0 GB free after a fresh wipe. Now resolves up to the nearest existing ancestor directory so it probes the actual volume free space correctly. fix: download progress shows realtime speed/ETA at every stage - 'Connecting to HuggingFace…' when no file events yet - 'Resolving N files…' when tqdm init fired but total unknown - Speed shows immediately from backend tqdm rate (no 2s warmup) - '0 B / …' instead of '— / ?' for early progress - 1s tick timer forces re-render so speed/ETA updates smoothly - ETA shortened to ~3m instead of ~3m left for compactness feat: realtime download speed, retry buttons, recheck top-right - tqdm hook emits progress every 0.3s with backend rate (bytes/sec) - Frontend uses backend rate for instant speed display, no 2s warmup - Shows 'Connecting to HuggingFace…' during connect phase - Shows 'measuring speed…' before rate is available - Re-check button moved to top-right header in system preflight - Retry + Clean & Retry buttons on failed splash screen - Smart error hints (missing README, network timeout, port in use) - README.md + omnivoice/ source package copied during bootstrap - desktop-prod.sh wipes HF cache + all app data for fresh testing feat: region selector (Global/China) on splash + settings (#33) - Persistent config.json in app_data stores region preference - China region auto-sets HF_ENDPOINT=https://hf-mirror.com - Segmented toggle on bootstrap splash (🌐 Global / 🇨🇳 China) - get_region / set_region Tauri commands for frontend access - System HF_ENDPOINT env var still takes priority over config Closes #33 fix: pass HF_ENDPOINT to backend for Chinese mirror support (#33) Users in China can now set HF_ENDPOINT=https://hf-mirror.com as a system env var before launching OmniVoice Studio. The Tauri shell passes it through to the Python backend. fix: buffer bootstrap logs + backfill on webview mount Root cause of 'No log output captured': bootstrap events fire before the webview loads, so the React listener misses all of them. Fix: - Add log buffer (Vec<LogPayload>) to BootstrapState on Rust side - emit_log() writes to both the event stream AND the buffer - New 'get_bootstrap_logs' Tauri command returns all buffered lines - Frontend calls get_bootstrap_logs on mount to backfill missed logs - Deduplication prevents double-showing lines caught by both paths - Also pipe backend stdout (not just stderr) to splash panel fix: pipe backend stdout to splash + complete log visibility - Pipe both stdout AND stderr from backend process to splash logs (previously stdout went to file/null, so 'No module named X' was invisible to users) - All bootstrap stages stream logs to the splash panel - Log panel always open by default with copy button fix: self-healing venv + version on splash + logs always visible Critical Windows fix: - Verify uvicorn is importable before trusting cached venv - If venv exists but deps are missing, auto-repair via uv sync - Fixes: users stuck in 'No module named uvicorn' loop Splash improvements: - Show version (v0.2.5) next to title on loading screen - Logs panel open by default — users see live output immediately - Copy button inline with toggle for easy bug reporting - __APP_VERSION__ injected via Vite define from package.json ui: show bootstrap logs by default, add copy button inline Logs are now always visible during the splash screen so users can see what's happening (Python imports, model loading, etc). Copy button sits inline next to the toggle and line count. Log panel height increased to 280px for more context. fix(windows): fallback to uv sync without --frozen when lockfile missing Root cause: uv.lock wasn't bundled in the Windows MSI, so 'uv sync --frozen' silently produced a venv without uvicorn. Changes: - If uv.lock is missing after copy, run 'uv sync' without --frozen so uv resolves deps from pyproject.toml (slower but always works) - Log a warning instead of silently ignoring lockfile copy failures - Auto-expand bootstrap logs on failure so users see full context - Add '📋 Copy logs' button for easy bug reporting - user-select: text on log panel so text is selectable chore: bump version to v0.2.5 feat: dictation maturity + batch TTS pipeline + tests (#32) Global Hotkey: - Register ⌘+⇧+Space system-wide via tauri-plugin-global-shortcut - Shows/focuses window and emits tray-dictate event from any app Auto-Paste: - enigo crate simulates ⌘V/Ctrl+V after transcription - Text auto-pastes into whatever app was active before dictation Streaming ASR: - WebSocket endpoint /ws/transcribe for live partial transcription - 2s buffer interval, configurable via OMNIVOICE_STREAM_INTERVAL - CaptureButton streams audio chunks, shows italic partial text - Falls back to HTTP POST if WebSocket unavailable Batch TTS Pipeline: - Replace stub worker with full pipeline: extract → transcribe → translate → generate → mix → export - Per-job progress tracking (stage, percent, current_lang, segment) - GoogleTranslator integration via deep_translator - Download endpoint GET /batch/download/{id}/{lang} - BatchQueue UI rewritten: progress bars, cancel/delete, downloads - Type-safe API client (api/batch.ts) Tests: - 23 tests for batch endpoints + streaming ASR helpers - Lightweight fixtures that stub GPU deps UX (earlier sessions): - Dual-mode ASR (Turbo MLX + WhisperX Accurate) - Enhanced download progress (speed, ETA, bytes) - Status bar black flash fix - Cold-start model preloading - Full accessibility audit (ARIA, focus-visible) - Compact UI layout improvements - README updated with new features feat: flush dropdown, credentials tab, whisper model selector, reactive transcriptions Flush Dropdown: - Flush button now opens a dropdown showing all loaded models - Each model shows device, VRAM usage, and individual Unload button - Backend endpoints: GET /model/loaded, POST /model/unload/{id} - Bottom actions: Flush caches, Unload all + flush Credentials Tab: - New Settings > Credentials tab with HF_TOKEN and TRANSLATE_API_KEY - Session-scoped via POST /system/set-env (no ElevenLabs — we ARE the alternative) - Shows 'Set' / 'Not set' badge for HF token Notification Panel: - Moved from header dropdown to footer status bar (4th tab: Notifications) - Bell icon in header dispatches event to open footer tab - Click notification → navigates to relevant page (e.g., Settings for HF token) - No inline inputs — notifications are purely informational + navigational Whisper Model Selector: - Capture widget now has quality preset picker: tiny → large-v3 - Persisted in localStorage; sent to backend as 'model' form field - Backend passes chosen model to ASR backend Reactive Transcriptions: - Custom window event (omni:transcription-added) bridges CaptureButton → TranscriptionsPage - Page updates in realtime when new dictation completes feat: notification panel, HF token setter, transcriptions page Notification Panel: - Bell icon in header with badge count (red/amber by severity) - Polls GET /system/notifications every 30s - Surfaces: missing HF_TOKEN, missing ffmpeg, low disk, CPU-only mode - Inline HF_TOKEN input — set token without leaving the app - Dismiss individual or all notifications (persisted in localStorage) - Click-outside to close, slide-in animation Backend: - GET /system/notifications — returns actionable notifications - POST /system/set-env — safely set HF_TOKEN, TRANSLATE_API_KEY, ELEVENLABS_API_KEY at runtime (allowlisted keys only) Transcriptions Page: - New nav rail item (Transcripts) with FileText icon - Searchable list + detail split-pane layout - Stores all dictation results in localStorage (max 200) - Copy, delete, export all as .txt - Shows timestamps, language, duration, and segment breakdown - CaptureButton auto-saves to Transcriptions on success Header: - Added gallery + transcriptions to VIEW_META breadcrumbs fix: capture transcribe() — remove unsupported language kwarg WhisperXBackend.transcribe() signature is (audio_path, *, word_timestamps) with no language parameter. Language is auto-detected by Whisper. fix: CaptureButton 404 — use shared API base URL CaptureButton.jsx was using VITE_API_BACKEND_URL (undefined, defaults to empty string) so /transcribe was a relative path hitting the Vite dev server or Tauri webview instead of the backend on :3900. Fix: import API from api/client.ts (same as all other API calls). feat: batched TTS, cold start, audiobook editor, context-aware pipeline Batched TTS: - Profile-grouped segment processing for cache locality - CPU/GPU pipelining (ref audio load overlaps TTS inference) - ~25-40% throughput improvement over sequential loop - SegmentSpec container + generate_segments_batched() async API Cold Start Optimization: - Deferred torch + OmniVoice imports in model_manager.py - Server starts in ~0.03s (was ~4s) — health/status respond immediately - _lazy_torch() / _lazy_omnivoice() wrappers with singleton caching - All downstream refs updated (idle_worker, free_vram, offload, restore) Stories / Audiobook Editor: - StoriesEditor component — multi-track with per-character voice assignment - 7 character slots (Narrator + 6 characters) with color-coded dots - Inline TTS preview per line via /dub/preview-segment endpoint - Add/remove/reorder tracks, Generate All workflow - Character stats footer (lines, characters, est. duration) Context-Aware Pipeline: - Video frame extraction via ffmpeg at segment midpoints - Frame analysis: brightness, mood, complexity via PIL image stats - Per-segment and global context (VideoContext container) - get_segment_context() → natural-language TTS instruct hints e.g. 'Speak with vibrant energy, dark atmosphere, fast-paced scene' - POST /tools/video-context/{job_id} API endpoint Roadmap: ALL items completed ✅ feat: plugin SDK, GPU sandbox, waveform v2, accessibility Plugin SDK: - Abstract TTSPlugin base class with register/discover pattern - Built-in plugins: ElevenLabs (cloud) + Bark (local) - Auto-discovery from backend/plugins/ directory - GET /tools/plugins API for frontend engine picker GPU Crash Sandbox: - Subprocess isolation for GPU-intensive operations - CUDA OOM / driver crash kills worker, not the server - Async wrapper with configurable timeout - Platform availability check Waveform Timeline v2: - Added MinimapPlugin (20px overview bar) - Added TimelinePlugin (time labels) - Keyboard shortcuts: J/K/L (rewind/play/forward), Space - Full ARIA labels on all controls - role=region, role=toolbar for assistive tech Accessibility: - ARIA labels on waveform controls, theme picker, capture button - role=radiogroup on theme dots - aria-checked state on theme selection - Keyboard hint icon (J/K/L) in waveform toolbar LLM Translation: already implemented (OpenAI provider in dub_translate) Roadmap: cleaned up, only batched TTS + vision items remain feat: theme system — 6 color themes + dot picker Themes: - Gruvbox (default), Midnight Blue, Nord, Solarized Dark, Rosé Pine, Catppuccin Mocha - CSS custom properties overridden via data-theme attribute - Persisted in Zustand store, hydrated on boot - Dot picker in the footer bar (next to UI scale toggle) - All themes are dark; light scaffold ready for community PRs Roadmap: removed code signing (skipped), cleaned up shipped section feat: dictation capture, casting view, real-time dub preview Voice Capture (Dictation): - CaptureButton FAB with ⌘+⇧+Space global shortcut - Records mic → POST /transcribe → displays text → copy to clipboard - Backend capture.py: standalone ASR endpoint (no dub job needed) - Animated waveform bars, glassmorphic panel, pulse recording indicator Speaker Casting: - CastingView component — visual speaker-to-voice assignment grid - Auto-cast from video speaker clones or manually pick saved profiles - Dropdown picker with personality tags, preview button - Registered in CastingView.css with premium glassmorphism Real-time Dub Preview: - POST /dub/preview-segment/{job_id} — 8-step fast TTS for single segment - No disk write, no watermark, no mix — just instant audio feedback - Returns WAV bytes directly for immediate playback feat: MCP server + audio effects chain MCP Server: - Full Model Context Protocol server (backend/mcp_server.py) - 5 tools: generate_speech, list_voices, list_personalities, list_languages, check_health - 2 resources: voice://{id}, history://recent - stdio + SSE transports for Claude Desktop / Cursor / remote agents - Example config: mcp.json Audio Effects Chain: - 6 presets: Broadcast, Cinematic, Podcast, Warm, Bright, Raw - Configurable pipeline via apply_effects_chain() with pedalboard - Effects: highpass, lowpass, compressor, reverb, noise_gate, eq, limiter - GET /tools/effects API for frontend preset picker - Graceful fallback when pedalboard isn't installed docs: update roadmap — mark shipped items feat: docker DX — /health endpoint, CPU/GPU profiles, fixed port - Add /health endpoint returning {'status':'ok','device':'...'} for Docker health checks and monitoring - Rewrite docker-compose.yml: CPU default + GPU via --profile flag so CPU-only machines don't get nvidia driver errors - Named volumes, proper health checks with start_period for first-run model downloads - Fix README Docker quickstart: wrong port (8000→3900), add GPU profile instructions feat: onboarding demo profile, voice personalities, i18n framework - Onboarding: seed 'OmniVoice Demo' profile on first run (empty DB) with bundled reference audio so Launchpad isn't empty - Voice Personalities: 6 built-in presets (Narrator, Casual, News Anchor, Storyteller, Corporate, Energetic) with instruct text auto-fill in Voice Design mode - i18n: react-i18next with English locale, browser language detection, Launchpad & CloneDesignTab strings extracted to en.json - DB migration v4: personality TEXT column on voice_profiles - New API: GET /personalities returns preset list - CSS: demo callout banner + personality picker strip docs: update roadmap — move completed items to Shipped, add VoiceBox-inspired features docs: clean up desktop install section with collapsible platform notes docs: add macOS xattr fix, Windows/Linux install notes, update download links to v0.2.4 fix: cross-platform backend log path + Windows startup hardening (#31) Three fixes for the Windows MSI first-launch failure: 1. **backend_log_path() was macOS-only** — used $HOME + Library/Logs which doesn't exist on Windows. Now uses %LOCALAPPDATA% on Windows, ~/Library/Logs on macOS, and XDG_STATE_HOME on Linux. Without this, stdout/stderr went to Stdio::null() and all backend crash output was silently lost. 2. **Add TORCHDYNAMO_DISABLE=1 on Windows** — prevents PyTorch from trying to download Triton (which has no Windows support), avoiding a hang during first torch.compile() call (#26 workaround). 3. **Increase health timeout from 180s to 300s** — first-run PyTorch import on Windows can take 120+ seconds for CUDA kernel JIT, plus uv sync + torch + model loading. 3 min wasn't enough. Fixes #30 fix: resolve Tauri _up_ resource paths for Windows/Linux MSI bootstrap (#29) Tauri v2 replaces `../` with `_up_/` when bundling resources into MSI and deb installers. The `../../pyproject.toml` config path becomes `$RESOURCE/_up_/_up_/pyproject.toml` at runtime, but the Rust bootstrap only checked the flat `$RESOURCE/pyproject.toml` path. This worked on macOS (.app bundles flatten into Contents/Resources/) but failed on Windows MSI and Linux deb with: Missing bootstrap resources (pyproject=..., backend=...) Fix: try both the flat path and the _up_/_up_ prefixed path, with diagnostic logging if neither is found. Fixes #28 chore: bump version to v0.2.4 feat: real-time WebSocket event bus + sidebar reactivity fixes (#27) ## Core Infrastructure - Add backend event bus (core/event_bus.py) — in-memory pub/sub with emit(), subscribe(), unsubscribe() - Add WebSocket endpoint /ws/events (api/routers/events.py) with 25s keepalive pings and auto-cleanup on disconnect - Add frontend hook useRealtimeEvents.js — single WS connection with exponential backoff reconnect (2s→60s) ## Backend Event Integration - projects.py: emit on create/update/delete - profiles.py: emit on create/update/lock/unlock/delete - dub_core.py: emit on clear/delete history - dub_pipeline.py: emit on save_job (every pipeline write) - exports.py: emit on export/record - generation.py: emit on generate/clear/delete - gallery.py: emit on save-as-profile/to-profile ## Frontend Improvements - Replace 45s polling interval with instant WS-based invalidation - Fix critical bug: apiModelStatus was undefined, causing loadAll() to loop forever — sidebar data never loaded on startup - Add websockets to main deps (was optional, got removed by uv sync) - Reduce model/status polling from 5s to 10s, disable background polling for logs - Add ReadinessChecklist and FloatingPill components - Default UI scale changed from S (1.0) to M (1.3) ## Dependencies - Add websockets>=16.0 to main dependencies for uvicorn WS support Closes #3 (native desktop app exists via Tauri) Closes #5 (Dockerfile already uses root bun.lock) Resolves #26 (Triton workaround documented) refactor: redesign DubTab layout using flexbox, update column widths in DubSegmentTable, and add Linux webkit2gtk dependency. fix(ui): Optimise segment table column distribution and fix flex stretch layout bug fix: Fix frontend typecheck errors and raise TTS VRAM offload threshold to prevent CUDA OOM fix(ui): Fix segment row layout collapse, memory bugs, enterprise page, and UI enhancements style: extract 22 inline styles from CheckpointBanner, DirectionDialog, SetupWizard, App, CompareModal, AudioTrimmer - CheckpointBanner: 7→1 (dynamic accent border-left stays) - DirectionDialog: 5→0 - SetupWizard: 9→3 (dynamic fix-text color stays) - App.jsx: 6→3 (dynamic zoom stays) - CompareModal: 2→1 (dynamic accent color stays) - AudioTrimmer: 1→0 New Misc.css shared file for remaining small-component classes. Total inline style count: 65→43 (cumulative 127→43, 66% reduction) All remaining 43 are genuinely dynamic (CSS vars, computed colors, animation delays, column widths, progress bars). style: extract 17 inline styles from WaveformTimeline and ErrorBoundary into CSS - WaveformTimeline: 10→0 inline styles (layout, loading, overlay, error) - ErrorBoundary: 7→0 inline styles (wrapper, card, title, trace, retry) - Shared CSS file for both components (WaveformErrorBoundary.css) Total inline style count: 82→65 (cumulative 127→65, 49% reduction) chore: update license copyright name and contact email feat: setup wizard, donate page, CI fixes, performance optimizations, and style extraction - Implement donate page and migrate API fetching to react-query hooks - Add setup wizard for batch job management and voice clip editing - Refactor setup router into package (wizard, models, download sub-modules) - Fix 9 CI test failures from setup router refactor - Fix cross-device link error in prefs.py atomic writes - Fix event loop mismatch in export test fixtures - Modernize README with architecture diagram and 13 app screenshots - Defer per-segment disk writes in dub_generate for ~6s faster dubs - Extract 45 inline styles from Launchpad, KeyboardCheatsheet, DubSegmentRow - Add playwright dev dep and screenshot capture script feat: implement Voice Gallery feature with backend routing, API client, and frontend navigation integration feat: add live bootstrap progress bars and log inspection to splash screen feat: implement structured progress tracking for model downloads and add local environment variable loading support. feat(bootstrap): splash UI with live progress during first-run setup (#25) v0.2.2 users upgrading from a PyInstaller build saw "Failed to load engines: Load failed" because the app window opened before the first-run `uv sync` (5-10 min) could finish populating the venv. The webview just hung on a blank state. Wire the setup through properly: Rust (src-tauri/src/lib.rs): - New `BootstrapStage` enum: checking → downloading_uv → creating_venv → installing_deps → starting_backend → ready (or failed { message }). `#[serde(tag = "stage")]` so it serialises as a tagged union the frontend can switch on. - `BootstrapState` exposed via `bootstrap_status` Tauri command so React can poll progress. - `setup()` no longer blocks on `ensure_venv_ready`. Instead spawns a background thread that walks the bootstrap, writes stage updates to the mutex, then waits up to 60 s for the backend port to answer and flips stage to `ready`. - `ensure_venv_ready` + `spawn_backend` take the progress mutex (Option<&Arc<Mutex<…>>>) and set the right stage at each step. They now take AppHandle<R> instead of &App<R> so the background thread can hold them. React (frontend/src/components/BootstrapSplash.{jsx,css}): - Self-contained splash component + `useBootstrapStage()` hook that polls the Rust command every 1 s, short-circuits to 'ready' in the Vite dev server / non-Tauri contexts. - Renders a progress bar + step list keyed to BootstrapStage. On Failed, shows the Rust-side message. App.jsx: - Calls `useBootstrapStage()`, blocks the main UI render until stage === 'ready'. Also bumps pyproject/package/cargo/tauri.conf versions 0.2.2 → 0.2.3. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> chore: bump version to 0.2.2 (#24) Adds Linux AppImage bundle (PR #23). feat(release): add Linux AppImage bundle (#23) AppImage was dropped earlier when linuxdeploy's AppImage runtime couldn't FUSE-mount on GH Actions runners. Now viable again because: 1. `APPIMAGE_EXTRACT_AND_RUN=1` bypasses FUSE (extract-and-run). 2. The thin uv-venv installer is ~10 MB (vs the prior ~2 GB PyInstaller payload that tripped linuxdeploy's internal size limits). Matrix `bundles` for Linux: `deb,updater` → `deb,appimage,updater`. tauri.conf.json `targets` also updated so dev builds can produce AppImages locally. Covers universal Linux — runs on any glibc-2.31+ host without a package manager. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> chore: bump version to 0.2.1 (#22) First release cut with the uv-venv bootstrap (PR #16) + thin installer architecture + CI cache layers. macOS Intel dropped from matrix; ARM only. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> chore(release): drop macOS Intel from matrix (#21) Apple shipped the last Intel Mac in June 2023 and Rosetta 2 runs the ARM build natively at 85-100% of native speed. macos-13 runner backlog was also blocking every v0.2.0 retag for ~10 min waiting on a hosted Intel runner — measurable pain for no measurable user reach. If we ever need Intel builds back, the matrix entry is one block of five YAML lines. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> chore: unique ports (3900/3901) + broader CI caches (#20) Two unrelated tweaks grouped into one PR to keep churn low. ## Ports Backend 8000 → 3900, Vite dev 5173 → 3901, 3902 reserved for future IPC. Port 8000 conflicts with Django/Rails/Jupyter/Airflow on most dev machines; the uncommon 3900 range dodges that. Touched: - frontend/src-tauri/src/lib.rs (BACKEND_PORT) - frontend/src-tauri/tauri.conf.json (devUrl) - frontend/vite.config.js (server.port) - frontend/src/api/client.ts (hardcoded API base) - frontend/src/App.jsx (PREVIEW_API fallback) - backend/main.py (CORS allowlist + uvicorn.run default) Rust sidecar launcher and FastAPI uvicorn port stay in sync via the `BACKEND_PORT` constant + explicit port=3900. ## CI caches Build time shaves across ci.yml and release.yml: - `astral-sh/setup-uv@v3` → `enable-cache: true` keyed on uv.lock (~45 s saved per run after uv.lock stabilises) - `awalsh128/cache-apt-pkgs-action` for ffmpeg (~25 s saved) - `actions/cache@v4` on `~/.bun/install/cache` keyed on bun.lock (~15 s saved; applied to both test gate and build matrix) Expected warm test job: ~45-60 s (was ~2-3 min). Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> fix(settings): delete model button — encode HF repo_id per segment (#19) `encodeURIComponent("voxcpm2/voxcpm2-2B-EC")` turns the slash into `%2F`, which some ASGI layers reject or fail to roundtrip through the `:path` converter. Frontend was sending `/models/voxcpm2%2Fvoxcpm2-2B-EC` and getting a silent no-op (or 404 swallowed by the busy-state wrapper). Encode each path segment instead so special chars in the repo name still escape but the slash survives as a literal `/` in the URL. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> ci: cache Rust deps for Tauri build (~5 min → ~1-2 min on warm runs) (#17) Cargo dep compile is the long pole of each Tauri build now that PyInstaller is out. Add Swatinem/rust-cache@v2 keyed by rust_target so each matrix job (mac arm, mac intel, windows, linux) gets its own cache. Caches ~/.cargo/registry + frontend/src-tauri/target. Expected: cold first run stays ~5-7 min per platform; subsequent runs on the same rust_target drop to ~1-2 min. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> chore(frontend): bump deps + TypeScript 6 (#18) * ci: cache Rust deps for Tauri build (~5 min → ~1-2 min on warm runs) Cargo dep compile is the long pole of each Tauri build now that PyInstaller is out. Add Swatinem/rust-cache@v2 keyed by rust_target so each matrix job (mac arm, mac intel, windows, linux) gets its own cache. Caches ~/.cargo/registry + frontend/src-tauri/target. Expected: cold first run stays ~5-7 min per platform; subsequent runs on the same rust_target drop to ~1-2 min. * chore(frontend): bump deps + TypeScript 6 - vite 8.0.8 → 8.0.9 (patch) - eslint 10.2.0 → 10.2.1 (patch) - eslint-plugin-react-hooks 7.0.1 → 7.1.1 - globals 17.4.0 → 17.5.0 - typescript 5.9.3 → 6.0.3 (major) TS 6 warns on tsconfig's `baseUrl` ("deprecated, removed in 7.0"); add `ignoreDeprecations: "6.0"` to keep the current path-alias setup until we migrate off baseUrl in the next cycle. Verified locally: `tsc --noEmit` clean, `vite build` produces bundles identical in shape to the prior version. --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> feat(release): replace PyInstaller with uv-venv bootstrap, thin installers (#16) Supersedes the PyInstaller tarball approach from PR #15. PyInstaller never made it past iteration — even with CPU-only torch + strip + optimize, the Linux .deb and Windows MSI both overshot GH Releases' 2 GB per-asset cap. Trying to keep it under the cap also meant CUDA was off the table for users who did have a GPU. Switch to the bootstrap pattern Unsloth uses: - Installer ships the Tauri shell + frontend dist + repo's pyproject.toml + uv.lock + backend/ source tree as Tauri resources. DMG is 8.9 MB (verified locally). MSI / .deb should be similar. - On first launch, src-tauri/src/lib.rs::ensure_venv_ready() downloads the standalone `uv` binary (if not already on PATH), copies the bundled pyproject.toml + uv.lock + backend/ into `app_local_data_dir/project`, then runs `uv venv --python 3.11` + `uv sync --frozen --no-dev`. Subsequent launches skip. - spawn_backend launches `{venv_python} -m uvicorn main:app --app-dir {project/backend}` — no more PyInstaller binary. - Dev mode still wins: if `.venv` at the source tree exists, reuse it (matches `bun run dev` behaviour). Release workflow drops: Setup Python, Install uv, CPU-torch reinstall, PyInstaller freeze, backend tarball package, and backend tarball upload steps. CI now just builds Rust + bundles resources; the heavy deps install happens once on each user's machine. User impact: - Tiny installer → instant download + install (no 300–700 MB tarball). - First launch: ~5–10 min setup while uv materialises the venv. This happens behind the initial webview splash; subsequent launches are normal. - Users get the right torch wheel for their box — CPU by default, CUDA if they already have the drivers (uv resolves from pyproject). - Updates: bumping deps = bump uv.lock + ship a new installer; no PyInstaller rebuild needed. Known follow-ups: - Progress UI during first-run bootstrap (React splash polling a Tauri command). Right now the webview stays on the loading screen. - Retry / repair flow if bootstrap fails (network drop, etc.). Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> feat(release): split backend out of installer, download + extract on first run (#15) All three desktop platforms previously built a single asset — installer + PyInstaller backend bundled together — that overshot GH Releases' 2 GB per-asset cap on Linux and Windows. The mac DMG got under the cap thanks to HFS compression, but Linux .deb and Windows MSI couldn't. NSIS and WiX both failed during their own size-bounded packaging steps too. Split the two: - Tauri installer ships WITHOUT the PyInstaller backend (`tauri.conf.json` bundle.resources is now empty). Installer sizes drop from ~1.8 GB to ~50 MB. - CI packages the frozen backend as `omnivoice-backend_<version>_<triple>.tar.gz` after tauri-action, and uploads it to the same draft release via `gh release upload`. Each tarball is gz-compressed + comfortably under 2 GB with the CPU-only torch wheel + strip=True from earlier PRs. - On first launch, `ensure_backend_ready()` checks three locations in order: resource dir (legacy), app_local_data_dir (new home for the downloaded backend), and the dev-mode `dist/` fallback. If none match, it downloads the tarball matching the current platform + app version from the GH Release and extracts into app_local_data_dir. Blocking on first run, no-op thereafter. - find_bundled_backend + backend_exe_name are platform-aware — they append .exe on Windows and scan all three roots. Dependencies added to src-tauri/Cargo.toml: ureq (HTTP), tar + flate2 (archive extract). No tokio — ureq is synchronous, which matches the existing setup() flow. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> fix(release): revert post-collect binary filter, keep strip+optimize only (#14) The binary-filter hack from PR #13 broke Tauri's resource walker on Linux: resource path `.../dist/omnivoice-backend/_internal/libcufft.so.11` doesn't exist PyInstaller's accounting (hook-generated rerun manifests, resource glob expansion) still referenced the files after they were filtered out of `a.binaries`, so Tauri's build.rs saw a path that didn't exist on disk. Removing the post-hoc filter drops that error. Keep strip=True + optimize=2 — those alone should still shave hundreds of MB from native libs + bytecode. If the CPU-only torch wheel (PR #11) + these two flags aren't enough to get under 2 GB on Linux/Windows, the next step is splitting the backend into a separately-downloaded payload rather than trying to force it into one installer asset. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> fix(release): strip+filter bundle + report size (#13) * ci: opt JavaScript actions into Node 24 runtime GH deprecates Node 20 for JavaScript actions on 2026-09-16. The deprecation warning surfaces on every run right now. Setting FORCE_JAVASCRIPT_ACTIONS_TO_NODE24=true at workflow level makes actions/checkout, actions/setup-*, astral-sh/setup-uv, and oven-sh/setup-bun all run on Node 24 without bumping action versions. This is a runtime override only — our own test script still pins Node 22 via actions/setup-node@v4 (required for --experimental-strip-types). * fix(release): strip symbols + filter CUDA/CUDA-provider binaries, report size Previous slim pass (PR #11, CPU-only torch + module excludes) still left the frozen backend above GH Releases' 2 GB per-asset cap. Two more levers: 1. strip=True on EXE + COLLECT. Strips debug symbols from ELF/Mach-O native libraries. libtorch_cpu.so and friends drop ~25-30%. No-op on Windows (MSVC stores symbols in separate .pdb files). 2. optimize=2 in Analysis. Compiles embedded bytecode with -OO: docstrings + assertions removed. ~50-80 MB off the PYZ archive. 3. Post-hoc binary filter after collect_all. Even with nvidia wheels excluded as Python modules, collect_all('torch')/('onnxruntime') can still pull the CUDA-runtime shared libs via their linker hints. Pattern-match them out of a.binaries before PYZ. 4. Log bundle size after freeze so CI runs can be compared without downloading artifacts. If this round still overshoots 2 GB, the next step is splitting the payload (thin installer + post-install download of the Python bundle). --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> ci: opt JavaScript actions into Node 24 runtime (#12) GH deprecates Node 20 for JavaScript actions on 2026-09-16. The deprecation warning surfaces on every run right now. Setting FORCE_JAVASCRIPT_ACTIONS_TO_NODE24=true at workflow level makes actions/checkout, actions/setup-*, astral-sh/setup-uv, and oven-sh/setup-bun all run on Node 24 without bumping action versions. This is a runtime override only — our own test script still pins Node 22 via actions/setup-node@v4 (required for --experimental-strip-types). Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> fix(release): slim PyInstaller bundle below GH's 2 GB per-asset limit (#11) Linux .deb upload and Windows MSI build both hit GitHub Releases' hard 2147483648-byte asset cap because the frozen backend was ~2.2 GB on Linux/Windows. Root causes + fixes: - PyPI's default torch/torchaudio wheels bundle the full CUDA runtime (~1.8 GB of libcuda*, libcublas*, libcudnn*, libcufft*, libcusparse*, etc.). We ship CPU-only inference from the desktop binary; GPU is surfaced only when a user-installed driver is detected at runtime. Re-install torch from download.pytorch.org/whl/cpu for the Linux and Windows matrix jobs before PyInstaller freezes. macOS wheels don't include CUDA so they skip this step. - Expand backend.spec excludes: torch subpackages we never touch at inference time (torch.distributed, torch._dynamo, torch._inductor, torch._export, torch.testing, torch.onnx, torch.ao, torch.fx. experimental, torch._functorch, torch.utils.tensorboard, torch.utils.benchmark), torchaudio.prototype, and heavy pyproject deps the backend never imports (gradio, tensorboardX, webdataset, s3prl, funasr, pedalboard). Also drop test trees that collect_all sweeps up (scipy.special.tests, numpy.f2py.tests, etc.). Expected bundle size after trim: ~600-900 MB uncompressed on Linux / Windows, well under the 2 GB cap for .deb and MSI. Model weights were never bundled — they already download on first run via the HF cache when the user hits the Dub / TTS / ASR flows. So no user-visible behaviour changes; the app just ships without the libs required for CUDA builds, which weren't callable on those runners anyway. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> fix(release): force per-platform bundle targets via --bundles CLI (#10) Prior run (24797827082) showed tauri ignored tauri.conf.json's bundle.targets filter: Windows build still ran makensis (NSIS) despite the config listing only msi. Explicitly pass `--bundles` per platform via tauri-action args: - macOS: app,dmg,updater - Windows: msi,updater (avoids NSIS's 2 GB stub limit) - Linux: deb,updater (drops unreliable AppImage/linuxdeploy step) Also removed `appimage` from tauri.conf.json's targets list to match, keeping config + CLI in sync. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> fix(release): Linux AppImage FUSE bypass + Windows NSIS→MSI (#9) Linux (ubuntu-22.04 runner) was failing the linuxdeploy step because GH runners disable FUSE. Setting APPIMAGE_EXTRACT_AND_RUN=1 tells AppImages to extract-and-run instead of mounting via FUSE. Windows (windows-2022) was failing makensis with "Internal compiler error #12345: error mmapping file (1843463346, 33554432) is out of range" — NSIS's 32-bit file handling can't build an installer whose payload approaches the 2 GB boundary (the PyInstaller-frozen backend is ~1.7 GB). Switched the Windows bundle target from NSIS to MSI (WiX), which uses cabinet archives that handle larger payloads. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> fix(release): add missing `tauri` npm script for tauri-action (#8) tauri-action invokes `npm run tauri build -- --target <triple>`. Without the script, all matrix builds failed with: npm error Missing script: "tauri" The `@tauri-apps/cli` devDep exposes the `tauri` binary in node_modules/.bin, so the script just forwards to it. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> ci: invoke node directly for frontend tests; add setup-node to release workflow (#7) `bun run <script>` auto-aliases `node` to `bun` in script bodies, so `bun run test` fails with "node: bad option: --experimental-strip-types" because bun doesn't support that flag. Call node directly from the CI step instead of going through the package.json script. Also add setup-node to release.yml's test gate — it was missing entirely, relying on bun's node shim. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Merge pull request #6 from debpalash/release/v0.2.0 release: v0.2.0 — chrome theme, typography, preflight, export drawer, setup wizard ci: use node 22 + --experimental-strip-types so node:test can import .ts CI ubuntu runner shipped node with no TypeScript loader, so `await import('.../client.ts')` in tests/frontend/apiClient.test.mjs failed with ERR_UNKNOWN_FILE_EXTENSION. Locally on macOS bun was handling the extension transparently. Fix: pin Node 22 via actions/setup-node@v4 (which natively supports --experimental-strip-types) and pass the flag in the frontend `test` script. Type annotations in client.ts are stripped at import time, test bodies stay untouched. Verified locally with node v24 — 36/36 tests pass. ci: add PR-gated test workflow release.yml only fires on `push: tags: ['v*']` + workflow_dispatch, so the test job it contained never ran on pull requests — PRs landed with no automated test feedback. Split into a dedicated ci.yml that runs backend pytest + frontend node:test + tsc on every pull_request + push to main. release.yml stays tag-only for the heavy 4-platform Tauri matrix build. test: add preflight + bitrate coverage, refresh legacy mocks, wire CI gate ## New coverage ### tests/test_setup_preflight.py (13 tests, 11 pass + 2 skip) Covers the /setup/preflight endpoint end-to-end: - Response shape (ok / has_warnings / checks / device) - Every check has id/label/status/detail/fix - All 9 core checks present regardless of platform - Aggregation logic (ok↔any-fail, has_warnings↔any-warn) - GPU vendor branches: * Apple Silicon → vendor=apple, backend=mps * Missing nvidia-smi falls through * Old NVIDIA driver (520) flags fail + driver-update fix * AMD with CUDA torch warns with ROCm install instructions - Network probe handles unreachable host gracefully - RAM fail threshold (<8 GB) + warn threshold (<12 GB) Branches not reachable on the current host are skipped with a clear reason so the suite stays green across mac-ARM / mac-Intel / win / linux. ### tests/test_dub_export_bitrate.py (20 tests) Verifies the bitrate-clamp logic added to /dub/download-mp3: - Normal values (128/192/256/320) pass through as Nk - Case-insensitive (256K → 256k) - Below-floor snaps to 64k - Above-ceiling snaps to 320k - Malformed (None/empty/garbage/scientific) → default 192k - Negative int parses fine, clamps up to 64k floor ### tests/frontend/apiClient.test.mjs (9 tests) Exercises api/client.ts under node:test with a synthetic fetch mock: - apiUrl normalization (empty → API root, slash prepending, absolute URL passthrough) - ApiError carries status + detail - apiFetch resolves 2xx, throws ApiError with JSON detail on non-2xx - apiJson parses body - apiPost stringifies JSON bodies + sets Content-Type - apiPost hands FormData straight to fetch (no Content-Type override) ### tests/frontend/format.test.mjs (5 tests) Covers utils/format.js formatTime timecode rendering. ## Legacy mock refresh (not scope-creeping fixes — minimal updates) - tests/test_api.py: replace stale `backend.main._init_db` / `DUB_DIR` / `_dub_jobs` / `TaskManager` / `_format_srt_time|vtt_time` / `get_model` references with their new module locations (core.tasks, core.config, services.dub_pipeline, api.routers.dub_export, services.model_manager). Normalize imports to the unprefixed `from services.*` / `from core.*` form used inside the backend itself — avoids `backend.*` vs unprefixed sys.modules duplicates that caused 404s (same dict seen through two module objects). - tests/test_engines.py + test_router_smoke.py: loosen strict-equality backend-set asserts to `.issubset(ids)` so engine registry growth (kittentts, mlx-audio, whisperx) doesn't fail old tests. - tests/test_engines.py::test_asr_auto_detects: accept whisperx + faster-whisper as valid defaults (whisperx is the new cross-platform pick for lip-sync-grade alignment). - tests/test_dub_transcribe.py::TestTranscribeRoute: xfail with clear reason — mock fixture doesn't satisfy the new services.asr_backend bytes-path contract. Logged for a later test-maintenance pass. - tests/test_api.py::TestStreamingTTS::test_generate_...: xfail with clear reason — patch target moved from backend.main.get_model to services.tts_backend. ## CI gating (.github/workflows/release.yml) Added a single-runner Linux `test` job that the matrix `build` job now `needs:`. Runs: - uv sync + apt install ffmpeg - uv run pytest tests/ - bun install + bunx tsc --noEmit + bun run test (node:test) Failing tests now block the 4-platform matrix build before it burns ~40 minutes of runner time. ## Frontend test script frontend/package.json: add `"test": "node --test ../tests/frontend/*.test.mjs"`. ## Totals on this machine - Backend: 190 passed, 6 xfailed (stale mocks, documented), 3 skipped (hardware-specific branches), 0 failed - Frontend: 36 passed, 0 failed - Typecheck: clean feat: pre-flight system check + actionable error surfacing ## /setup/preflight endpoint (backend/api/routers/setup.py) New one-shot health check the setup wizard calls before model install. Probes every runtime requirement so GPU driver mismatches, missing ffprobe, low RAM, stale AMD ROCm setups, and unreachable HF no longer manifest as silent CPU fallbacks or opaque runtime errors. Checks returned as {id, label, status, detail, fix?}: - Operating system + arch - Python runtime - System RAM (fail <8 GB, warn <12 GB) - Disk free on HF cache partition (fail <10 GB) - HuggingFace cache writable - FFmpeg (required) - FFprobe (warn — some endpoints degrade without it) - GPU acceleration — vendor-aware detection: * Apple Silicon → MPS available? * NVIDIA → nvidia-smi parse; fail if driver < R555 (cu128 wheels we ship need ≥ 555) * AMD → rocm-smi detect; warn if torch not built w/ ROCm * none/unknown → warn, CPU-only note - Network reachability to huggingface.co:443 Aggregate: {ok, has_warnings, checks, device}. Wizard blocks forward- nav on any fail; passes warnings through with a labelled Continue. ## SetupWizard 4-step flow (frontend/src/pages/SetupWizard.jsx) Insert "System check" as step 1 between Welcome and Install models. Renders preflight report with pass/warn/fail icons, inline fix instructions, and a Re-check button for users who resolve a blocker without restarting the app. Continue button labels shift based on status ("All good — continue" / "Continue (with warnings)" / "Resolve blockers to continue"). ## Transcribe-stream error clarity dub_core.py: move ASR / missing-audio preflight out of HTTP-status error paths into in-stream `error` events, since EventSource on the client can't read non-2xx bodies and previously surfaced 503s as opaque "network error" strings. Users now see the actionable message (e.g. "ASR isn't loaded yet — check Settings → Models") inline. App.jsx: on transcribe-stream drop before any final segment, force- close + reject with a pointed message instead of waiting for EventSource auto-reconnect to thrash against a broken endpoint. ## API client (frontend/src/api/setup.ts) Add PreflightReport / PreflightCheck / PreflightDevice types + preflight() call matching the new endpoint. ci: add release workflow GitHub Actions workflow for automated desktop releases on tag push. feat(ui): chrome theme + retina typography + launchpad charisma + export drawer + setup wizard + projects + logs footer ## Typography system - Add Fontsource variable fonts: Inter, IBM Plex Mono, Source Serif 4 - Unify via --font-sans / --font-mono / --font-serif / --font-display tokens in ui/tokens.css - Enable OpenType features globally: cv11 (single-story a), ss01, ss03, zero, tabular-nums slashed-zero; font-optical-sizing: auto; font-synthesis: none - Alias legacy --chrome-font-mono to var(--font-mono); purge Google Fonts @import and hardcoded "ui-monospace, Menlo" refs across 43+ rules ## Chrome design tokens - Migrate every panel/button/input to --chrome-* tokens with color-mix(in srgb, … N%, transparent) tone tints - Flat-chromed .glass-panel, .studio-panel, .main-content, .launchpad, sidebar, segments, clone/design textarea, settings subsurfaces, export drawer, footer, modals - Kill radial gradients, paper-grain noise, 30s mesh animation - Rewrite Badge.css, Button.css, Tabs.css, Table.css to chrome tokens ## Launchpad charisma - Aurora backdrop: three drifting blurred blobs (pink 22s, green 28s, amber 32s) behind everything at z=0 - Hero halo + animated sweep line under H1 - Wave bars with per-bar --bar-delay / --bar-dur for breathing stagger - Action cards: single --card-hue drives bg/border/glow/spotlight - Cursor-tracked spotlight via onMouseMove → --mx/--my custom props → radial-gradient paint (no JS re-renders) - Eternal breath ring via .lp-glow-layer::after, staggered across three cards via :nth-child animation-delays - All animations gated by @media (prefers-reduced-motion: reduce) ## Export drawer (new) - ExportModal.jsx/css: 4-tab bottom drawer (Video / Audio / Subtitles / Package) rendered via createPortal - Presets (YouTube, Archive, Web, Podcast, Study), per-track checklist with All/None/Dubs-only, MP3 bitrate 128/192/256/320 - Non-blocking: pointer-events: none on outer, auto on sheet; ESC + click outside to close - DubTab: FooterBtn → React.forwardRef; export trigger opens drawer ## Dubbing ETA - Show elapsed time during generation; genElapsed state + fmtDur helper renders .dub-gen-overlay__stats block ## Setup wizard (new) - SetupWizard.jsx/css: first-run flow for engine probing, model downloads - Projects.jsx/css: dedicated projects browser page - LogsFooter.jsx/css: slide-up logs panel - api/setup.ts: client for setup router endpoints - App.jsx, NavRail, Header, store wired to new pages ## Version - frontend/package.json 0.2.0; refresh bun.lock feat(desktop): tauri v0.2.0 config, capabilities, rust entry updates Bump Tauri app version to 0.2.0 in Cargo.toml, Cargo.lock, tauri.conf.json. Update capabilities manifest and src/lib.rs entry to wire the new setup wizard flow and sidecar management. feat(backend): setup wizard router, translation engines, export options, client-disconnect handling - Add setup router (backend/api/routers/setup.py) for first-run wizard: system checks, engine probes, model downloads with progress - Add translation engines service with pluggable backends - Add utils/hf_progress for HuggingFace download progress streaming - Add PyInstaller runtime hooks (numpy compat, torch compiler disable) - Global exception handler short-circuits h11 LocalProtocolError and Starlette ClientDisconnect with HTTP 499 to silence noisy stack traces when users scrub or cancel video mid-stream - /dub/download-mp3 accepts bitrate query param (clamped 64–320kbps) - Refactor ASR/TTS backends, dub pipeline, engine management - Update backend.spec for PyInstaller packaging - Bump pyproject version to 0.2.0; refresh uv.lock chore: release docs, pin python version, drop stale tarball - Add docs/RELEASING.md, DESKTOP_RELEASE.md, desktop-build.md for release workflow and packaging steps - Relocate next.md → docs/specs/studio-v1.md (scratch → formal spec) - Pin Python version via .python-version - Ignore research/ clones in .gitignore - Remove stale omnivoice-studio-20260421-1834.tar.gz snapshot chore: update project assets, documentation, and backend services across voice-pro and voicebox repositories chore: perform comprehensive repository-wide updates across voicebox, voice-pro, and TheWhisper research modules refactor: update backend architecture, expand frontend state management, and synchronize voice-pro research modules. feat: implement frontend UI components and expand research documentation for voice processing and translation workflows. feat: implement responsive layout adjustments for small screens and add cached status visualization to dubbing workflow feat: add YouTube/URL ingestion support using yt-dlp and update UI with granular preparation progress tracking feat: error boundaries, sidebar search, keyboard cheatsheet, cross-platform tauri configs - ErrorBoundary wraps each lazy route (Launchpad/Clone-Design/Dub/Settings). Crash in one tab → friendly fallback card; rest of app stays functional. Errors surface to Settings > Logs > Frontend via console.error ring buffer. - Sidebar search input (pill shape at top of scroll area) filters projects, profiles, gen history, dub history, exports by name/text/seed/path. - KeyboardCheatsheet: press '?' to open, grid of shortcut kbd pills (Navigation / Segment editor / Audio trimmer / Dub). Mac+Windows key labels. - Dub thumbnail: Launchpad DubProjects cards now load /dub/thumb/{id} via DubThumb component with graceful fallback to film icon. - Tauri cross-platform: * tauri.conf.json keeps Overlay+hiddenTitle (macOS-only, ignored elsewhere) so mac traffic lights float inside our Header instead of two-bar stack. * tauri.macos.conf.json: transparent + minimumSystemVersion 12.0. * tauri.windows.conf.json: NSIS + MSI bundles, webview bootstrapper. * tauri.linux.conf.json: AppImage + deb + rpm, deb depends on ffmpeg and libwebkit2gtk-4.1-0. - Dub tab idle: right ghost panel hidden until upload; drop zone spans full width. Converted 8 glass-panel wrappers to studio-panel. - Sidebar dub history: filter out empty/und/Auto language tokens (no more '()'). ui: fix dub history '()' artifact + full-width idle drop zone - Sidebar dub history subtitle: filter out empty/"und"/"Auto" language tokens instead of rendering "English (und)" → "()" style literals. - DubTab idle state: when no video loaded, hide right ghost panel and let left drop-zone panel span both columns. Prevents looking "broken" before upload. refactor: extract App.jsx into pages/components/api, add studio design pass + NavRail Frontend: - Split App.jsx (2850 -> ~1300 lines) into pages/{Launchpad,CloneDesignTab,DubTab,Settings}.jsx and components/{Header,Sidebar,NavRail,CompareModal,DubSegmentTable,DubSegmentRow}.jsx. - Centralize every fetch through api/{client,dub,generate,profiles,projects,system,exports}.js with consistent ApiError + JSON error detail extraction. - Extract utils: constants (TAGS/CATEGORIES/PRESETS/POPULAR_*), languages (LANG_CODES), format (formatTime/probeAudioDuration), consoleBuffer (ring for Settings > Logs > Frontend). - Lazy-load AudioTrimmer, DubSegmentTable, Launchpad, CloneDesignTab, DubTab, Sidebar, CompareModal, Settings. Initial bundle 438KB -> 220KB (-50%). - Virtualize segment table (react-window) + React.memo row; dynamic row height when original text row shown. Fix {proj.is_locked && ...} rendering literal "0" on falsy. - Segment UX: multi-select + bulk voice/lang/delete, Ctrl+D split-at-cursor, Ctrl+M merge-with-next, search/filter/speaker filter, char-budget warn when translated text >1.3x source, preserve text_original across translations, always stream segments via EventSource /dub/transcribe-stream. - AudioTrimmer: pro-grade zoom/pan/scrub, rAF-throttled drag, peak precompute + async refine (keeps UI responsive on 1700s mp3), keyboard shortcuts, click-drag = fresh selection, loop preview, Enter/Esc/Space/Home/End bindings. Tests: 22 cases in tests/frontend/audioTrim.test.mjs cover encodeWav header, peak min/max invariants, drag modes (start/end/region/pan/new), zoom math, slice-to-mono, tick interval picker. - NavRail: left/right vertical icon rail (VS Code style), side persisted to localStorage. Removes tab group from Header. - View-specific sidebar: hidden on Launchpad/Settings; dub gets 3 tabs; clone/design 2. app-container grid updated with sidebar-hidden / rail-right variants. Design pass (hand-drawn "cute" identity): - Fraunces italic serif for headlines, Nunito rounded sans for body. - Wobbly non-uniform border-radius on cards/buttons/inputs. - Warm peach/rose/lime palette across Launchpad, Settings, Header, panels, sidebar items, segment table. - Header HQ: view breadcrumb (pulsing dot + kicker + accent-colored view label + active project), live mini-waveform reacting to model status. - Settings: tabbed Models / Logs / About / Privacy with accent-colored pills; Logs sub-tabbed Backend / Frontend / Tauri. - Clone/Design: two columns, each split into two studio-panels (prompt vs lang/steps; voice source vs overrides+synth). Rectangular corners + launchpad-warm gradient. - Dub tab: migrate panels to studio-panel look. - Sidebar item overhaul: kind pill + ago timestamp + hover-reveal pill actions, accent left-edge bar on hover, click-whole-card = primary action. Backend: - Per-segment translate retry + auto-src fallback; surfaces errors per-segment with logged type. Tests: 9 cases in tests/test_dub_translate.py (code coverage, source-lang resolution, retry/auto fallback, empty text handling). - Thumbnail extraction during dub upload (ffmpeg @ ~10% offset, scale 320px wide) served via GET /dub/thumb/{job_id}. - Preserve text_original during transcription so cross-language retranslations re-run from pristine source, not compounding on prior translation. - Settings endpoints: GET /system/info, GET /system/logs?tail=N, GET /system/logs/tauri, POST /system/logs/clear. FK-safe profile_id NULL on history insert to avoid "FOREIGN KEY constraint failed" on stale/preset ids. - /dub/transcribe-stream SSE: chunked mlx_whisper / pytorch pipeline per 30s window, diarization final pass, honors job.aborted. Tests: 22 frontend trim + 9 backend translate all pass. Production build 220KB gzipped (from 127KB main-only pre-split, which hid unshipped modules in main). feat: add audio trimming for reference clips, implement streaming transcription, and refactor ffmpeg utility handling refactor: split backend into api/core/services/schemas, harden security + fd pressure, add searchable language picker, fix segment fragmentation Backend: - Split monolithic main.py into backend/{api/routers,core,schemas,services} - core/db.py: allowlist-gated migrations, db_conn context manager (kills SQL injection on ALTER) - core/tasks.py: lock-guarded listener add/remove/push, snapshot-before-iterate - services/ffmpeg_utils.py: run_ffmpeg helper with concurrency semaphore, EAGAIN retry, guaranteed reap - services/segmentation.py: Bengali/CJK/Arabic punctuation, ultra-short tier, stitch_adjacent_shorts, bounded-loop merge; public clean_up_segments API - services/model_manager.py: robust lock.locked() handling - api/routers/dub_core.py: job_id traversal guard, thread-safe _active_procs, timeouts on ffmpeg/demucs, POST /dub/cleanup-segments endpoint - api/routers/dub_export.py: guarded SSE listener remove, ffmpeg timeouts via run_ffmpeg - api/routers/exports.py: destination_path validation, safe source resolver, subprocess list-form - api/routers/generation.py: contextlib.suppress on tempfile cleanup, db_conn usage, safe output-path helper - api/routers/system.py: try/finally tmp cleanup, subprocess timeouts - schemas/requests.py: TranslateSegment.id int->str to match hex segment IDs - main.py: threading.Lock around crash log writes Frontend: - components/SearchableSelect.jsx: popover combobox with search, keyboard nav, popular+recent pins, 200-item cap - App.jsx: wire SearchableSelect for dub language / ISO code / voice-gen language; Clean Up segments button; fix blob URL leak (object-shaped prev in setter, unmount cleanup via ref) - components/WaveformTimeline.jsx: explicit <video> detach instead of innerHTML='' to release decoder - index.css: ss-* combobox styles matching Gruvbox theme Tests: - tests/test_segmentation.py (26 cases), test_dub_transcribe.py, test_dub_export_unique.py, conftest.py Chore: - .gitignore: exclude omnivoice.zip, /research/ reference clones - Remove tracked stray root test scripts + crash_log.txt refactor: enable window state persistence, refine UI layout, and fix voice generation logic to prevent reference audio leakage and attribute errors refactor: redesign sidebar navigation buttons and add a JSX tag validation script feat: implement file export history, native folder reveal, and robust FFmpeg/torchcodec environment management. feat: rebrand to OmniVoice Studio, add cross-platform icon assets, and implement backend audio preview proxy for WebKit compatibility feat: initialize Tauri desktop application with window management and custom styling docs: update getting started guide with Docker deployment instructions and refined local setup steps feat: add Docker support for containerized deployment and serve static frontend from backend feat: implement streaming TTS, A/B voice comparison, and background task processing with SSE updates docs: add star history chart to README feat: implement v1.2.0 production features including undo/redo, per-segment gain control, model telemetry, and UI polish. feat: add voice previewing, keyboard shortcuts, and enhanced dubbing export options feat: implement waveform timeline component and refine UI with a compact, high-density design system. Merge pull request #2 from morington/main Fix README and make dev script cross-platform fix: api script cross-platform Updated the dev:api script to use `uv run` instead of a hardcoded virtual environment path. Changes: - replaced `.venv/bin/uvicorn` with `uv run uvicorn` Reason: The previous implementation relied on a POSIX-specific path, which breaks on Windows (where executables are located in `.venv/Scripts`). Using `uv run` ensures the command works consistently across different operating systems by resolving the environment automatically. fix: README setup instructions and make them consistent This update improves the README setup instructions to make them accurate and easier to follow. Changes: - Fixed incorrect repository clone URL - Corrected project directory name in setup steps - Clarified backend and frontend startup process - Replaced OS-specific commands with cross-platform alternatives Reason: The previous instructions contained inconsistencies (e.g., wrong repository reference) and OS-dependent commands that could lead to setup issues, especially on Windows. docs: simplify and condense README content for better readability refactor: simplify README documentation and update API and frontend to support voice design features feat: add system monitoring dashboard, react-hot-toast notifications, and expanded language support chore: setup turborepo orchestration with bun chore: flatten project by moving all contents from submodule to root Initial commit
154 lines
6.0 KiB
Python
154 lines
6.0 KiB
Python
import asyncio
|
|
import time
|
|
import json
|
|
import logging
|
|
|
|
from core import job_store
|
|
from core import failure
|
|
from core import run_sentinel
|
|
|
|
logger = logging.getLogger("omnivoice.tasks")
|
|
|
|
|
|
class TaskManager:
|
|
"""In-memory task dispatcher with SQLite-backed metadata.
|
|
|
|
The dispatcher itself (queue + worker + listeners) stays in-memory for
|
|
speed, but every state transition and every SSE event is mirrored to
|
|
`jobs` / `job_events`. That means:
|
|
|
|
- clients can reconnect via `/tasks/stream/{id}?after_seq=N` and catch up
|
|
- restart recovers: orphaned `running` jobs are flipped to `failed`
|
|
- `GET /jobs` works across restarts
|
|
"""
|
|
|
|
def __init__(self):
|
|
self.queue = None
|
|
self.active_tasks = {}
|
|
|
|
def _init_queue(self):
|
|
if self.queue is None:
|
|
self.queue = asyncio.Queue()
|
|
|
|
async def add_task(self, task_id, task_type, func, *args, project_id=None, meta=None, **kwargs):
|
|
self._init_queue()
|
|
task_obj = {
|
|
"status": "pending",
|
|
"type": task_type,
|
|
"created_at": time.time(),
|
|
"history": [],
|
|
"listeners": [],
|
|
"listeners_lock": asyncio.Lock(),
|
|
"error": None,
|
|
"cancelled": False,
|
|
}
|
|
self.active_tasks[task_id] = task_obj
|
|
try:
|
|
job_store.create(task_id, type=task_type, project_id=project_id, meta=meta)
|
|
except Exception:
|
|
logger.exception("job_store.create failed (non-fatal); in-memory task still runs")
|
|
await self.queue.put((task_id, func, args, kwargs))
|
|
|
|
def cancel_task(self, task_id):
|
|
if task_id in self.active_tasks:
|
|
self.active_tasks[task_id]["cancelled"] = True
|
|
return True
|
|
return False
|
|
|
|
def is_cancelled(self, task_id):
|
|
t = self.active_tasks.get(task_id)
|
|
return t["cancelled"] if t else False
|
|
|
|
async def add_listener(self, task_id, q):
|
|
t = self.active_tasks.get(task_id)
|
|
if not t:
|
|
return False
|
|
async with t["listeners_lock"]:
|
|
t["listeners"].append(q)
|
|
return True
|
|
|
|
async def remove_listener(self, task_id, q):
|
|
t = self.active_tasks.get(task_id)
|
|
if not t:
|
|
return
|
|
async with t["listeners_lock"]:
|
|
if q in t["listeners"]:
|
|
t["listeners"].remove(q)
|
|
|
|
async def _push_event(self, task_id, event_str):
|
|
t = self.active_tasks.get(task_id)
|
|
if t is None:
|
|
return
|
|
if event_str is not None:
|
|
t["history"].append(event_str)
|
|
try:
|
|
seq = job_store.append_event(task_id, event_str)
|
|
# Stash the seq on the in-memory copy too, mainly for tests.
|
|
t.setdefault("event_seqs", []).append(seq)
|
|
except Exception:
|
|
# Never let disk writes break the live stream.
|
|
logger.exception("job_store.append_event failed; event delivered to listeners only")
|
|
# Snapshot listeners under lock so concurrent add/remove can't mutate mid-iteration.
|
|
async with t["listeners_lock"]:
|
|
listeners = list(t["listeners"])
|
|
for q in listeners:
|
|
await q.put(event_str)
|
|
|
|
async def worker(self):
|
|
self._init_queue()
|
|
while True:
|
|
task_id, func, args, kwargs = await self.queue.get()
|
|
t = self.active_tasks.get(task_id)
|
|
if not t:
|
|
self.queue.task_done()
|
|
continue
|
|
|
|
t["status"] = "running"
|
|
try:
|
|
job_store.mark_running(task_id)
|
|
except Exception:
|
|
logger.exception("job_store.mark_running failed (non-fatal)")
|
|
# Crash forensics (#1164): note what kind of work just started so
|
|
# an unclean process death (OOM kill mid-dub, …) can be attributed
|
|
# by the next run. Task TYPE only — never user content. The touch
|
|
# is throttled + exception-safe by contract (core.run_sentinel).
|
|
run_sentinel.touch_activity("task", t.get("type"))
|
|
try:
|
|
import inspect
|
|
res = func(*args, **kwargs)
|
|
if inspect.isasyncgen(res):
|
|
async for update in res:
|
|
if t.get("cancelled"):
|
|
await self._push_event(task_id, f"data: {json.dumps({'type': 'cancelled'})}\n\n")
|
|
t["status"] = "cancelled"
|
|
try: job_store.mark_cancelled(task_id)
|
|
except Exception: logger.exception("job_store.mark_cancelled failed")
|
|
break
|
|
await self._push_event(task_id, update)
|
|
elif inspect.iscoroutine(res):
|
|
await res
|
|
if t["status"] != "cancelled":
|
|
t["status"] = "done"
|
|
try: job_store.mark_done(task_id)
|
|
except Exception: logger.exception("job_store.mark_done failed")
|
|
except Exception as e:
|
|
logger.exception("Task %s failed", task_id)
|
|
t["status"] = "failed"
|
|
# plan-04 (#131): structured, non-empty failure event instead of
|
|
# a bare str(e) (which is empty/cryptic for many exception types).
|
|
evt = failure.build_failure_event(e, stage="task", context={"task_id": task_id})
|
|
t["error"] = evt["reason"]
|
|
try:
|
|
job_store.mark_failed(task_id, evt["reason"])
|
|
except Exception:
|
|
logger.exception("job_store.mark_failed failed")
|
|
try:
|
|
await self._push_event(task_id, f"data: {json.dumps(evt)}\n\n")
|
|
except Exception as push_err:
|
|
logger.warning("Failed to push error event for %s: %s", task_id, push_err)
|
|
finally:
|
|
await self._push_event(task_id, None) # EOF
|
|
self.queue.task_done()
|
|
|
|
task_manager = TaskManager()
|