Closes #1716.\n\nDefines supported, best-effort override, unverified, and unsupported architecture evidence; marks RX 6700 XT/gfx1031 over WSL2 ROCDXG as unverified; and requires routing, utilization, and CPU-fallback evidence before claiming acceleration.
Fixes #1711.\n\nGives only the OmniVoice subprocess a 120-second readiness budget while retaining the shared 30-second default for all other sidecars, with regression coverage.
Closes#1718.
Adds structured pre-install and measured post-install disk costs, complete sidecar preflight accounting, strict authorization for recursive scans, and localized catalogue details with confidence and deduplication context.
Closes#1709.
Queues SRT selection until speaker analysis and clone extraction finish, then applies the newest selected subtitle file with stale-result, failure, replacement, retry, and abort guards.
Closes#1713
Adds a separately identified per-user MSI and updater channel, non-administrator install/uninstall verification, and fail-closed WebView2 handling for current-user installs.
* docs: add private production API deployment
* docs: harden private API deployment guidance
* docs: make proxy trust configuration executable
* docs: clarify private API trust boundaries
* docs: persist private service application data
Run uvicorn directly under the dev wrapper so a worker crash cannot hide behind a live reload parent. Preserve Python source reloads, restart isolated crashes with bounded diagnostics, and keep persistent crash loops loud.
Closes#1690.
Preserve audible, language-matched playback when WebView video decoding falls back, keep waveform timing aligned with the selected dub, prevent recursive recovery failures, and disable stale audio caching.
Closes#1692.
Closes#1689.
Uses the Catalogue workspace width for readable engine rows, keeps status badges out of engine names, and collapses by the named Tauri-scaled container. Compact Settings rows remain unchanged.
Prepare the tested main branch for the v0.5.1 patch release with synchronized version sources, mirrors, lockfiles, release notes, and install guidance.
Closes#1687.
Redesign Model Catalogue as one responsive workspace canvas with a compact wide-screen header, simplified navigation, and a single primary scroll plane.
Closes#1685.
Fail before backend/window startup when Linux source hosts lack Enigo’s libxdo linker input or WebKit’s GStreamer audio sink. Print an exact distro package command, sync source-build docs, and lock the probes with deterministic tests.
Closes#1680Closes#1682
Fixes#1677 and #1678.
Publishes first-run media tools to the live backend, provides precise cross-platform missing-process guidance, and keeps localized source-language selection available before transcription. Includes regression coverage and deterministic model-store test isolation.
Fix fresh-clone desktop development by creating the required dist placeholder before Tauri starts, and make source setup install the selected CUDA or ROCm PyTorch stack consistently. Adds behavior-level cross-platform regression coverage.\n\nFixes #1664.\nFixes #1665.\n\nThanks @uberclokr for the contribution.
Move unbounded Stories and Audiobook project data to revisioned IndexedDB storage with bounded local fallback, durable clear tombstones, migration/recovery safeguards, and deadline-safe persistence before exits and relaunches.
Closes#1636.
Fix the post-merge Vitest failure by invalidating CaptureWidget startup continuations and releasing timers, sockets, recorder/worklet state, and microphone streams on teardown. Adds fail-before/pass-after coverage for active capture, pending fallback, delayed microphone permission, and delayed WebSocket authentication.
Classify and journal local and remote streaming generation failures, return actionable scrubbed guidance, and keep exception details, tokens, and user paths out of logs and NDJSON responses.
The at-capacity test set client.config.max_concurrent_tasks = 1 after
connect_worker, racing the server's stream-open ConfigUpdate — which
carries the REGISTERED capacity (2, from the hello). When the frame
lands after the mutation (slow CI runners), the override is clobbered,
the worker honours 2 slots, accepts the second assignment, and the test
reports over-concurrency that never existed. Failed CI twice on
2026-08-21 (#1627 first run + main).
Fix: pass the limit through WorkerConfig so hello -> registration ->
ConfigUpdate all agree from the start; assert the registered capacity
as a regression guard. Demonstrated fail-before: with the old pattern
the server record stays 2 and a delivered config frame reverts the
client gate to 2.
The default curl|sh and irm|iex installs now put a real app on disk
(/Applications or ~/Applications, ~/.local/bin/VoiceStudio, MSI product).
Both uninstallers gain an opt-in flag that targets exactly those:
- uninstall.sh --app: adds the app bundle / AppImage to the dry-run plan
- uninstall.ps1 -RemoveApp: resolves the MSI product across HKLM/HKCU/
WOW6432Node and uninstalls it silently under -Yes
* feat(install): prebuilt-app installs by default, --source opt-in, --version picker
- install.sh: default mode now downloads the verified release asset
(dmg/AppImage + SHA256SUMS check) instead of cloning and building;
--source keeps the previous clone-and-build flow; --version pins a release
- install.ps1: same split — msi download with checksum verification and
setup wizard by default; -Source (or VOICESTUDIO_INSTALL_MODE) for the
source build; VOICESTUDIO_VERSION picks a release
- worker landing page documents the modes
* fix(install): CI smoke covers binary + source modes; hdiutil output parse
- drop -quiet from hdiutil attach (it suppresses the mount-point line the
script parses — caught by the macOS smoke)
- install.ps1 runs msiexec silently under CI, wizard interactively
- smoke verifies binary installs per OS (app bundle / AppImage / MSI
registry entry) and keeps full source coverage behind --source
* ci(install): check HKCU/WOW6432Node too — Tauri MSI registers per-user
* fix(install): rename $version — collides with bun installer's $Version under iex
* feat(install): one-command installer URL (.sh + .ps1) + 3-OS install smoke
- scripts/install.ps1: Windows source installer (winget deps, uv, bun,
clone, uv sync, frontend build); honors OMNIVOICE_PYTHON/OMNIVOICE_REGION
- infra/install-redirect: Cloudflare Worker serving /install with
User-Agent sniffing (curl -> sh, PowerShell -> ps1, browser -> landing
page); proxies live from main; /install.sh + /install.ps1 aliases
- scripts/install.sh: fix stale advertised URL (main/install.sh never
existed) and repo-root resolution so a local run no longer clones a
duplicate repo into ~/VoiceStudio (verified on macOS arm64)
- .github/workflows/install-smoke.yml: run both installers end-to-end on
ubuntu/macos/windows when they change
- docs-sync: install one-liners lead each platform guide; STRUCTURE.md
(#1626)
* fix(install): don't let a failed bun download pass silently
curl | sh runs an empty script and exits 0 when the download fails, so
a bun.sh hiccup surfaced much later as 'bun: command not found' (seen
on the macos-latest smoke runner). Fetch to a temp file, verify, fall
back to npm -g bun when node exists, and die with the manual command.
Same post-install verification for uv.
* fix(install): UTF-8 BOM for install.ps1 + quiet-style changelog entry
- tests/scripts/test_uninstall_ping.py requires shipped PowerShell
scripts with non-ASCII text to carry a UTF-8 BOM (Windows PowerShell
5.1 mis-decodes otherwise); same treatment uninstall.ps1 already gets
- test_changelog_style caps entries at ~400 chars
Refreshes frontend, tooling, and Python dependencies; regenerates the frozen lockfiles and worker protocol stubs. Keeps compatibility caps for FastAPI, Oxlint, and Vitest where newer releases break repository contracts. Updates the Uvicorn bind-failure regression test for its new nonzero exit code. Fully tested after merging current main.
Adds per-line subtitle timing, insertion, and bidirectional merge controls while preserving each speaker's attribution across merge/split, restore, translation, and cast edits. Includes regression coverage and localization updates. Closes#1612.
* fix(indextts): accept the config name upstream ships, and keep long text alive
Two independent defects, both reported on a working IndexTTS 2.5 install.
Install always failed. IndexTeam/IndexTTS-2.5 ships the model config as
config.yaml — at the pinned revision d0aa86e7 and at HEAD; config_v2_5.yaml
exists in no upstream revision. VoiceStudio demanded that name, so
_weights_floor_ok never found it and the install died claiming 'the download
was likely interrupted' when the download had been perfect. The only way
through was to hand-rename the file. Both names are accepted now, in the
installer and on the load path, so installs created with the workaround keep
working without a reinstall.
Long text was killed at 60s. infer() is one blocking upstream call that puts
nothing on the wire, and IndexTTS was the only sidecar still on the 60s
recv_timeout_s class default while pockettts and omnivoice-subprocess had both
raised theirs. Raising the default alone does not fix it — which is why the
reporter's RECV_TIMEOUT_S=3600 edit didn't help: progress frames are also what
report activity to the GPU pool's execution clock (#1367), so a silent sidecar
still trips the outer generate budget. The sidecar now heartbeats every 5s
while infer() runs (and during the cold model construction), _send takes a
lock so the beat thread can't interleave framing, and the deadline rises to
900s via OMNIVOICE_INDEXTTS_RECV_TIMEOUT_S.
test_indextts25_health_requires_25_config_name asserted the bug — that a
checkout holding only config.yaml is unhealthy — so it is rewritten to the
corrected contract, including that a genuinely truncated download is still
caught.
Fixes#1611
* test(indextts): follow the installed config name in the sidecar loader tests
Two more tests encoded the config_v2_5.yaml assumption, both asserting
cfg_path against a directory where no config existed at all — so they were
pinning the literal name rather than the resolution. They now lay down a real
checkpoints/ tree and assert the resolved path, including that a checkout
carrying the pre-fix hand-renamed config still resolves.
Caught by the full suite; the targeted runs during development did not reach
tests/backend/services/.
* test(indextts): event-driven heartbeat tests, real interleaving proof, precedence pin
Review round on #1619 — all four findings taken.
- The docs line naming 0.5.1 is version-neutral now ('Earlier installs') —
version labels are the owner's call.
- The heartbeat tests waited on wall-clock sleeps; they now block on a
per-write Event with a bounded deadline, so scheduler load can't flake them.
- The _send test asserted the lock EXISTS — a tautology. It now drives four
concurrent writers through a stream that yields between every byte and
asserts every frame decodes; verified fail-before by removing the lock
(torn frame) and pass-after.
- The precedence test deleted config.yaml before creating the renamed one, so
reversed precedence still passed. Both files now coexist for the assertion;
verified fail-before by reversing _CFG_NAMES.
* fix(openai-compat): reuse cached engine instances in _resolve_engine
The direct engine-ID path in /v1/audio/speech constructed a fresh
backend per request (return cls()). For SubprocessBackend engines that
meant: a new sidecar process, a full torch import and an engine model
reload on EVERY request (measured ~28s floor per pockettts request on
an M3 Pro), plus another atexit hook registration each time — exactly
what get_engine_instance_for()'s docstring warns against.
Route the explicit-ID path through the same cached-singleton seam the
active-engine path already uses. Unknown/unavailable IDs keep their
400s; tts-1/tts-1-hd and the OmniVoiceBackend special case are
unchanged.
* fix(openai-compat): unload the outgoing engine on explicit-ID switches
Review follow-up (Greptile/CodeRabbit on #1614): caching instances without
a switch rule would let each distinct explicit engine ID stay resident,
accumulating sidecars / multi-GB in-process models. Mirror
get_active_tts_backend's MM2-01 switch rule: a different explicit ID
(omnivoice included, which resolves to the active engine) unloads the
outgoing instance first, best-effort.
* fix(openai-compat): evict via the shared single-engine-resident seam, not a router-local cache
The explicit-ID unload cache (13c14e2c) kept its own instance ref keyed by
model id. The shared engine cache is deliberately keyed by CLASS (registry
rebinds, idle sweeps and engine_memory eviction all mutate it), so the
router's id-keyed ref could go stale and keep serving an instance the
lifecycle system no longer tracked — caught by
test_openai_speech_toggle_off_sends_raw_text in full-suite order, and it
also introduced a novel unload path that ignored the
OMNIVOICE_SINGLE_ENGINE_RESIDENT opt-out.
Drop the router-local cache entirely: _resolve_engine returns the shared
cached singleton (get_engine_instance_for), and create_speech calls
evict_other_tts_engines(backend.id) before warming the engine — the exact
seam /generate uses. That covers every transition (explicit id → explicit
id, explicit id → tts-1/omnivoice aliases), honors the policy opt-out, and
leaves no per-router state to drift. Regression pinned at the route level in
test_speech_request_evicts_other_resident_engines.
* chore(changelog): trim the #1614 entry to the one-liner limit
415 chars against the 400 the style test allows — CI would have failed on it.
---------
Co-authored-by: debpalash <4178343+debpalash@users.noreply.github.com>
* fix(setup): tolerate reserved memory in the RAM preflight, add OMNIVOICE_RAM_PREFLIGHT=0 escape hatch (#1618)
An "8 GB" machine reports ~7.8 GB usable (firmware/iGPU/kernel
reservations), so comparing OS-reported RAM against the marketing-size
8 GB threshold hard-blocked exactly the boundary hardware the minimum is
meant to admit — with no way past the wizard. Both thresholds are now
compared with a 7% reserved-memory allowance, and
OMNIVOICE_RAM_PREFLIGHT=0 downgrades a genuine fail to a warning for
users who accept the OOM risk (same opt-out shape as
OMNIVOICE_ASR_VRAM_PREFLIGHT).
Regression tests: backend/tests/test_ram_preflight_1618.py.
Docs: troubleshooting §1c.
* review: hermetic preflight stubs in tests; correct the escape-hatch doc
Greptile P1: the Settings panel can't set OMNIVOICE_RAM_PREFLIGHT (and the
blocker appears before setup completes anyway) — the doc now points at
PowerShell / shell env only.
CodeRabbit: stub _network_check and media_tools.summary so each RAM
assertion stays fast and offline (26s -> 6s locally).
* fix(watermark): run AudioSeal eagerly instead of through torch.compile
AudioSeal vendors moshi's @torch_compile_lazy on SEANetEncoder.forward, so
the first embed of a session — not the model load, which #1576's prefetch
already warms — called torch.compile and dropped into Inductor's C++ codegen.
On a macOS arm64 deployment that compile raised CppCompileError on 10/10
takes: the embed fail-opened and the audio shipped UNMARKED, an EU AI Act
Art. 50(2) provenance gap, after burning 30-40s on the first take and 5-8s on
each later one.
The compile is pure cost even where it succeeds. Measured on an M3 (5s of
24kHz audio, three consecutive embeds): compiled 9.70/0.26/0.23s vs eager
0.30/0.28/0.27s — a ~10s first-embed tax to save ~0.03s afterwards, on CPU
work already bounded by the 30s chunk loop. Both embed and detect now run
inside audioseal's own no_compile() switch, restored on the way out (it is a
process global, and other models are entitled to compile).
Verified end-to-end: first embed 9.70s -> 0.26s, watermark still round-trips
at confidence 1.0 with the OmniVoice message intact.
Fixes#1615
* fix(watermark): collapse the eager-guard globals into one lock-guarded state
CodeQL flagged _eager_saved's module-level initializer as dead, and it was
right: depth 0->1 always writes the field before depth 1->0 reads it, so the
None at import was never observed. Depth and saved-value are only meaningful
together and only under _eager_lock, so they become one dict rather than two
module scalars — which also drops the global statement.
Also splits three semicolon-joined statements in the regression test (Ruff
E702, CodeRabbit).
Mutation re-checked after the refactor: a naive no_compile() body still fails
with 'compile was handed back mid-embed'.
* fix(dictation): refresh accessibility blocker
* docs(changelog): note accessibility refresh
* test(dictation): assert the native widget hide on accessibility grant
The recheck regression asserted only that the Accessibility pill text left
the DOM, so it still passed with hideWidgetWindow() removed and the native
capsule stranded on screen. Hold one stable getCurrentWindow().hide spy and
assert it after the poll (fails before the fix, passes after).
Add the current v0.5 engine-switching GIF plus Model Catalogue and gallery-save screenshots to the canonical Docker Hub overview using absolute raw GitHub asset URLs. Includes a changelog entry.
Refresh current v0.5.0/0.5 tag examples, document API-key and share-PIN behavior, and require encrypted private-overlay access for remote deployments. Keeps the Docker install guide and changelog synchronized.
Fix server-mode admin authentication recovery without trapping PIN-only deployments, and prevent stale 403 responses from clearing or superseding newly issued sessions. Includes backend/frontend regression coverage, docs, and changelog credit for @paoloantinori.
Restore weekly full-history gitleaks scans by allowlisting only the exact cryptography type name Ed25519PrivateKey, with an exact-value regression guard and changelog entry.
Four-angle /simplify on the cumulative branch diff:
- The idle-reaper grace flag now lives entirely inside _get_generator's
lock: the prefetch claims it only when THAT call builds the model, and
every other getter call consumes it. This deletes the duplicated
call-site clears in embed/detect (detect no longer touches the
generator's grace at all — it was clearing a flag for a model it never
uses), and closes the lock-gap window where the prefetch's claim could
land on an already-used model, which the old comment claimed was
impossible.
- Shared _env_float(name, default) for main.py's three inline float-env
parsers (capture delay, watermark delay, MCP start timeout): one
NaN/negative-rejecting implementation instead of three drifting
copies; the older two lacked the isfinite guard entirely.
- Test cleanups: dead isinstance-Future assert half removed, the
fake-audioseal Event-wait simplified to sleep, the reaper-diversion
guard simplified to a plain no-op lambda, stale setdefault sentence
dropped from the conftest comment.
Skipped with reason: merging the double will_mark() gate (they guard
different invariants — pool creation vs model load, both tested) and
hoisting the reaper guard to conftest (an autouse module-attr patch
would break tests that verify release_idle_models directly).
CodeRabbit on 28c7bace:
1. (Major) get_watermark_pool's double-checked pattern re-read the
global after an unlocked null-check, so shutdown_watermark_pool's
reset could land in between and the caller received None. The
executor is now captured and returned under _watermark_pool_lock.
2. (Minor) the idle-grace test overwrote _prefetched_unused after the
embed call, making the embed's clearing unobservable — a failing
embed would have passed unnoticed. It now asserts the flag directly,
and a guard diverts any leaked idle reaper (idle_worker resolves
release_idle_models per call) to a no-op for the test's duration.
Second CI red on the same test, different assert: the conftest fix killed
the leaked PRELOAD task, but a test lifespan that exits without shutdown
also leaves idle_worker running, and idle_worker calls
release_idle_models on these same module globals from another thread —
re-stamping _last_used mid-test. Each phase of the test now re-
establishes its preconditions immediately before its release call and
pins now= to a far-future monotonic, so an interleaved reaper tick
cannot change the outcome. Verified against the full 5801-test suite
run in one process.
The shutdown drain killed the module singleton with no replacement, so
any process that keeps running after a lifespan shutdown — the CI suite
does exactly this — dead-submitted on the next watermark op: "cannot
schedule new futures after shutdown" (CI red; independently confirmed
by Greptile P1, CodeRabbit Major, and the plugin code review at 95/100
confidence). shutdown_watermark_pool() now resets the singleton under
its build lock before draining, so the next get_watermark_pool() hands
out a live replacement. Regression test covers
drained-pool-refuses + replacement-accepts.
Same round, minor findings: the drain's except now logs with exc_info
instead of a bare pass (GHAS CodeQL empty-except); the watermark delay
knob rejects negative/non-finite overrides (CodeRabbit); conftest sets
OMNIVOICE_PRELOAD_WATERMARK=0 unconditionally so a stray export from
the runner shell cannot re-enable background warm-ups mid-suite
(CodeRabbit).
Two CI failures, both understood:
1. test_shutdown_preload_race_1000 pins the production _cancel_and_await
_tasks call site by regex; the new fifth handle broke the pattern. The
guard now pins all FIVE handles (its property — every preload handle
awaited under one generous bound — is unchanged).
2. test_prefetched_model_gets_one_extra_idle_window flaked only in the
full suite: many tests boot the app lifespan, and any that exits
without a lifespan shutdown leaves the deferred watermark-preload
task pending — 35s later it fires mid-suite in another thread and
re-stamps _last_used under whatever test is running. conftest now
defaults OMNIVOICE_PRELOAD_WATERMARK=0 for the test session (a test
can still opt in), and the grace test neutralizes will_mark so a
leaked warm-up can't touch it.
Bot findings: Greptile P1 + CodeRabbit — cancelling the preload task
doesn't stop a watermark-pool thread already inside the ~42s cold
import, and nothing drained that pool at shutdown (only the GPU pool
was reset). Shutdown now drains the watermark pool's queue
(shutdown(wait=False, cancel_futures=True)) — bounded abandon, same
documented reality that Python can't kill a running thread. CodeRabbit
Major: the warm-up reads its own delay knob
(OMNIVOICE_PRELOAD_WATERMARK_DELAY, default 35s) instead of reusing the
capture-ASR delay, so a capture env override no longer retimes it.
CodeRabbit Minor: the _prefetched_unused claim/clear transitions now
happen under _generator_lock, so the retention grace can't be granted
to a model that has actually been used; the test fixture resets all
lifecycle globals.
Skipped with reason: gating prefetch on local-checkpoint presence — the
warm-up downloads only what the first embed would download anyway;
time-shifting that download is the feature, not a new network call.
The first mark_synthetic serialized the audioseal import plus the
generator load INSIDE the first synthesis — measured at ~42s inline on
a cold filesystem (macOS, 2026-08-17 report), pushing a cold first
synthesis to ~87s and 3s past a 90s client timeout. The generator now
warms on a background task ~35s after boot (+5s past the capture-ASR
warm so the two cold imports don't contend), on the watermark pool,
cancellable at shutdown (OMNIVOICE_PRELOAD_WATERMARK=0 opts out; the
pool is only created when will_mark() says watermarking is active, and
setup-half failures log immediately instead of surfacing at shutdown).
Because the prefetch thread races the first embed, the lazy builds now
hold per-model locks — one build per model, no cross-blocking: a
detector load no longer queues behind a ~42s generator build, and
release_idle_models takes both locks in a fixed order. A
prefetch-warmed, never-used generator survives ONE extra idle-reaper
window so a first synthesis shortly after boot still finds it warm;
real embed/detect use clears the grace.
Also: embed/detect failures now log the full traceback (exc_info). The
catch-all printed only the message, which today left a
ModuleNotFoundError('getopt') inside AudioSeal's forward undiagnosable
from the log — audio silently ships unmarked when this fires.
* feat(omnivoice): port upstream VoiceClonePrompt persistence + FlashInfer opt-in
Upstream k2-fsa teardown ports, verified with generated voice samples:
- VoiceClonePrompt.save()/.load() (upstream format v1, weights_only-safe)
on the vendored model, and a disk layer under the in-memory prompt LRU
(DATA_DIR/prompt_cache, keyed by ref path+mtime+ref_text+preprocess,
32 newest kept, OMNIVOICE_PROMPT_DISK_CACHE=0 opts out). First generation
of a session with a known voice skips the reference re-encode and any
auto-transcription pass — verified across two real processes (encodes=1
then encodes=0, same voice).
- omnivoice_flashinfer.py ported (packed CFG attention, fused kernels,
optional CUDA graphs), schedule adapted to our num_step+1 divergence.
Opt-in via OMNIVOICE_FLASHINFER=1|graph, CUDA-only, replaces
torch.compile for the session; missing package / apply failure / runtime
failure all degrade with a named reason (same #278 contract as compile:
classify → unapply → retry once, session latch). Measured 2.20x at
batch=1 on an RTX 4090 with byte-identical text and clean ASR round-trip.
- Docs: OmniVoice guide gains instruct+reference combination semantics
(consistent instruct stabilizes cloning, reference wins conflicts),
inline pronunciation control (pinyin / CMU), prompt persistence, and
corrects the 'no voice design' claim; performance.md documents both new
env knobs.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* chore: point changelog entries at the real PR number (#1565)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(pr): harden FlashInfer lifecycle + prompt-cache writes per review
Bot harvest round 1 (#1565): unapply on apply-failure (half-patched model
could crash the next render); pin eager-mode FlashInfer inference to one
thread too — the attention plan and packed position ids are per-generation
module state, so interleaved _gpu_pool workers would corrupt each other;
restore the CAPTURED pre-apply attention impl (could be flash_attention_2)
instead of assuming sdpa; unique tmp name per prompt-cache write; correct
the _forward_logits layout docstring; resolve VoiceClonePrompt at test
runtime; docs — Known limits keeps only the limitation, performance.md
states the VRAM cost and scopes the fallback claim to classified kernel
failures.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(pr): round-2 review — publish only a fully restored model, redact latch reason, tighten CPU-persistence test
Greptile: the runtime fallback now unapplies BEFORE swapping generate, so
a concurrent render keeps queuing behind the thread-affinity wrapper while
teardown mutates modules. CodeRabbit: FlashInfer failure reasons pass
through core.failure.sanitize before latching/logging (wheel paths embed
the user's home); the save-portability test now creates the tokens on CUDA
when available and asserts the persisted payload itself is CPU-resident.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(pr): fail-closed latch reason when the sanitizer itself breaks
CodeQL empty-except + CodeRabbit round 3: if core.failure.sanitize raises,
the raw reason (home paths, wheel paths) was latched anyway. Now only the
exception class survives with a fixed redaction note; two regression tests
(normal redaction + sanitizer failure).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* fix(test): watermark producer scan must match code, not prose
ee35d238 broke main's CI by adding a comment that *mentions*
backend.generate() to worker/transport/server.py — the watermark coverage
guard greps raw source, so the comment made the module a 'producer' that
never marks. Blank COMMENT/STRING token spans before matching (layout
preserved, unparseable files fall back to a raw scan) and apply the same
rule to the allowlist staleness check; a new self-test pins the class.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(test): keep f-string code scannable; require code-level mark_synthetic
Greptile P1 + CodeRabbit on #1564: on Python <=3.11 an entire f-string is
one STRING token, so blanking it would let a synthesis call inside a
replacement field evade the producer scan — f-prefixed strings now stay
raw there (fail closed), while 3.12+ blanks only literal FSTRING_MIDDLE
text. The 'module references mark_synthetic' certification is now also
code-only, so a comment can't satisfy it. Self-test extended with both.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
CodeRabbit #1562 findings, both real:
- makeLoader is now generation-guarded: the initial retry loop overlaps
freely with WS-triggered reloads, and a slow in-flight response could
resolve AFTER a fresher reload and overwrite its list with stale data.
Each invocation bumps a generation; only the newest may setState.
- The regression test awaited the queue via sleep-polling; it now uses
asyncio.wait_for(q.get()) so a failure surfaces as TimeoutError instead
of depending on 10ms poll timing (repo rule: no sleeps as sync).
PUT/DELETE /profiles (rename, delete, revoke consent) and the history/export
mutators are sync FastAPI endpoints: their bodies run in threadpool workers
where asyncio.get_running_loop() raises, so event_bus.emit() hit the
RuntimeError branch and silently dropped the "profiles" event. The UI only
refetches the voice list on that event, so after a rename the list kept stale
state, and a reload during that window could land on an empty panel (no
retry on the initial load either) — which reads to a user as "all my voices
are gone" even though nothing was deleted.
emit() now captures the serving loop in subscribe() and hands off from
foreign threads via call_soon_threadsafe (async callers are unchanged).
Also: the initial list loads in useAppData retry until FIRST success via
retryInitialLoad — a WS-triggered reload failure still keeps the previous
list, but the first load has nothing to keep. Loaders gained {rethrow: true}
for the initial path so the retry actually engages (they swallow errors by
design elsewhere); an integration test pins that wiring.
Tests: tests/test_event_bus_thread_emit.py fails on the old emit (verified
by stashing the fix) and passes with it; a live two-instance probe confirmed
PUT rename → WS event arrives on the fixed build and never on the original.
* feat(settings): compute-device override — auto | CUDA | ROCm | XPU | MPS | CPU
Auto-detect stays the default; the override kills the 'auto-detect picked
wrong' issue class. Applied at the single choke point (_probe()'s family
selection) so routing, get_best_device(), and every badge inherit it.
Resolution: OMNIVOICE_DEVICE env > Settings pick (prefs.json) > auto (#981
pattern). An override can steer, never invent hardware: a family the host
lacks is noted and ignored; cpu is always honorable. Applies at next
backend start (host caps are immutable per process — same restart contract
as the rest of the Performance tab, RestartBadge shown).
GET/PUT /api/settings/compute-device (admin-gated) reports resolved vs
applied so the panel shows restart-required truthfully and disables itself
under an env pin instead of pretending.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs(changelog): entry for the compute-device override (#1557)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(device-override): harvest — the override reaches CT2 ASR, full i18n, honest edge states
- _ctranslate2_cuda_ok() and the ASR sidecar now gate on the probe's family,
so a cpu pin (or ROCm host) can never hand CTranslate2 a CUDA device —
the override reaches every CT2 loader through one shared gate
- override_ignored exposed by the API and shown by the panel (env pin naming
a device this machine lacks: auto is in effect, restart won't change it)
- all 8 panel strings + 5 device-family labels translated into all 21
locales; failed saves keep their error visible through the re-sync
- test isolation: cleanup drops OMNIVOICE_DEVICE before re-probing so no
overridden caps leak into later tests; panel tests wait for loaded state
- xpu/intel search keywords; oxfmt formatting
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(device-override): round 2 — fail-safe probe fallbacks, complete i18n, combined pin state
- a broken capability probe now means CPU everywhere (CT2 gate + ASR
sidecar) — never a torch-derived guess that would bypass a cpu pin or
re-open #1529 on ROCm; regression test added
- env-pinned AND not-detected shows both facts in one subtitle
- device_load_failed/perf_save_failed translated into all 21 locales;
CJK/th/vi/ar strings no longer say literal 'Auto'
- test_ctranslate2_never_gets_cuda_on_a_rocm_build pins the probe family
(it was order-dependent on the lru_cache before)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(test): pin the probe family in the faster-whisper OOM-fallback test
Same class as the rocm-build test: it mocked torch but not the probe the
new override gate consults first, so on a cpu-family CI host the CUDA
fallback chain under test was unreachable.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* docs(engines): a guide for every engine + index; fix two engine-metadata bugs
21 new pages under docs/engines/ (10 TTS, 10 ASR, index README) — every
registered engine now has one: what it's for, platform support, model env
vars, quirks with issue refs. Linked from both READMEs' engine sections.
Code fixes found while verifying facts against the registries:
- KittenTTS docstring claimed default voice 'Jasper'; the code default is
expr-voice-2-f
- the isolated-ASR sidecar read only ASR_MODEL_FW while the download
preflight read ASR_MODEL_FASTER — set one and the other quietly used a
different model; both now resolve ASR_MODEL_FW-override → ASR_MODEL_FASTER
- moonshine's install hint named 'useful-moonshine', a package the backend
never imports; now moonshine-onnx / moonshine-voice
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs(changelog): entries for the engine guides + sidecar model fix (#1556)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs(engines): second-harvest fixes — TLS guidance, matrix/code alignment, CN counts
- README matrix aligned to gpu_compat (the code is the source of truth):
CosyVoice macOS is CPU not MPS, IndexTTS and GGUF gain their real
CUDA/CPU/MPS cells
- gpt-sovits guide: prefer https/tunnel for non-loopback servers, plaintext
warning; first-use download guidance on both OmniVoice pages
- preflight empty-env fallback matches the sidecar (ASR_MODEL_FASTER='' no
longer resolves a different repo)
- nano installs via uv pip; kitten log level wording; index links install
guides incl. the Gatekeeper step; README_CN engine counts 16/11
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs(readme-cn): the all-engines-local claim now excludes the remote client
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* docs(readme): lead with download + first clone; seed benchmarks page
Quickstart (installers, install guides, a three-step first-clone walkthrough)
moves above What's-new/Features in both READMEs — visitors get the action
before the pitch. New docs/benchmarks.md anchors measured per-engine/device
numbers on the bench_pipeline.py harness, community-contributed, no estimates.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs(changelog): entry for the README conversion restructure (#1555)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(bench): emit RTF + CUDA peak VRAM; guard NaN RAM; define the benchmarks schema
Bot harvest on #1555: the tts stage now prints RTF per warm measurement and
CUDA peak VRAM (None elsewhere — no made-up zeros), the stage floor refuses
unmeasurable RAM instead of sailing past a NaN comparison (FLOOR_GB=0
overrides), docs/benchmarks.md columns map 1:1 to what the harness prints,
and the download badges say they open the release page.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs(readme): link palash.dev from the maker section
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(bench): name the resolved engine, track VRAM from resolution, comment the guards
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs(readme): the quick-switch gif is the hero image
The hero shows motion now; the Launchpad screenshot moves into the 0.5.0
What's-new slot so nothing appears twice.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(bench): peak VRAM is reserved memory; adapter engines name their model
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(bench): subprocess-isolated engines report VRAM n/a, not a parent-side zero
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(bench): out-of-process detection is declarative; sherpa rows name their model
'runs_out_of_process' is now a TTSBackend attribute set by SubprocessBackend
AND omnivoice-gguf (which inherits TTSBackend directly but spawns a binary
per generate — the isinstance check missed it). Duck-typed for the same
module-purge reason as _is_subprocess_isolated. Sherpa-onnx identity comes
from _model_dir's basename when _model_id is absent.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(bench): backends self-report model identity via TTSBackend.model_identity()
Greptile enumerated the adapter engines one at a time (mlx _model_id,
sherpa _model_dir, cosyvoice env-only) — the attribute sniffing rots per
engine. The hook fixes the class: each multi-model backend reports its
own identity, the profiler just asks.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* test(shell): backend-lifecycle fault-injection harness
Runs spawn_backend_and_wait/supervise_backend against REAL dying child
processes and asserts the user receives the correct NAMED diagnosis —
not merely that recovery happens. Wrong/missing explanation was 61% of
the historical "can't reach the backend" class; this rig is the
permanent regression harness for every future lifecycle fix.
Seam: OMNIVOICE_BACKEND_CMD (JSON argv or whitespace form) runs any
command as "the backend" — venv bootstrap and ffmpeg resolution are
skipped, everything else (err-log run offsets, drainer threads, env
pinning, real OS pipes, spawn-failure diagnostics) stays real. Plus
OMNIVOICE_LOG_DIR (per-test log+marker dirs, also a support tool) and
harness-only timing overrides OMNIVOICE_STARTUP_BUDGET_S /
OMNIVOICE_SUPERVISOR_POLL_MS whose production defaults are pinned by
unit tests. Lifecycle fns genericized over tauri::Runtime for the
MockRuntime app; behavior-neutral with the env unset (unit-pinned).
Scenarios (tests/backend_lifecycle.rs, scenario children = this test
binary re-invoking itself; serial by mutex + CI --test-threads=1):
- port conflict (exit 78) → the detectHints-matchable port phrasing
- generic chained traceback → root cause survives into the diagnosis
and the crash marker
- spawn failure → spawn diagnostic reaches the user, NO bogus marker
- slow start past budget → timeout names the budget + last stderr
- post-Ready crash loop → 3 restarts announced, markers before restarts,
"kept crashing" diagnosis naming the last exit
- SIGKILL (unix) → named as signal 9
- deliberate kill → supervisor yields silently, no marker, never Failed
- deferred-startup FATAL → the named step reaches the user, forensics,
and the splash narration
CI: harness added to the 3-OS tauri-cross-platform matrix.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* chore(ci): temporary Windows loader bisect probe for the harness binary
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(shell): embed Common-Controls v6 manifest into Windows test binaries
Bisected on #1551: EVERY integration-test binary of this crate died at
load on Windows with STATUS_ENTRYPOINT_NOT_FOUND (0xc0000139) — cargo
gives test binaries no manifest, so the loader resolves comctl32 v5,
which lacks the TaskDialogIndirect entry point tauri's dialog/tray stack
imports. build.rs now embeds tests/windows-test.manifest via
rustc-link-arg-tests on Windows targets. Bisect probe removed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(test): scenario gate is PID-valued — the parent can't self-inject
CodeRabbit on #1551: in a parallel local `cargo test`, the parent's own
scenario_child test could observe the armed env and start playing the
backend in-process (binding the port, idling 600s). The gate value is
now the arming process's PID; a matching PID stays inert, so only the
spawned child — a different process — runs the scenario.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* feat(startup): bind the socket in ~1s and narrate startup step by step
The structural fix for the "can't reach the local backend" class (~1 in 5
of every issue ever filed): uvicorn served nothing until torch import
(10-20s cold), the 30-router fan-out, an import-time DB migration, the
cuDNN preload, and alembic all finished — every slow or fragile step
rendered as an unexplained dead backend.
main.py now keeps module scope fast and defers the heavy work:
- _phase_a_build (executor thread): prefs/env restore + #963 migration,
yt-dlp overlay, cuDNN preload, torchaudio, model_manager, router
imports — order preserved, literal imports so PyInstaller still traces.
- _phase_a_finalize (event loop, no awaits → atomic wrt requests):
include_router, mounts, MCP, SPA, openapi bust.
- _phase_b: the old lifespan startup body; handles on app.state so
shutdown survives a startup that never finished.
- Eager mode (pytest / OMNIVOICE_EAGER_INIT=1) runs everything at import
— byte-equivalent behavior for the ~100 lifespan-less TestClient sites
and for embedders (dump_api_routes, probe boot runner opt in).
While starting: /health answers 503 with the current step, new
/startup/progress serves the full ledger (always 200), and
StartupGateMiddleware 503s everything else with the [starting] marker
(same skip-the-Report-button convention as [shutting_down]). A deferred
failure keeps import-crash semantics: traceback to stderr → shell crash
forensics, run sentinel stays uncleared, exit 1 names the failed step.
Shell: startup_progress() probe (marker-header-gated so a foreign
responder can't narrate the splash) feeds per-step log lines into the
launch poll and the supervisor's reconnect wait. --health-check absorbs
the deferred init (60→180s); --diagnose runs Phase A up front so it
still sees restored prefs. Docker HEALTHCHECK semantics unchanged
(curl -f fails on 503 exactly as it did on connection-refused).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(startup): join the Phase A thread on shutdown; async fail-path sleep
Bot-review harvest on #1550: cancelling the deferred-startup task cannot
stop the executor thread inside Phase A's blocking imports — shutdown now
waits (bounded, only when a build started and hasn't finished) on a
thread-completion event so interpreter teardown can't race a mid-import
(#1000 class). The failure path's last-poll beat is now awaited, not
time.sleep — a blocking sleep froze the very loop that beat exists to let
serve. Also: dump_api_routes forces eager (assignment, not setdefault),
and the integration test's child gets DEVNULL instead of an undrained
pipe that could wedge a cold boot.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(startup): close the Phase A submission race; CodeQL nits
Review finds on #1550: shutdown could sample _phase_a_started unset
while the executor callable was queued-but-not-running, skipping the
thread join. started is now set BEFORE submission, the submission is
shielded so a cancel can't strand a queued callable that would never set
_phase_a_finished, and the wrapper sets finished on every exit including
the already-built early return. Contract pinned by
test_phase_a_thread_join_contract. Plus explanatory comments on the new
bare excepts and a consistent return in the gate's websocket branch.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* fix(shell): gate Ready on the deep health probe; pace crash-loop restarts
Two supervisor hardenings from the backend-reliability root-cause pass:
Ready now requires backend_ready() — the identity probe (/system/info
string-sniff) AND the deep probe (/profiles must 200) — at both Ready
transitions (startup poll, supervisor respawn wait). The shallow probe
alone announced a backend whose install/DB had broken underneath as up;
the UI looked alive while every real request 500'd or dead-ended on
"can't reach the backend". Death detection stays process-exit-only, so a
busy-but-alive backend is still never killed.
Supervisor respawns now back off: first respawn immediate (a one-off
crash self-heals fast), then 5s, then 15s, capped — the budget check
ends a hopeless loop, not an unbounded sleep. The pause runs behind the
already-visible "reconnecting" banner and yields within 500ms to app
quit or a deliberate retry-flow replace.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(shell): yield backoff to a tracked replacement child, not just the flag
Greptile P1 on #1548: a completed Retry/Clean&Retry sets the deliberate-
kill flag and track_backend_child clears it — possibly both between two
500ms backoff samples, so the flag alone can be missed and the old
supervisor would free_port() the retry's healthy replacement. The dead
child we observed can never read as alive again, so a live tracked child
during backoff can only be a replacement — yield to it promptly so the
retry's spawn_backend_and_wait can claim the supervisor slot at Ready.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(shell): backoff yields on spawn-generation change, not liveness
Second Greptile pass on #1548: a replacement child that itself exits
before the old supervisor's next 500ms sample read as "still dead" under
the liveness check, so ownership transfer was missed. The spawn
generation (bumped by every track_backend_child, never un-bumped) is
observable regardless of the replacement's fate — snapshot it at death
detection, yield the moment it changes.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(shell): snapshot spawn generation before observing the exit
Third-pass review find: sampled after try_wait, a replacement tracked in
the gap bakes its own generation into the snapshot and the ownership
transfer is missed. Snapshot first, and re-check once more before
touching the port so the zero-backoff first respawn is covered too.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* feat(triage): crash-class recurrence report — the reliability metric
scripts/crash_class_report.py measures the "backend died / never came
up" class (the project's #1 lifetime failure, ~1 in 5 of all issues)
filtered to reports from the current version — the definition of done
for the reliability cycle. Buckets by the bug reporter's Build-status
stamp (#1547): current / outdated / unknown (pre-deflection builds), so
deflection-miss noise never pollutes the number the work is judged on.
tests/scripts/test_crash_class_report.py pins the title→sub-class
mapping against the real historical title shapes and locks the stamp
literals to frontend/src/utils/bugReport.js so a reworded marker fails
in CI instead of silently zeroing the metric.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(triage): --version is authoritative; loud fetch-cap warning
Bot-review harvest on #1549: with --version, the Environment Version
line now decides the bucket (extracted to pure classify_build + tests) —
a report stamped "current at filing time" during another version's
window no longer counts toward this version's recurrence. Hitting the
500-issue fetch cap now warns loudly instead of silently understating.
The stamp lockstep test asserts the full Build-status prefix.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* fix(report): offer the latest release before filing from an outdated build
6 in 10 sampled "can't reach the backend" reports came from builds that
were already obsolete when filed, and were closed with "please update" —
pure triage noise. Every Report-bug affordance now funnels through
openBugReport(): on an outdated build it offers the latest release first
(with a "File anyway" escape hatch), and the report body carries a
triage-greppable "**Build status:**" line either way, so current-version
recurrence — the reliability metric — is countable separately from
stale-build reports.
Freshness sources per deployment (behavior identical, implementation per
mode): desktop reads the Rust updater's channel-aware verdict from the
store (no new network path, no CSP widening); browser/dev/Docker make one
bounded latest-release GET, only once the user has initiated the report
flow whose destination is github.com. An 'unknown' dev build stays
silent entirely — never nudged, never claimed current.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(report): anchor version parsing; zh-TW reportBug.title in Traditional
Bot-review harvest on #1547: parseVersionTriple now rejects trailing
non-semver data (1.2.3.4, 1.2.3garbage) instead of silently reading the
leading triple into an outdated/current verdict; the pre-existing zh-TW
reportBug.title was Simplified-script — now properly Traditional.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Defer and coalesce omnivoice.app and omni_ui persistence behind a 250 ms
quiet window with a 1,000 ms hard maximum, preserving storage schemas,
synchronous pending reads, legacy formats, Factory Reset semantics,
widget read-only ownership, and lifecycle (pagehide/visibilitychange)
durability. Adds scheduler, restore, reset, StrictMode, concurrent-render,
role-ownership, and migration regression tests plus an opt-in
production-bundle responsiveness harness.
Lands #1541 by @bultodepapas (maintainer landing branch; the out-of-scope
attribution-policy commit was dropped).
Co-authored-by: Gius <bultodepapas@gmail.com>
resolve_within split candidate paths on os.sep alone. Windows accepts /
as a real separator but os.sep is \ there, so a persisted sub-path such
as "job_123/out.mp4" stayed a single component, failed the
basename-equality check, and raised UnsafePath — while the identical
value split cleanly and resolved on POSIX. A data directory written on
Linux or by the Docker deployment and then opened by the Windows desktop
app hit exactly that.
Split on both separator families instead, which is what the comment
above the split already states the code intends. This is not a
loosening: every component still goes through the same basename / "." /
".." / empty rejection, and the commonpath containment check and symlink
resolution below are unchanged. POSIX behaviour is unchanged too — a
backslash is already rejected there as a foreign separator before the
split runs.
This also restores real coverage of the symlink-escape guard on Windows.
test_resolve_within_rejects_symlink_escape asserts through
"link/secret.wav", which previously raised at component validation
before reaching the containment check it exists to cover, so it passed
for the wrong reason. It now matches on the reason.
* fix(release): harden the AppImage repair step
All three review findings on #1544, fixed before the tag re-runs it:
- appimagetool pinned to the immutable 1.9.1 release with a verified
SHA-256 — a mutable 'continuous' binary must not execute with the
updater signing key and a release-write token in its environment
- the release tag reaches the script as env data, never interpolated
into shell source (zizmor template-injection)
- a failed latest.json download now fails the step unless the asset is
confirmed absent, and the patch refuses to upload unless at least one
linux signature was actually replaced — a repacked AppImage can never
ship paired with stale updater metadata
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(release): the updater manifest gets one final writer, after the matrix
CodeRabbit + Greptile on #1545: every tauri-action leg re-uploads the
shared latest.json, so patching it inside the Linux leg races the other
platforms — a later leg's upload could resurrect the stale pre-repack
signature. The manifest patch moves to a post-matrix job that runs once
after all legs: it aligns the manifest's linux entries with the .sig
asset that actually shipped (self-verifying — no cross-job state), and
no-ops when they already agree. The leg keeps asset repack/re-sign only.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(release): a failed .sig download fails the manifest-align job
Same fail-closed rule as the manifest itself: absence is decided by the
asset list; any other download failure must not exit 0 with a stale
signature left in latest.json.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
The v0.5.0 tag build — the first real release since #1518's guard — proved
the files-map fix loses: linuxdeploy re-links .DirIcon to an ABSOLUTE
build-machine path after tauri places the real bytes, and the guard
correctly refused to publish. tauri-action's atomic build+sign+upload
leaves only a post-upload seam, so the Linux job now repairs the packed
artifact: extract, replace .DirIcon with the icon bytes as a regular file
(nothing left to dangle), repack with appimagetool, re-sign with the
updater key, clobber the draft release's asset and patch the linux
signature inside latest.json. The existing smoke then validates the
repaired AppImage. No-ops cleanly when .DirIcon already resolves.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
CodeRabbit's last-cycle Minor on #1540, applied as the immediate
follow-up the docs-sync rule prescribes: three OmniVoice mentions in
docs/remote-workers.md now carry the product's name.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* chore: bump to 0.5.0
Owner-requested minor bump. package.json is the source of truth; the
three mirrors (Cargo.toml, pyproject.toml, _FALLBACK_VERSION), the three
lockfiles and the branding pin move in lockstep, and the accumulated
Unreleased section becomes the curated 0.5.0 release notes — quiet
Highlights first, one-liner subsections after, duplicates folded.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs: fresh README for the VoiceStudio era; 0.5.0 notes wear the release
README: 551 lines from 656 — a What's-new-in-0.5.0 section with real
captures, the engine tables corrected to the actual 16 TTS registrations,
a stale Settings path and a broken Colab link fixed, roadmap/FAQ/credits
trimmed to what earns its place.
Release notes: the quick-switch GIF and catalogue/gallery screenshots,
captured from the running app during the pre-bump test pass, embedded
after the Highlights; #1542's gallery work and the ffmpeg CI fallback
recorded in their subsections.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs: the feature inventory's Remote Model Downloads mention survives the README trim
check-docs-drift requires every docs/features.yaml name verbatim in the
README; the overhaul folded the phrase away. It now lives in the remote
workers feature line.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs: one blockquote, and an architecture claim that survives the opt-ins
CodeRabbit on #1540: MD028 blank line inside adjacent blockquotes, and
'every layer is on your machine' contradicted the opt-in remote paths
documented two sections away — it now states local-by-default with the
opt-ins named.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* feat(gallery): save gallery voices as profiles, with validated audio references
Work-in-progress lifted from the concurrent gallery session at the
owner's request (its uncommitted working tree, preserved verbatim from
base 92b1ee5d; safety snapshot remains at rescue/gallery-wip):
- gallery voices can be saved as local profiles: audio is copied into
the profile store with content-addressed filenames, existing profiles
are detected and refreshed only when the source clip changed
- backend/core/audio_validation.py: symlink-rejecting, root-contained
resolution for persisted profile WAV references, with tests
- archetype/community routers and the Voice Gallery UI updated for the
save-as-profile handoff (spec: docs/specs/longform/26-gallery-use-handoff.md)
- locale updates for the new gallery strings across all 21 files
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* chore: drop a stray local screenshot script that rode in with the tree copy
* fix(community): explain the tolerated Content-Length parse failure; drop an unused import
CodeQL on #1542: the empty except now says why it is safe (the streamed
byte counter enforces the same cap regardless), and the test file loses
an unused Path import.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(gallery): review findings — copy outside the write lock, no stale completions
CodeRabbit on #1542, all findings addressed:
- the profile-audio copy stages to a .part temp BEFORE BEGIN IMMEDIATE
and publishes via atomic os.replace inside it — other backend writers
no longer block for the duration of an audio copy; a mid-copy failure
leaves no temp droppings and no profile row (both pinned by tests)
- VoiceGallery async ops carry per-operation generation tokens: a
preview or save-as-profile that resolves after unmount (or after a
newer operation) can no longer play audio, redirect into a workspace,
or touch state — three fail-before regression tests
- VoiceGalleryActions imports the page at test runtime; the e2e locator
uses a stable data-testid instead of a translated string; symlink
tests skip cleanly where the OS can't create symlinks; the changelog
line carries its PR ref
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* ci: static ffmpeg fallback when the chocolatey feed is down
Third feed outage to break a PR run (2026-07-20, 2026-07-28, today —
three attempts, three 'installed 0/1'). Chocolatey is a distribution
channel, not the dependency: after the retry loop exhausts, fetch the
static gyan.dev build from its GitHub release mirror and put it on
PATH — same binary, no feed in the path. URL verified live (HTTP 200).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
An RX 7900 XTX in the :rocm image crashed ASR init with 'CUDA driver
version is insufficient for CUDA runtime version' (#1529): ROCm torch
answers torch.cuda.is_available() and hands out 'cuda' device strings,
but whisperx/faster-whisper run on CTranslate2, whose CUDA runtime is
NVIDIA-only. Same class as the Apple/#1127 lesson, on the AMD axis.
- _ctranslate2_cuda_ok(): 'cuda' for CTranslate2 only when torch is a
real CUDA build (torch.version.hip is the honest tell); ROCm hosts
take CPU int8 instead of a native crash.
- _auto_detect(): on a ROCm-GPU host prefer pytorch-whisper — a pure
transformers pipeline riding torch itself, so it actually uses the
HIP GPU while CTranslate2 engines would idle on the CPU.
Fail-before/pass-after: 4 new tests fail on the old device pick/order.
Fixes#1529
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* feat(engines): open the door between the LLM family and its providers
The openai-compat family entry and the LLM Providers panel are one
system — llm_backend resolves every call through the active provider —
but the UI presented them as unrelated (council coherence finding). Now:
- the catalogue's openai-compat row carries a 'Provider · model' hint
naming the endpoint that actually answers (decorative: a provider
registry hiccup degrades to no hint, never a failed listing)
- the row offers 'Configure providers' straight into Settings → LLM
Providers; the panel gains the backlink into catalogue → LLM family
- three new strings in all 21 locales, matching each file's provider
terminology
Also: bugReport's encoded-ceiling test is hermetic now — it was the one
test in its file trusting ambient fetch, and hung on any machine where a
local backend holds the port without answering.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(i18n): the catalogue note names the ACTIVE provider, not the edited one
CodeRabbit on #1538: the panel can be editing a provider that is not
active, and 'this provider answers…' then points at the wrong one. The
note now says the provider MARKED ACTIVE answers, which is true under
any selection — no state-dependent copy needed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* fix(security): replace persistent admin keys with sessions
Exchange the remote administrator key once for bounded, revocable credentials. Canonicalize backend principals, enforce cookie CSRF and exact origins, and use path-bound one-use WebSocket tickets.
Migrate the bundled UI away from durable master-key storage and credential-bearing URLs. Add unit, integration, static-hygiene, and production-browser regressions plus synchronized operator documentation.
* docs: link session hardening to PR 1528
* fix(security): key session indexes with process pepper
Use HMAC-SHA-256 instead of an unkeyed digest for in-memory session and WebSocket-ticket indexes. This preserves constant-size lookup identifiers, makes copied records unusable without the process pepper, and resolves CodeQL's weak sensitive-data hash finding.
* fix(auth): align empty bearer migration precedence
Centralize the Authorization-channel presence decision with canonical principal parsing. Bearer followed only by spaces now remains an empty channel during legacy cookie migration, while unsupported or invalid explicit credentials stay authoritative and fail closed.
* fix(security): harden admin session review boundaries
* fix(security): derive key generations with HKDF
* fix(auth): anchor the admin-session store so module reloads cannot fork it
test_master_exchange_does_not_bypass_pin_on_normal_routes failed in full-suite
runs: test_mcp_bindings' client fixture purges the services.* tree from
sys.modules and reloads main, so api.routers.auth re-imported a fresh
services.admin_sessions (new AdminSessionStore) while core.auth kept its
import-time reference to the old one — the exchange issued the cookie into
one store and the middleware resolved it against another, turning the
expected "PIN required" into "API key required".
Root cause is the class of bug, not the one test: a process-global auth
store defined as a bare module-level singleton forks under importlib.reload
or purge-and-reimport. Fix at the source: admin_session_store now resolves
through a synthetic sys.modules anchor (_omnivoice_admin_session_store_anchor)
that reloads never re-execute and package-prefix purges never match, so every
copy of the module shares the one per-process store. No consumer or behavior
changes.
Regression test reproduces both fork vectors (in-place reload and
sys.modules purge + fresh import) and asserts previously issued sessions
still resolve and the store identity is preserved; it fails before this fix
and passes after.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(auth): honor X-Forwarded-Proto for CSRF origin and Secure cookies behind TLS proxies
Behind Tailscale Serve (docs/remote-gpu.md) or any TLS-terminating proxy,
the browser talks https while the backend hop stays http, so exact-origin
CSRF compared an https Origin against an http expectation and rejected
every legitimate request, and the session cookie shipped without Secure.
uvicorn's ProxyHeadersMiddleware only rewrites the scope for loopback
peers, which misses Docker and any non-loopback proxy topology.
New core.csrf.effective_scheme derives the client-facing scheme: resolved
scope first (uvicorn's trusted-proxy rewrite wins), then an upgrade-only
read of X-Forwarded-Proto's first value — https/wss promotes http to
https, everything else is ignored, and a genuine TLS hop can never be
downgraded. Used by both the destination-origin comparison and
auth._secure_cookie so the WS-ticket/logout CSRF paths and the cookie
Secure flag agree. Spoofing gains nothing: the host:port half of the
origin tuple is untouched, browsers cannot attach the header cross-site
without a preflight this API never grants, and forging it on plain http
only adds Secure (the browser then drops the cookie — self-harm only).
Regression tests: proxied https origin accepted (origin check, Secure
flag, logout), comma-separated chains, scope-fallback path, spoofed
header still rejects cross-origin, cannot downgrade real https, junk
values ignored.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(auth): consume the stored admin key only after a successful exchange
A remote-backend user upgrading with their backend unreachable lost the
only stored copy of OMNIVOICE_API_KEY: every migration path deleted the
durable ov_api_key BEFORE the session exchange settled, stranding them
until they recovered the key from the server box. Close the whole class:
- client.ts bootstrap: read the legacy key, exchange first, and remove
the durable copy only after the exchange succeeds; on failure the key
stays so the next launch retries the migration (auth gate still rises).
- authSession.ts exchangeApiKey: move removeLegacyMaster from before the
fetch to the cookie/bearer success paths — the key never coexists with
a live session, but a rejected or hung exchange no longer consumes it.
- remoteBackendProbe.ts configuredRemoteBackend: stop wiping the key on
every app mount.
- RemoteBackendPanel: a connection test or an aborted save no longer
wipes the pending key; only disabling the remote backend discards it.
- prefKeys.js: ov_api_key moves from PREF_KEYS to PRESERVED_KEYS —
factory reset preserves the pending connection credential exactly like
ov_backend_url; the successful migration is what deletes it.
Tighten the credential-hygiene static guard to match: it accepted
sessionStorage.setItem('ov_api_key', …) — the exact class it exists to
close. The guard now flags .setItem(<master key>) on any storage
receiver, quote style, or injected-store alias, with a self-test pinning
what it catches and what stays legal.
Fail-before/pass-after regression tests: backend unreachable retains the
key and the next bootstrap retries it; a successful exchange removes it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* perf(auth): make session validation occupancy-independent
* test(auth): catch optional master-key storage calls
* feat(docs): add PR control document for bultodepapas in VoiceStudio
* docs: keep the PR tracking board in the fork; credit the changelog line
The pr-control document is excellent process discipline, but it is the
contributor's own operational board (their inventory, their update
commands) — it lives naturally in their fork, and docs/agents/ here is
context every repo agent loads. Removed with appreciation; the changelog
line gains its contributor credit.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: debpalash <4178343+debpalash@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* fix(worker): reserve the capacity slot before announcing the accept
Two changes for #1536 (flaky test_worker_at_capacity_rejects_without_penalty):
The client now inserts into _running BEFORE awaiting the accept send.
Today _send enqueues synchronously so the old order could not actually
interleave — but the reserve-then-announce order is the invariant that
stays correct if _send ever gains backpressure (a bounded outbox is the
natural evolution), instead of silently reopening an over-accept window.
If the accept send fails, the reserved task is cancelled: work the
scheduler never saw accepted must not run to double-execution.
The test now pins the real invariant — no over-concurrency — rather than
the scheduler's bookkeeping timing: on a loaded CI runner the first
attempt can die environmentally (a stream hiccup fails _run, whose
finally frees the slot), after which accepting the second task is the
CORRECT behaviour the old flat assertion punished as a failure. The
assertion now applies only while the first attempt is still running, and
names the over-accept explicitly when it fires.
Fixes#1536
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(worker): release the reserved slot on a cancelled accept-send
CodeRabbit on #1537:
- except BaseException, not Exception: a handler cancelled while the
accept send is in flight must release the reserved slot too, or the
unaccepted task keeps running and double-executes after reassignment.
Fail-before/pass-after regression test included.
- the capacity test now polls the second task out of its dispatch states
instead of sleeping 0.5s, and asserts the penalty-free invariant
(excluded_workers empty) unconditionally — capacity rejections never
exclude the worker regardless of the first attempt's health.
- TaskAccepted.envelope finding skipped: the field is read nowhere
server-side (registration is the only envelope consumer) — pre-existing
unused-field design, not introduced here.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* fix(i18n): 53 default-value-only strings now speak all 21 languages
Every string used via t(key, { defaultValue }) without a locale entry
rendered English for every non-English user — the worker/compute chrome
(quick settings, join panel, QR enrolment), clone/design labels, workspace
voice strip, and two crash explainers. All 53 keys now exist in en.json
and carry reviewed translations in the 20 other locales, matching each
file's established terminology (existing worker/token/engine vocabulary,
catalogue tab names for the in-text path references, registers preserved,
{{placeholders}} byte-identical, the ovw_ token prefix untranslated).
The two crash explainers are translated from their FULL concatenated
source text — the extraction initially captured only the first string
segment, which src/test/streamDropError.test.ts caught by failing on the
missing proxy/buffering guidance.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(i18n): reference localized UI labels inside diagnostic strings
CodeRabbit on #1534, fixed as the class: every locale's crash_broken_env
quoted the English "Clean & Retry" although the button itself is
localized — all 19 now quote each file's own clean_retry label. Plus the
flagged singles: es unload verb disambiguated from downloading, hi unload
verb aligned with crash_oom_kill, sv kontrollplan gender agreement, de
crash_broken_env moved to the file's Sie register, ru seed_reroll_hint
mistranslation, zh-TW path label matched to the real 系統日誌 section name.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
The same cover artwork the project uses on the web, bleeding in from the
hero's right — where the layout holds only air — behind a radial feather
plus right-edge fade so no box edge survives, screen-blended so its dark
field vanishes into the chrome. Decorative: aria-hidden, empty alt,
pointer-inert; 22 KB webp bundled via vite.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* fix(crash): bound crash evidence to the run that produced it
backend_err.log is one file shared by every backend run, and it was
TRUNCATED on each spawn. Both properties destroyed the evidence a crash
marker exists to carry: a respawn wiped the dead process's final words,
and an unbounded tail read afterwards attached the replacement's healthy
startup to the old run's crash marker — the undiagnosable report in #1510
(startup lines, no traceback, timestamps after the recorded crash).
The file is append-only now with a run-start header; each spawn records
the byte offset where its run begins; and every death path (crash markers,
venv-heal detection, restart-budget message, the 300s startup timeout)
reads through read_error_log_tail_for_run(), which cannot see another
run's output. The log rotates to backend_err.log.1 past 1 MiB so
append-only cannot grow unbounded. Bootstrap-phase reads (uv sync) keep
the whole-file reader — no backend run exists yet there.
Fail-before/pass-after: the new tests fail under the old File::create
truncation and unbounded tail.
Fixes#1510
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(crash): flush the dying run's stderr before the next offset; redact home paths
CodeRabbit on #1532:
- the stderr drainer is tracked now and joined (2s bound) before a new
spawn records its offset, so a dead run's buffered tail cannot be
appended after the new run's start and misattributed. Full per-child
offset binding is unnecessary: spawns are serialized by the #1223
spawn-once flow; the buffered tail was the only remaining window.
- the spawn-failure diagnostic redacts the home-directory prefix — it is
retained across runs now and lands verbatim in bug reports.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
The catalogue's group captions read "Available" / "Not installed", which
renders a fresh install (3 of 16 engines ready) as a mostly-broken app.
The sections now say "Ready to use" / "Add more engines", and the
unavailable-row details toggle asks "What it needs" instead of "Why
unavailable?" — same information, framed as headroom to unlock.
Council outcome (first-run seat): unavailable engines must read as more
you could install, never as brokenness. Keys added to all 21 locales.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* feat(engines): add quick switching controls
* docs(changelog): note engine quick switching (#1530)
* fix(support): theme amount cards
* fix(support): restore themed amount cards
* fix(engines): address quick switch review findings
* fix(engines): green the full frontend suite around the quick switch
Three failure classes the targeted runs missed:
- the popover referenced --chrome-radius, which does not exist; it now
wears the footer's shared MENU_SURFACE like the compute popover
- LogsFooter tests hand-wrote their api/system and api/hooks mocks, which
drop every export the footer gains next; they are partial mocks now
- DubHeader/AudiobookHero tests rendered without a QueryClientProvider,
which useEngines needs
Also: workspace-header chips open the popover downward (dropUp stays on
the footer instance) so it cannot clip off the top of the viewport.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(engines): one QueryClient per test module, not per render
CodeRabbit: the inline client made every wrapper render a fresh cache,
so rerender() restarted the /engines query mid-test.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* fix(wayland): rewrite a stale portal identity instead of trusting it
Found live: with the desktop entry's Exec pointing at a binary that had
been moved, GLib resolves the entry to NULL, the host portal answers
"Could not register app ID: App info not found", CreateSession then
fails with "An app id is required" — and the dictation shortcut is
silently dead for the entire session. Only the focused-window fallback
keeps working, which reads as "the shortcut randomly stopped".
ensure_desktop_identity() trusted any existing entry. It now validates
the USER-LOCAL entry's Exec target and rewrites the entry when the
program is gone (parsing both the current quoted spelling and the
unquoted one older builds wrote). System-dir entries stay untouched —
deb installs manage their own.
The stale-entry class is easy to hit in the wild: a dev entry pinned to
target/debug survives cargo clean; an AppImage entry survives the file
being moved or renamed.
(Rebuilt from the first push, whose `git add -A` had swept in another
working session's unrelated in-progress files; this commit carries only
the wayland fix and its changelog line.)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(wayland): read Exec from the Desktop Entry group only
CodeRabbit, #1526: find_map over every line accepted an Exec= from a
[Desktop Action …] group, so an entry with no main-group Exec — which
GLib resolves to NULL — could be retained as healthy, keeping exactly
the stale identity the rewrite exists to replace. Parsing is scoped to
[Desktop Entry] now, with an action-only regression case.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* test(asr): parse the guard, don't grep it
Two Majors CodeRabbit raised on #1523 — which I merged before reading
them, so this is the follow-up rather than a fix on the branch.
- Approved files were matched by BASENAME, so any future
`<anything>/asr_backend.py` was exempt from the guard it exists to
enforce. Matching is by relative path now; a decoy
`backend/engines/asr_backend.py` calling the selector is caught.
- Detection was a line regex, wrong in both directions: it missed
`import get_active_asr_backend as pick` and fired on the name inside
docstrings and comments. It walks the AST now, alias-aware, so only
real calls count.
Both verified by planting the exact bypasses: an aliased call in
services/tts_backend.py and the decoy module above. Neither was caught
before this change.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* test(asr): resolve the selector's bindings before flagging a call
CodeRabbit, #1524: matching any call named get_active_asr_backend also
reported a local helper or an unrelated object's method that happens to
share the name. False positives are how a guard stops being believed —
people add allowlist entries for code that was never the bug.
Bindings are resolved first now: a bare call counts only if the name was
imported FROM services.asr_backend, an attribute call only if it hangs
off a module alias for it. Six shapes are pinned in the suite — direct,
aliased and module-attribute calls flagged; a same-named local function,
an unrelated method, and the name inside a docstring not.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat(ui): one support page, and tabs where there were toggles
Sponsor, commercial licence and contact were three destinations for one
question — how do I support this / how do I reach these people — and each
one made you leave to find the others. They are now three sections of a
single page: support, licence, contact, in that order, separated by a
hairline rather than more chrome. Every existing entry point still works;
`initialView` scrolls to the right section instead of hiding the other
two, so the footer heart, the dub/export commercial-licence links and
Contact all land where they meant to.
ContactPage becomes `ContactSections` — the body without the shell — and
its "Support the project" CTA now scrolls up to the support section
rather than navigating, because that surface is on the same page.
Model Catalogue: the Engines/Models switch and the matrix's TTS/ASR/LLM
switch are tabs, not Segmented. These pick between workspaces, not
between the two states of one setting, and Tabs carries roving tabindex
and role="tab" from the primitive. The matrix tabs keep their active
engine chip and now keep their hover title too — Tabs passes `title`
through.
Tests: the pane/family switches are driven by pointer down, not click —
Radix activates on pointer down, so a bare fireEvent.click leaves the
pane unchanged and reads as a switcher that ignores itself. The contact
suite now covers the section (its host owns the header), and asserts the
support CTA scrolls without ever reaching for Ko-fi.
Full frontend suite: 2022 passed. The one unhandled `window is not
defined` rejection in the parallel run predates this change — same error,
same count, on the base commit.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat(engines): available engines first, and unavailable ones recede
Two things the matrix got wrong for a list you pick FROM: it rendered in
payload order, so a usable engine could sit under four you cannot select,
and an unavailable row was faded WHOLE — which took its status badge and
GPU chips down with it, the two things that say why it is unavailable.
Available rows now sort to the top, preserving registration order inside
each group (that order is meaningful — it puts the defaults first). The
name of an unavailable engine recedes instead, and its mark dims with it;
the evidence stays at full contrast.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(support): the footer heart returns you to the support section
CodeRabbit, #1522: App.jsx renders SupportPage in the same tree position
for donate / enterprise / contact, so React keeps ONE instance and only
swaps props. The scroll effect treated 'support' as "already at the top"
and returned early — correct for a fresh mount, wrong for the only way
this page is actually reached. Clicking the footer heart from the contact
section left you sitting on contact.
Every view scrolls now. The regression test drives the prop change the
way the router does and fails without the fix.
Also adds the (#NNN) refs the Unreleased entries were missing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* test(support): cover the enterprise route's licence section
CodeRabbit, #1522: the suite drove support and contact but not the third
destination — and every section renders regardless, so only the scroll
target proves the mapping. Uses the exact initialView App.jsx passes for
mode === 'enterprise'.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
The recurrence guard from #1512 scanned api/routers only. A service or
engine module that transcribes on a request's behalf skips ensure_loaded()
just as thoroughly, so the guard could be sidestepped by moving the call
one module down the stack — verified: adding a get_active_asr_backend()
call to services/tts_backend.py passes the router scan and fails this one.
The broader scan is the one thing #1519 did better than the fix that
landed in #1515; absorbing it here rather than leaving it in a PR that
now conflicts. Thanks @ahov520.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* refactor(launchpad): quieter, borderless design refresh
The launchpad carried decoration from an earlier direction: icon chips,
corner-hung count badges, a permanently visible filled arrow, uppercase
mono card titles, and a dotted stipple divider — plus a frame that had
been invisible since the app-wide border tokens were zeroed.
Rework it around what the borderless direction actually implies:
- Feature tiles get a whisper-faint surface instead of a dead frame, and
read as three bands (bare glyph + count / title + arrow / description).
`--card-hue` is spent sparingly — the glyph at rest, the surface, count
and arrow only once raised. Titles move to sans sentence case; counts
are plain tabular numerals. Lift softened 4px -> 2px, coloured glow ->
neutral shadow, plus an explicit focus ring and a staggered entrance.
- Hero drops the boxed "646" pill and the filled A/B-Compare button for
quiet type, with a hairline standing in for the separation.
- Section labels trade the dotted stipple for a single fading hairline;
rows are transparent until hover and reveal "Open" on hover/focus (it
stays in the DOM, so AT and keyboard always reach it).
- Hero, tiles, recent files, callout and project lists now share one
1180px column — previously only the top half was capped, so lists ran
edge-to-edge on a wide display while the deck stayed centred.
Two bugs found and fixed while doing it:
- Buttons that had `border border-solid border-transparent` removed fell
back to the UA default border and rendered a visible 1px outline. They
now carry `border-0` explicitly.
- `.lp-animate` used `animation-fill-mode: both`, so after the entrance
it kept owning `transform` — and animation-origin declarations outrank
normal ones, which silently killed the card hover lift. Now `backwards`,
which still holds the from-state through the stagger delay.
Also drops CSS the page has not rendered since #904: the cursor-spotlight
layer, the breath ring, and the per-card waveform strip.
Verified with headless renders at 1600/1280/940 and the empty state.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(dictation): decode Wayland portal signals and show the capture pill
The GlobalShortcuts portal declares Activated/Deactivated as
(o session, s shortcut_id, t timestamp, a{sv} options). We decoded the
timestamp as u32, so zbus rejected every signal with
Signature mismatch: got `(osta{sv})`, expected `(osua{sv})`
and the press was dropped as an invalid signal. Registration succeeded
and the desktop even reported the bound chord back, so the hotkey looked
wired up while doing nothing at all — on every Wayland compositor, for
the whole life of the feature (#1490). Decode the 64-bit timestamp, and
keep the 32-bit spelling as a fallback so a non-conforming portal
degrades to working rather than to silence.
With presses arriving, the second half of the failure showed: nothing
had shown the widget window since it became a hidden recorder host, so a
capture ran with no pill on screen — and a mic or Accessibility failure
rendered into a window nobody could see. Add show_dictation_pill, which
bottom-centres the capsule on the monitor under the pointer and shows it
without taking focus (Windows keeps SW_SHOWNOACTIVATE so paste still
lands in the user's document), and call it from the widget for every
state but idle. Wayland denies clients their own placement, so the
compositor picks the spot there; the pill still appears.
dispatch_dictation_capture now logs whether a press was emitted or
queued — a press that reaches Rust and produces nothing was otherwise
indistinguishable from one the compositor never delivered.
Tests: portal signals decode at both timestamp widths (the 64-bit case
fails before this change with the exact production error); pill
placement centres, respects a second monitor's origin, and clamps rather
than going off-screen; the widget shows for a state needing the user,
stays hidden while idle, and never shows for a press that arrives while
dictation is disabled.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* chore: sync in-progress workspace changes
Uncommitted work already in the tree, checkpointed so the branch matches
the local machine:
- Remote GPU workers: join-from-the-app flow, one-time secrets, QR join
codes, a Compute control in the status bar, and the device-list
Workers panel (#1516)
- Model Catalogue workspace, with Settings pointing at it
- Settings sidebar search and keyboard navigation
- Demo assets for dubbing, dictation and voice design, plus the scripts
that render them
- Backend: validation-error handling, ASR request-path degradation, and
the accompanying tests
- CHANGELOG entries for the above and for the Wayland dictation fix
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(tests): follow Engines to the Model Catalogue, and green the sweep
- test_supertonic3 asserted the license gate points at "Settings" while
the engine now names Model Catalogue → Engines, which is where the
accept button actually lives. The assertion follows the move; what it
pins is unchanged — the hint must name a place the user can reach it.
- Carries the CJK allowlist entries for the rendered dub bundle (#1517)
and the regenerated route snapshot for /workers/agent (#1516), both of
which this branch inherits from the workspace sync.
- docs/install/linux.md: the dictation capsule is bottom-anchored
everywhere except Wayland, where the protocol gives applications no
say in their placement. Documented rather than left as a surprise
(CodeRabbit).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* ci: stop a flaky dependency fetch from failing green runs
en-core-web-sm resolves to a direct GitHub release URL, and github.com
intermittently answers `http2 error: refused stream before processing
any application logic`. uv's own three retries all land within the same
few seconds and fail together, so the whole job dies on a dependency
that has nothing to do with the change under test — it cost #1518 and
#1517 an otherwise-green run tonight.
Two changes: back off between whole `uv sync` attempts, which is what
actually clears it, and pass --no-sync to the pytest steps. `uv run`
re-resolves the environment before running, so every test step was a
fresh chance to hit the same fetch even though the install step had
already synced — that is exactly how #1518 failed, in the isolated
backend/tests step, with all 5467 tests already passed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* ci: one retry seam for every uv sync, not just the job that failed last
en-core-web-sm resolves to a direct GitHub *release* URL rather than a
package index, and github.com intermittently answers `http2 error:
refused stream before processing any application logic`. uv's own
retries all land inside the same ~10 seconds and fail together, so a job
dies on a dependency unrelated to the change under test. Tonight that
cost four otherwise-green runs across #1515, #1517 and #1518 — and the
first fix only covered the Tests job, so the next failure simply moved
to Smoke (Linux), which syncs separately.
The fetch is per-job, so the fix has to be per-job: scripts/uv-sync-retry.sh
backs off between whole attempts (15s, 45s, 90s) and every workflow that
syncs now goes through it — ci.yml (tests + the platform matrix),
release.yml, security.yml, evals.yml. It still fails loudly after four
attempts, so a genuinely broken lockfile is not disguised as a flake.
The Tests job also lacked the UV_HTTP_TIMEOUT / UV_HTTP_RETRIES the smoke
matrix has always set, which is part of why it was the one that kept
dying; it has them now.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* test(ci): pin the Intel-Mac contract by intent, not by command spelling
test_ci_verifies_intel_mac_as_the_documented_remote_only_host asserted
the literal line `run: uv sync --extra pockettts`, so routing every sync
through scripts/uv-sync-retry.sh read as a broken Intel-Mac contract. The
contract it exists to protect is that the pockettts extra installs ONLY
on backend_supported legs — which the regex now pins, while leaving how
the sync is invoked free to change.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* ci: keep every uv run out of the resolver, and bound the retry budget
CodeRabbit, #1517:
- `uv run` re-resolves before running, so the smoke suite, the
worker-artifact tests, the release test run and the eval run were each
a fresh chance to hit the flaky direct-URL fetch outside the retry
loop. All of them pass --no-sync now; the environment is already
synced by the step that owns the retries. security.yml's
`uv run --with pip-audit` is deliberately left alone — it layers an
ephemeral package rather than running the project's own tests.
- The retry count multiplied uv's own budget (UV_HTTP_RETRIES=5 with a
120 s timeout on the smoke matrix). Three attempts and 60 s of total
backoff outlast the refusals actually observed while staying well
inside the jobs' timeout-minutes.
- The Intel-Mac contract test pinned the smoke command literally too, so
--no-sync tripped it exactly like the sync line did. Same fix: assert
the contract (smoke runs only on backend_supported legs), not its
spelling.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat(demos): ship the demo audio and video the app already advertises
Every demo asset in the app was a dead link on anything but a Mac.
`personalities.py` has carried a `preview_url` for each of the seven
voice-design presets since they were added; DictationDemo.jsx posts three
bundled WAVs to /transcribe so the feature can be shown without microphone
permission; the Dub workspace reads a manifest and plays a source video plus
four dubbed languages. None of those files were committed, because the tooling
that renders them (scripts/build_demos.sh, scripts/build_dub_demo.sh) hard-
requires macOS `say` — it even carries a `TODO: add espeak-ng path for Linux
contributors`. So the presets returned 404, the replay buttons did nothing, and
the dubbing demo never loaded.
Rendered with VoiceStudio's own engine, which runs wherever the app does:
- 7 voice-design previews (2.2 MB)
- 3 dictation replay clips (1.1 MB) — verified by transcribing them back:
the conversational and French clips round-trip exactly
- dubbing demo: source + 4 dubbed videos with subtitles and manifest (9.6 MB)
Tooling fixes this turned up:
- build_dub_demo.sh wrote to backend/assets/demo/dubbing, but main.py mounts
backend/assets/samples at /demo_audio — so the frontend's
/demo_audio/demo/dubbing/manifest.json could never have resolved even after
a successful Mac build. Output moved under the mount.
- `say` is now the fallback rather than the requirement: the new
scripts/render_dub_demo_audio.py renders the five tracks with the engine and
the shell script picks them up.
- The five demo paragraphs lived in two files. They are now one JSON both read
— two copies is one edit away from a video whose subtitles disagree with it.
- render_demos_omnivoice.py peak-normalized, which a single-sample transient
defeats: the Helpdesk preset landed at -30 dB RMS against -17 dB for its
neighbours, so the preview row played at wildly different volumes. Now EBU
R128 at -18 LUFS with a -1.5 dBTP ceiling.
- …and pinning the output rate, because loudnorm resamples to 192 kHz
internally and writes there unless told otherwise, which turned 2.1 MB of
previews into 17.5 MB of identical-sounding audio.
- update_manifest() looked for a manifest at a path nothing writes, so it
always printed "not found" and did nothing.
- Dictation is rendered here now too. It was excluded on the grounds that
`say` was good enough and engine TTS was overkill — true only on macOS.
tests/test_demo_assets_exist.py resolves every advertised URL against the
directory main.py actually mounts, and checks each dubbing subtitle matches the
script its manifest entry claims. A missing static file is not an import error
and not a failing request; nothing would have caught this otherwise.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* docs(changelog): stamp the demo-asset entries with their PR ref
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(demos): watermark rendered demo audio, and harden the render scripts
Review findings on #1517:
- Greptile P1: the renderers wrote engine output straight to disk, so a
re-render shipped demo audio with no provenance mark. These clips play
back to users as VoiceStudio output — they are synthetic audio leaving
the app like any other, and now go through mark_synthetic (#1169), the
one chokepoint every producing route uses. It runs on the file AFTER
loudnorm, since loudnorm re-encodes what it is handed, and says so
loudly when marking is unavailable rather than committing an unmarked
asset. The dubbing renderer shares the same helper.
- CodeRabbit: build_dub_demo.sh checked only source.src.wav before
deciding it could run without macOS `say`, so a Linux or Windows run
with four of five tracks present reached a missing one, called `say`,
and left a half-built bundle. It now requires all five.
- CodeRabbit: shutil.move over an existing path delegates to os.rename,
which raises FileExistsError on Windows — os.replace overwrites
atomically everywhere.
- CodeRabbit: the preview test discovered presets in a parametrize
argument, importing app code at collection time and leaving
core.personalities in sys.modules for later tests. Discovery moved into
the test body.
CI: the rendered dub bundle's zh/ja subtitles, its manifest and the
script source are dubbing CONTENT, not UI strings — allowlisted in
test_no_hardcoded_cjk.py with that justification.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(demos): a render that cannot be watermarked fails instead of warning
CodeRabbit and Greptile, #1517: mark_synthetic degrades rather than
raising — correct for generation, wrong for a render script, whose whole
job is to produce files a human then commits. A printed warning on a
scrolling console is not a gate, so both scripts exited 0 with unmarked
assets sitting on disk ready to commit. They now raise, with the reason
and the fix; OMNIVOICE_DEMO_ALLOW_UNMARKED=1 stays for a local listen.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* ci: stop a flaky dependency fetch from failing green runs
en-core-web-sm resolves to a direct GitHub release URL, and github.com
intermittently answers `http2 error: refused stream before processing
any application logic`. uv's own three retries all land within the same
few seconds and fail together, so the whole job dies on a dependency
that has nothing to do with the change under test — it cost #1518 and
#1517 an otherwise-green run tonight.
Two changes: back off between whole `uv sync` attempts, which is what
actually clears it, and pass --no-sync to the pytest steps. `uv run`
re-resolves the environment before running, so every test step was a
fresh chance to hit the same fetch even though the install step had
already synced — that is exactly how #1518 failed, in the isolated
backend/tests step, with all 5467 tests already passed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* ci: one retry seam for every uv sync, not just the job that failed last
en-core-web-sm resolves to a direct GitHub *release* URL rather than a
package index, and github.com intermittently answers `http2 error:
refused stream before processing any application logic`. uv's own
retries all land inside the same ~10 seconds and fail together, so a job
dies on a dependency unrelated to the change under test. Tonight that
cost four otherwise-green runs across #1515, #1517 and #1518 — and the
first fix only covered the Tests job, so the next failure simply moved
to Smoke (Linux), which syncs separately.
The fetch is per-job, so the fix has to be per-job: scripts/uv-sync-retry.sh
backs off between whole attempts (15s, 45s, 90s) and every workflow that
syncs now goes through it — ci.yml (tests + the platform matrix),
release.yml, security.yml, evals.yml. It still fails loudly after four
attempts, so a genuinely broken lockfile is not disguised as a flake.
The Tests job also lacked the UV_HTTP_TIMEOUT / UV_HTTP_RETRIES the smoke
matrix has always set, which is part of why it was the one that kept
dying; it has them now.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* test(ci): pin the Intel-Mac contract by intent, not by command spelling
test_ci_verifies_intel_mac_as_the_documented_remote_only_host asserted
the literal line `run: uv sync --extra pockettts`, so routing every sync
through scripts/uv-sync-retry.sh read as a broken Intel-Mac contract. The
contract it exists to protect is that the pockettts extra installs ONLY
on backend_supported legs — which the regex now pins, while leaving how
the sync is invoked free to change.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* ci: keep every uv run out of the resolver, and bound the retry budget
CodeRabbit, #1517:
- `uv run` re-resolves before running, so the smoke suite, the
worker-artifact tests, the release test run and the eval run were each
a fresh chance to hit the flaky direct-URL fetch outside the retry
loop. All of them pass --no-sync now; the environment is already
synced by the step that owns the retries. security.yml's
`uv run --with pip-audit` is deliberately left alone — it layers an
ephemeral package rather than running the project's own tests.
- The retry count multiplied uv's own budget (UV_HTTP_RETRIES=5 with a
120 s timeout on the smoke matrix). Three attempts and 60 s of total
backoff outlast the refusals actually observed while staying well
inside the jobs' timeout-minutes.
- The Intel-Mac contract test pinned the smoke command literally too, so
--no-sync tripped it exactly like the sync line did. Same fix: assert
the contract (smoke runs only on backend_supported legs), not its
spelling.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat(workers): join from the app, share by QR, and a status-bar Compute control
Remote workers shipped with a hole in the middle: the control plane could
mint join codes, and on the other machine there was nothing to paste them
into. Becoming a worker meant launching with OMNIVOICE_WORKER_MODE and
OMNIVOICE_WORKER_TOKEN in the environment and relaunching — on the machine
that is usually the least convenient one to configure by hand.
Backend
- GET /workers/agent, POST /workers/agent/join, POST /workers/agent/enabled.
Join redeems a code and starts the agent live; no restart.
- Worker mode now persists in settings as well as the environment (env still
wins, and the panel is told so it can disable a switch it cannot honour),
and it is written only after a join that actually worked — a failed
enrolment must not have the app retrying on every launch.
- The endpoint carried by the redeemed code is remembered. Without that a
machine that joined from the UI came back up enrolled but with nowhere to
dial, and the only fix was OMNIVOICE_WORKER_ENDPOINT.
UI
- "Lend this machine's GPU": paste the code, Join. Once joined it offers a
switch rather than another code, because the pinned certificate survives.
- <OneTimeSecret/> renders join codes and connection strings as a QR next to
the text, with a live expiry countdown, and is used by both halves. QR
generation is best-effort: a string past the format's capacity still shows
the code and Copy, because losing the QR is a degraded share and losing the
only copy of a one-time secret is data loss.
- Status-bar Compute control: pick local or a machine, flip the feature, mint
a join code — without opening Settings. Absent entirely until the user has
opted in or enrolled something.
- Remote workers now reads as a device list: status dot, address, latency,
live task meter, resident models, last seen; housekeeping actions revealed
on hover; a three-step empty state.
- Approve is on the row. A worker could connect, sit there labelled "Not
approved" and never be usable, with no way out of it in the UI.
Fixes found on the way
- Status dots and menu surfaces in the GPU picker were painted from fixed
Tailwind palette classes (bg-emerald-400, text-amber-400, hover:bg-white/5),
so on Midnight or Catppuccin they showed Gruvbox colours next to the
theme's own. Both controls now paint from themed --color-* tokens, shared
in computeTarget.jsx along with the JSON wrapper all three copies duplicated.
- Button funnels every child into one <span>, so an icon passed as a child
renders glued to its label — the flex gap only applies to the `leading`
slot. Six buttons across these panels were affected.
- InboundNodePanel passed `variant="warning"` to Badge, which takes `tone`;
the "on your network" warning rendered as an ordinary neutral pill.
Docs updated in the same change (docs/remote-workers.md): the join flow, the
QR, the status-bar control, and the new environment variable.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* docs(changelog): stamp the remote-workers entries with their PR ref
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(workers): a join is not done until the control plane accepts it
Review findings on #1516:
- Greptile P1: `start()` only SCHEDULES the dial-out loop, so a control
plane that rejected this worker — expired token, wrong address, a
server that never answers — looked identical to a successful join. The
route persisted worker mode, reported success, and the machine retried
forever on every launch. The agent now signals first registration, and
join waits for it before persisting anything.
- CodeRabbit: a failed REJOIN left the machine unable to reconnect to the
control plane it was already serving, because pinning the new
certificate overwrites the old one on disk. Snapshot the pinned
certificate, endpoint and setting up front, and restore them (and the
running agent) when the join fails.
- CodeRabbit: join and the enable toggle awaited stop()/start() with no
exclusion, so two concurrent requests could interleave their pairs and
have `start()` return early — reporting success for a control plane it
never dialled. Both now hold one lifecycle lock.
- CodeRabbit: with OMNIVOICE_WORKER_MODE set, the toggle still started or
stopped the agent and wrote a setting the rest of the app ignores,
contradicting the env_pinned status it reports. It now answers 409 and
says which variable is in charge.
- CodeRabbit: the QR code kept encoding the previous secret until the new
one finished encoding, so the code on screen could disagree with the
text beside it.
CI: regenerated tests/fixtures/api_routes.txt for the three
/workers/agent routes.
Tests: a join the control plane never accepts is a 409 that persists
nothing and leaves no agent dialling; a failed rejoin restores the
previous certificate, endpoint and setting; an env-pinned machine
refuses the toggle.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(workers): the environment pin governs joining too, not just the toggle
CodeRabbit, #1516:
- join_control_plane skipped the env_pinned guard set_agent_enabled
enforces, and joining is precisely what ENABLES worker mode: under
OMNIVOICE_WORKER_MODE it wrote a setting nothing consults, and with the
variable pinned off it handed back a machine that reported a successful
join and lent nothing. One shared guard now covers both routes.
- Two of the three rollback assertions could not fail before the fix
(nothing wrote those settings on the failure path). The test now pins
the behaviour only the rollback produces: the previous enrollment is
dialling again, rather than left stopped until someone notices.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(appimage): stop shipping a dangling .DirIcon, and prove it in CI
The Linux icon is blank because the AppImage's .DirIcon is an absolute
symlink into the machine that built it. From the published v0.4.2:
.DirIcon -> /home/runner/work/OmniVoice-Studio/OmniVoice-Studio/frontend/
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/
appimage/OmniVoice Studio.AppDir/OmniVoice Studio.png
That path exists on nobody's computer. The link dangles the moment the
AppImage leaves CI, so file managers have no icon for the file, and the
integration tools that read .DirIcon install nothing. A dangling symlink is
not a build error — the bundle packs, runs, and passes every check we had —
which is how it shipped for a whole release without anyone noticing.
Locally built AppDirs are worse: both .DirIcon AND the root .desktop symlink
come out absolute, so a from-source bundle has no readable desktop entry
either, which is why the icon is missing in the menu and the dock too.
- `.DirIcon` is now a real file, copied in through `appimage.files` — the
same seam that already places the WebKitGTK marker.
- `bundle.category` is set, so the generated desktop entry stops emitting an
empty `Categories=`. That is not the same as omitting the key:
desktop-file-validate rejects the entry and menu builders skip it.
- verify-apprun-bundle.sh — already run against the extracted AppImage in the
release job — now fails when .DirIcon is missing or resolves outside the
bundle, when the .desktop entry does not resolve inside it, when Icon=
names a file that is not at the AppImage root, or when Categories= is
present but empty. Its unit test covers each of those, including the exact
shape v0.4.2 shipped.
The `.DirIcon` copy cannot be verified without a full release build, so the
guard is the load-bearing part: the next release either passes it or fails
loudly. It can no longer ship blank in silence.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* docs(changelog): stamp the AppImage icon entries with their PR ref
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* docs(changelog): fold the AppImage icon fix into the existing Fixed section
CodeRabbit (#1518): the Unreleased block must carry one `### Fixed`
section of one-line entries. Merge the two entries in and drop the
narrative and the version reference.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
After the ordering fix, unloading the model on a 4090 still left the GPU at
1238 MiB with torch reporting 8.5 MB allocated and 803 MB reserved -- and no
number of Flush Memory presses moved it. A segment dump said why: ONE 803 MB
segment, 794.7 MB of it inactive-but-split, pinned by a single live block of
8,519,680 bytes.
That is cuBLAS's default workspace. It is taken from the caching allocator on
first use, so it lands inside whatever segment the model load had just grown,
and it is held for the life of the cuBLAS handle. empty_cache() can only
return segments that are entirely free, so one 8.5 MB block kept three
quarters of a gigabyte from ever reaching the driver again. On a machine
lending its GPU that is the difference between an idle node costing 470 MiB
and costing 1.2 GB.
free_vram() now clears the workspaces before emptying the cache, on the
unload paths only -- the next cuBLAS call re-takes one, which is cheap but
not something to pay per generate. The binding is private
(torch._C._cuda_clearCublasWorkspaces), so it is optional by construction: a
build without it keeps today's behaviour rather than failing an unload.
Found by adding reserved-vs-allocated to /system/flush-memory in 642513d2.
Allocated alone reads near zero after an unload, which is exactly why this
hid for so long -- every diagnostic we had agreed the memory was free.
memory_allocated counts live tensors only, so after an unload it reads
near zero while nvidia-smi still shows gigabytes. That gap is the whole
substance of every "flush says it worked, the GPU says it didn't" report,
and the endpoint was reporting only the half that looks good.
memory_reserved is what the caching allocator holds from the driver; the
remainder between that and the driver's own figure is the CUDA context and
kernel workspaces, which nothing in-process can hand back.
The shared voice model's unload emptied the allocator caches and *then*
dropped the reference. That frees nothing: the weights are still reachable
when gc.collect() runs, empty_cache() only returns blocks the allocator
already considered free, and the reference drops a moment later into a cache
nothing will flush again. The unload logs success, the engine leaves the
registry, and nvidia-smi does not move.
Six modules open-coded the same two lines. Exactly one had them inverted --
OmniVoiceBackend.unload, which is the path the engine-registry idle sweep
reaches, which is the sweep a headless worker node runs. So every unload a
user could trigger from the UI worked, and the one that runs unattended on a
machine lending its GPU held 3.6 GB indefinitely. Found on hardware: the
sweep fired on schedule, logged "Released 1 idle engine(s)", and VRAM stayed
flat at 3656 MiB for the next two minutes.
Replace all six with model_manager.unload_shared_model(), which clears the
reference, drops the clone-prompt side cache, then frees -- in that order,
in one place. Two callers gain the side-cache drop they were missing
(/system/flush-memory and the shutdown path), which is the same defect one
step down: an unload that kept the encoded reference tensors belonging to the
model it had just released.
A source guard asserts nothing outside model_manager assigns the shared
reference, so the next caller cannot reintroduce the ordering. It caught the
sixth site while being written.
Also give the AudioSeal watermark models the bargain every other model in the
app already makes: they loaded on the first embed and stayed resident for the
life of the process. CPU-resident, so this is system RAM rather than VRAM,
and the machines that notice are the ones running batches.
The error text on a failing unload changes with the ordering. "Could not be
unloaded, retry after the current generation finishes" was accurate when the
cache flush ran first and aborted before the release; now the release has
already happened and only the flush can fail, so it says that instead of
sending the user to repeat work that is done.
The startup preload exists so the first generate feels instant for the person
sitting in front of the app. A machine lending its GPU has nobody sitting
there, so it was several GB of VRAM held from boot against a request that may
never arrive — and the idle sweep could not reclaim it, because the sweep owns
the worker executor's engines while this is the default local model.
Measured on gpu2: a node that had run nothing still sat at 2.4 GB, and an idle
unload after a real job returned it to exactly that floor rather than below it.
Worker-mode processes now load on first request and release when idle, which is
what a node should do. A machine that is both a desktop app and a worker keeps
the warm-up — there is a real user there and the point stands.
Watching a ten-minute rule take effect means waiting ten minutes, so it tends
not to get watched. Both numbers are now env-tunable:
OMNIVOICE_ENGINE_IDLE_UNLOAD_SECONDS and OMNIVOICE_IDLE_SWEEP_SECONDS.
They are documented as a pair, because shortening only the threshold still
means waiting a full sweep interval to see it fire — which reads as a broken
sweep and sends you looking for a bug that is not there.
Unparseable values and anything below the floor are ignored with a warning
rather than honoured. A zero threshold would hand back a model the instant it
went idle and reload it for the very next request, which is worse than the
behaviour being tuned.
The ten-minute idle sweep lived inside the dial-out agent. A node that only
accepts inbound connections never starts that agent — on gpu2 it fails outright
with 'Set OMNIVOICE_WORKER_ENDPOINT' — so a machine lending its GPU to panels
that dial IN held several GB of weights forever. That is precisely the cost the
sweep exists to avoid, and it was silently missing in the mode most likely to
be a shared box.
The loop moves to module scope and both transports use it. Inbound starts it
when the listener starts and cancels it when the listener stops, and passes a
callback that re-advertises capabilities to every attached panel, so a control
plane's view of what is resident does not go stale the moment it becomes
useful. Local behaviour is unchanged: nothing sweeps unless a worker role runs.
Found by clicking Synthesize in the desktop UI — the one path nothing had
exercised.
task_store.stage_input mints inputs/<digest><ext>, a path rather than a bare
name. The node ran safe_filename over it, which rejects anything nested, so
every real clone input was refused, the dispatch failed, and the scheduler
retried about eighteen times a second while the 4090 sat idle and the user
watched a spinner.
The wire id is now hashed into a directory name rather than used as one. That
accepts any id the protocol allows while leaving placement entirely ours to
decide, which is the property the check was really buying. The declared
filename is still required to be a bare name, and a hostile one is still
refused outright — covered by its own test so the containment cannot be traded
away later to fix some future rejection.
Every earlier test used a flat id like 'ref-1' and so never met the shape
production emits.
Found on hardware. The queue was built once per connection and reused across
reconnects, so a frame a dying session left behind became the FIRST frame of
the next attach. The node requires a registration there, aborted the call, and
the two span at full speed — session epoch 2445 inside one second, the node
logging 'Locally aborted' on repeat, and the panel reporting the machine
offline while the connection list showed it connected.
Two more found on hardware.
Disconnect ended the session and the panel redialled two seconds later, so the
log read disconnected and connected in the same breath and the button appeared
to do nothing. A kicked key now sits out for a minute — long enough that the
disconnect is real and the person notices, short enough that it is plainly not
a revocation, which stays a separate and permanent action. The docs now say
which of the two buttons does which.
Re-pasting a connection string for an already-connected machine saved the new
string and then short-circuited on the existing session, so a wrong key
reported success, kept running on the old connection, and only failed after a
restart — by which point nothing pointed back at the paste that caused it. The
live session is now torn down before the new one is dialled.
Found on hardware. FetchResult seeked to request.size_bytes as though it were
a resume point, but that field is the artifact's total size — so every fetch
started at end-of-file, yielded no chunks, and failed with 'the result ended
before its final chunk' while the finished render sat on the node's disk.
ArtifactRef carries no resume field, so resumption is a protocol addition
rather than a reinterpreted one, and the fetch now always starts at zero.
Every earlier test drove publish and stage directly and never called
FetchResult with a populated ref, which is exactly why this survived them.
Found on hardware. The job ran on the GPU machine and the audio never arrived:
'gpu2 finished the job but its audio did not arrive.'
Both artifact directions were built and neither was wired. A result reported by
a dialled node is only staged on that node's disk — nothing pushes it, because
the node cannot call us — so the commit recorded an artifact path that had
never been written. Inputs had the mirror problem: nothing sent them, so a
clone would have failed on a reference file that was never delivered.
Results are now pulled when the frame naming them arrives, and inputs are
pushed before the assignment rather than alongside it, because the executor
asks for them as soon as it starts and an assignment that overtakes its own
reference audio fails on a file that is merely late.
A fetch that fails is not a silent loss: no artifact is recorded, the task
fails naming the machine, and the node keeps its copy because nothing
acknowledges a result we could not fetch.
Found on hardware. The Attach handler started the read pump and the outbound
loop but never the heartbeat loop that the outbound path starts inside
_connect_once. So a node registered, went silent, was declared dead about
ninety seconds later, reconnected, and flapped forever — and in between, work
aimed at it fell back to the local machine with 'gpu2 is offline', while the
panel had shown it ready at 3.4 ms moments earlier.
Every end-to-end test in this file finished inside three seconds, comfortably
within the grace window that hid it. The regression test therefore asserts on
the emitted heartbeat frames themselves rather than on liveness, and shortens
the advertised interval so it does that in two seconds instead of twenty.
Found on hardware. With the listener bound to 0.0.0.0 — which is what sharing
a GPU across a network requires — the issued string came out as
ovnode://...@0.0.0.0:7444. That is a legal bind and a meaningless destination,
so it would have failed on the far end with a connection error naming nothing,
and the person who pasted it had no way to tell a bad string from a firewall.
The string is now built from an advertised address rather than the bind: for a
wildcard bind, the source address the routing table would use to leave this
machine, found with a connected UDP socket that sends no packets and needs no
DNS. An explicitly typed bind is advertised verbatim, because someone who
entered a specific address meant it.
Adds the panel that makes inbound mode usable: a toggle to accept connections,
a bind field that says which side of "only this machine" you are on, per-person
connection strings with a copy button, the live list of who is connected with a
disconnect button, and a paste box for joining someone else's GPU.
Placed behind the existing Remote workers toggle rather than beside it. "Off
means off" is this feature's stated contract, and a second switch that stayed
live underneath would be exactly the surprise that promise exists to prevent.
Headless machines that only lend a GPU set OMNIVOICE_INBOUND_NODE and never see
this panel.
The unencrypted warning appears where it becomes true, not buried in a doc:
next to the bind field once it points beyond this machine, naming the address,
and again under every freshly issued connection string. The remove-access
confirm says the others stay connected, since that is the only place a user
learns keys are per person rather than one switch for everybody.
All 36 strings are translated into all 20 non-English locales in this change,
with the {{address}}, {{label}}, {{count}} and {{when}} placeholders verified
programmatically against en.json before writing — a dropped token is the exact
bug the parity test was built for, and en-only keys would have passed CI
silently while every other language read English.
Two existing WorkersPanel rename tests queried the only textbox on the page.
That was incidental, not intentional; they now name the field they mean.
Wires the two transport halves into something a user can actually turn on.
Two independent switches, deliberately not one. "Accept connections" makes this
machine a node others dial; "saved connections" are the nodes this panel dials
out to. A workstation with a GPU that also drives jobs on a second box does
both, so neither implies the other.
Binding stays on 127.0.0.1 until someone explicitly widens it, and widening is
its own field rather than a flag riding along with the enable toggle. With no
encryption that boundary is the difference between a credential on one machine
and a credential on a network, so it is never crossed as a side effect. The
API reports `exposed` so the UI can say which side of it the user is on.
Saved nodes are redialled only after the control plane is up, since the
connector hands frames to its servicer. Failing to listen records the reason
rather than leaving the feature looking enabled while it quietly accepts
nothing.
Docs say plainly that this mode is unencrypted, that the connection string is a
password crossing the network in the clear, and that dial-out remains the
better choice when one machine is enough. The Security section no longer
implies its TLS guarantees cover both modes.
Completes the inbound path. The panel opens NodeService.Attach with its key in
call metadata, answers the node's register frame, and then runs the ordinary
control-plane loops against the dialled stream — the same _read_loop and
_ping_loop the outbound path uses, so assignments, cancels, results and
reconciliation all behave identically. Only who opened the socket changed.
Registration is shared rather than copied: the body of Register is now
establish_session, reached from both roads. A second copy of session issue,
capability application and in-flight reconciliation is a second thing to keep
in step forever, and the half that gets forgotten is always reconciliation.
The version and feature gates run on the inbound road too — skipping them would
let an out-of-date node register cleanly and then ignore task inputs, which is
how a clone with no reference audio once came back reported as success.
Artifacts invert with the transport: the panel pushes inputs before it assigns,
and pulls results after. Both directions verify the declared sha256 and refuse
a stream that ends without its final chunk, because a truncated file renamed
into place and called done is the failure the upload path was already hardened
against.
Two things the end-to-end tests found, neither visible from unit tests:
* Every Attach built a fresh client with an empty worker id, so the challenge
signature could never match after first enrollment — inbound could connect
once and never reconnect. The id is now kept per panel key, because each
panel keeps its own registry and the same machine is a different worker id
to each of them.
* A node that has lost the id a panel gave it could prove possession of its
key and still be refused forever, with no way back except deleting it from
both sides. It is now re-adopted on proof of key possession, narrowly: the
public key must already be the one enrolled, so this can never admit a new
key. Covered by a test that forges a valid self-signature from a different
keypair and asserts it is refused.
Remote workers connect outbound: the node dials the control plane, spends an
enrollment token, pins a certificate. That stays the default and is unchanged.
It is also structurally 1:1 — a worker process holds one endpoint, one pinned
certificate and one worker id — so a second person wanting the same GPU box has
to get shell access to it, repoint the start script at their own address and
restart, which disconnects whoever was using it. Sharing a GPU requires root on
it and evicts the incumbent, and no amount of UI work fixes that, because the
constraint is the shape of the connection.
This adds the other arrangement: the node listens, and any panel holding a key
connects to it, concurrently, with no shell access to the machine.
* NodeService mirrors WorkerService. Transport roles invert; message roles do
not — the node still sends WorkerMessage and the panel still sends
ServerMessage, so every state machine on both sides is untouched. Register
folds into the stream as the first exchange and reuses the existing
request/response messages rather than growing parallel ones.
* Keys are per panel, not per node. Revoking one person leaves everyone else
connected; a shared key would be revoked by nobody and leave no record of
who used it. Stored hashed, compared in constant time against every key so
the reply time is not an oracle, and the plaintext exists exactly once.
* Failed authentication is throttled per source address, so one stale
bookmark cannot lock out a different panel.
* A connection log records every attach, refusal and disconnect, and any
session can be kicked. That is what replaces per-job approval, which would
make a shared GPU unusable and train people to click yes.
* Artifacts invert too: the panel pushes inputs before assigning, and fetches
results after. The node stages both under one contained directory and
trusts no id or filename off the wire.
Runs in plaintext by deliberate decision, recorded with its accepted risk in
docs/adr/inbound-node-mode.md, and scoped there to LAN and self-hosted use —
never a fleet transport, which goal_v2 B2/B5.2 still require to dial out.
Off by default, and bound to 127.0.0.1 until someone explicitly widens it.
Add OrcaRouter to the Settings → LLM Providers registry (OpenAI-compatible
gateway, base_url https://api.orcarouter.ai/v1, default openai/gpt-5.5).
Env surface follows the existing provider pattern: ORCAROUTER_API_KEY /
ORCAROUTER_BASE_URL / ORCAROUTER_MODEL.
- registry: Provider entry after OpenRouter
- llm_backend: include OrcaRouter in the not-configured hint
- settings search: 'orcarouter' keyword on the LLM Providers category
- docs: list OrcaRouter in the supported-provider docs (docs-sync)
- test: registry test covers the new id
Co-Authored-By: Claude <noreply@anthropic.com>
Three conflicts, all additive on both sides — resolved by keeping both
rather than choosing, since either side's entries were real shipped work:
* CHANGELOG.md — remote-GPU entries against branding, IndexTTS 2.5 and
the recording-input work
* setup/download.py — the per-target progress reset against main's
active-install tracking; both belong in the same finally block
* docs/features.yaml — the remote-worker and model docs against
docs/branding.md
Backend 5349 passed, frontend 1871 passed. `bun install --frozen-lockfile`
reports no changes, so the Docker build sees the same tree CI does.
The remote-GPU line, verified on hardware rather than asserted.
**Dubbing renders on the worker.** dub_generate.py dispatches the coarse
`dub_segments` operation through the gateway, following the audiobook
pattern: per-unit local fallback after consecutive remote failures, one
aggregated notice rather than one per segment. A 40-minute dub that loses
its worker at segment 200 degrades instead of producing 200 error rows.
**An out-of-date worker is now refused by name.** This was the worst
defect in the plan and it was silent: an un-upgraded worker registered
cleanly, then ignored `inputs` and rendered a clone with NO reference
audio — returned as success. A plausible wrong result with nothing
anywhere to surface it. Workers now declare features, and one missing
them is turned away with the features named and `no task was run`.
Verified live: a worker one commit behind was correctly refused.
**"Offline" and "cannot run this" are different facts.** Asking a live
worker for an engine it lacks answered "is offline or cannot be reached.
Wake the selected worker" — while that worker reported ready, one free
slot and 3.6 ms latency. The user was sent to wake a machine that was
already awake. The scheduler now distinguishes absent from present-but-
incapable, and names the engine rather than the operation, because the
engine is the thing a user can install.
**An engine with no catalog entry is no longer hidden.** A `repo_ids`
non-emptiness check had been implemented as a runtime filter, so a worker
silently refused to advertise any engine lacking a models.yaml entry —
which is four registered engines, including CosyVoice. Users with those
already installed would have lost remote support with only a log line.
Empty `repo_ids` now means "not downloadable here", never "not runnable".
**And a script so this stops being done by hand.**
scripts/verify-remote-worker.sh runs the per-phase acceptance checks
against a live worker, non-destructively. Its preconditions are the
mistakes that cost the most time: exactly one listener on the control
port (two instances silently shared it), and never detecting the worker
with a pgrep pattern that matches the ssh shell running it.
Its first real run found the dubbing picker claiming remote placement.
That turned out to be the CHECK being stale, not the picker — the port
had landed since it was written. It now asserts self-consistency instead:
the picker may claim remote only for an operation the control plane
actually advertises as remotely producible, which cannot rot the next
time an op is ported.
Backend 5291 passed, frontend 1812 passed. Acceptance script: no
automated failures across Phases 4-8 on an RTX 4090. Four checks remain
MANUAL by design — true airplane mode, concurrent downloads, killing a
worker mid-audiobook, and the model-list UI — and are reported as
unverified rather than passed.
Five workstreams that finish the remote-GPU line, plus the test hole that
let a broken signature reach a commit.
**Downloads go through the normal path** (Phase 5). Rather than a second
remote-only route, the existing Models install flow became target-aware,
so a model landing on a worker uses the same code, the same progress
events and the same UI as a local one. Progress rows key on
(target, repo_id) — the aggregator keyed on bare repo_id, so the same
model downloading here and on a worker at once collapsed into one row
that told the user nothing true about either.
**Audiobooks render chapter by chapter on the worker** (Phase 8), with
per-chapter local fallback and ONE aggregated notice. The failure that
shape exists to prevent: a remote GPU that sleeps at chapter 40 of 200
must not turn a working book into 160 rows of PROGRESS_LEASE_EXPIRED.
Dictation is deliberately NOT ported — it runs ASR per utterance inside a
live WebSocket loop, and paying queue admission plus a round trip there
would spend the one thing that route is for.
**Dubbing stays local, and says so** (Phase 7). The coarse worker
operation is not finished, so the picker still reports dubbing as local
rather than showing a green remote chip over work this machine is doing.
What could not wait is the in-loop OOM retry: it sniffed the error string
and flushed the *local* CUDA cache, which under remote execution is the
wrong machine's GPU entirely. That is fixed now, before the path that
would have exercised it exists.
**Two instances can no longer share the control plane.** A second
VoiceStudio silently bound the same worker port and coexisted, so remote
workers landed on whichever process won the race — a session that
registers with one instance and appears dead to the other. This produced
hours of misdiagnosis during hardware testing and would hit any user with
the app open twice. The second instance now keeps running locally and
explains the conflict instead of quietly competing.
**And the hole that allowed all this to be missable.** gpu_gateway called
Scheduler.submit(pinned_worker_id=...) one commit before that parameter
existed. Every remote generation raised TypeError; 5236 tests passed
anyway, because nothing exercised the gateway against the real scheduler.
tests/test_gpu_gateway_scheduler_contract.py now runs that path for real
and binds every gateway→dependency call signature. Verified by renaming
the parameter away and watching both tests fail with the original error.
Gallery previews also fall back to a local render when a downloaded clip
cannot be decoded, rather than yielding silence.
Backend 5274 passed, frontend 1812 passed.
Not yet verified on hardware: Phases 4, 5, 6, 7, 8. Only the TTS path and
its artifact transport have been proven on a real GPU.
Three phases that only make sense together: a job that names a worker,
a worker that reports honestly what it can actually run, and the small
defects that made both lie.
**Pinning** (Phase 1). `pinned_worker_id` is now honoured in both places
that choose a worker — `eligible_workers` and `select_worker` build
independent lists, so applying it to one silently leaked work onto
whichever machine was least busy. The pin persists across a restart via
an additive column, deliberately not alembic (justified in the code, per
the precedent already in db.py): quitting mid-render used to drop it
without a word. `max_attempts=1` was rejected as the mechanism — it makes
the FIRST failure terminal, including the penalty-free ones a stale
advisory view produces routinely.
Cancel now actually reaches the worker. `WorkerServicer.cancel` had zero
callers, so cancelling released the slot while the GPU thread kept
running, and a late result could resurrect the task as COMPLETED —
`commit_result` assigned that state directly, bypassing the transition
table where CANCELLED is terminal by construction.
**Honest capabilities** (Phase 4). A worker now probes whether weights
are actually present, and a job stops BEFORE dispatch with a typed 409
naming the model and the machine, instead of failing mid-task. The probe
fails OPEN: `is_cached`/`cache_is_complete` cannot see a user-managed
clone outside the HF layout, so only a positive "absent" refuses.
Refusing an engine that works today would break the compatibility
promise. `pool.supports` deliberately still ignores `downloaded` — had it
not, the scheduler would drop the worker and answer with a terminal
NO_CAPABLE_WORKER, which tells the user to check their install when the
truth is one download away. The frontend no longer offers "Report this
bug" for that state; it offers the download.
Catalog tags resolve against the TARGET's OS/arch/backend, not this
machine's. From a Mac control plane, a CUDA worker's model list was
showing the mlx-community repos it cannot run and hiding the ones it
needs.
**And the quiet ones** (Phase 0 leftovers): a model's human label rides
its own proto field so renaming it cannot orphan breaker history; an
empty model_id no longer forks the capacity slot key into two slots for
one model; the idle sweep cannot evict an engine out from under a live
LOCAL render.
Verified on real hardware, which is the only verification that has ever
caught anything here: 2025 characters, default settings, routed to an
RTX 4090 over the wire — 100% GPU utilisation on the remote box, 119.6 s
of 24 kHz audio returned in 16.6 s, 5.7 MB delivered out of band through
the artifact path rather than the control stream.
Backend 5259 passed, frontend 1808 passed.
Every enrolled worker sat at connected=False against a healthy control
plane, and the control-plane log showed no Register call arriving at all.
The worker's own log said only "connecting", then nothing.
The cause was on our side of the handshake. The client sends an HTTP/2
ping every 25 s to keep its long-lived Control RPC alive through NAT —
an interval the control plane itself configures. But the server kept
gRPC's default enforcement policy, which permits two idle pings and then
answers ENHANCE_YOUR_CALM:
GOAWAY received; Error code: 11; Debug Text: too_many_pings
So the control plane hung up on every worker for obeying the keepalive
the control plane asked for. Idle workers were hit hardest, because a
session with no traffic is exactly the case the ping exists to protect.
Fixed by accepting the interval this protocol configures: a 20 s minimum
still rate-limits an abusive peer, while removing the idle-ping count
ceiling stops a healthy session dying of its own liveness mechanism.
This is a whole-fleet fix, not a per-enrollment one.
Worth recording what this was NOT, because it looked exactly like it:
TLS pin-on-first-use was the obvious suspect, since a control plane that
regenerated its certificate on restart would strand every enrolled
worker with no useful error. Disproved — the live certificate
fingerprint and the pinned copy on the remote worker match exactly, and
the certificate survives restarts. Enrollment was never involved.
Verified live against a remote worker: the session now establishes where
previously nothing reached the server. It is not yet stable — it drops
after ~17 s and advertises zero engines — but that is a separate defect
being tracked on its own, and this fix is a prerequisite for reaching it.
Two phases of the remote-GPU plan, landing together because neither is
useful alone: on a 4090 any render long enough to exercise the progress
lease also outgrows the 8 MiB message cap, so a gateway that routes work
remotely without an artifact transport just moves where the failure
happens.
**The gateway** (`services/gpu_gateway.py`) is the single owner of GPU
calling, model status, downloads and model load, for both targets —
`prewarm`, `run`, `status`, `download`. prewarm and run stay separate
because collapsing them loses the two-phase load/generate budget split
(#1033/#1037) that the worker protocol already mirrors. Admission moves
in here too: the old `check_gpu_admission` call read *local* pool stats,
so under Remote it would 429 on local saturation while the remote GPU
sat idle.
**Artifacts** now move out of band above a negotiated threshold. Bytes
land in an attempt-scoped `.part` file, are verified against a declared
sha256, and are renamed into place only on an explicit last chunk — a
transfer that arrives short, reordered, or simply stops commits nothing.
A resume rehashes what is already on disk, or the digest would attest
only to the tail, which is the exact case a resume exists to protect.
Two failure modes found while verifying this, both fixed with
mutation-checked regressions:
* an oversized payload with no session (mid-reconnect, or a control
plane too old to serve UploadResult) has nowhere to go. It must not
enter `_pending` — an over-cap frame is re-sent on every reconnect,
killing the session each time and stranding every other task — but
it must stay retryable, unlike the size gate's TERMINAL verdict:
nothing about the render is wrong, only the route to it.
* the upload resume loop was bounded by "did the offset change", which
a receiver alternating between two byte counts satisfies forever.
The worker is single-slot by default, so that is not one lost upload
but the machine, doing nothing else, until someone restarts it.
Bounded by a round count instead.
The control stream is split into control and bulk queues so the
heartbeat this whole liveness model rests on cannot queue behind a
payload — `result_json` has no size cliff to catch it, and the next bulk
message added to the protocol would have reintroduced the stall
silently.
Live streaming stays on the control plane and now says so once per
socket: that route exists to put audio in the user's ear before the
sentence finishes, and paying queue admission plus a round trip per
utterance would spend the one thing it is for. Silence would have been
worse than the limit — the header badge would read "gpu2" while this
machine did all the work.
Backend 5236 passed, frontend 1807 passed. End-to-end verification on
real hardware has NOT been re-run since these changes; the CHANGELOG
claim for the Synthesize button waits on that.
Selecting a remote worker repainted a badge and nothing else. The cause was
not subtle: `scheduler.submit` had no production caller, and `routing.decide()`
was read only by the status endpoint that paints the header. Remote execution
was a complete, tested pipeline with no producer at its head.
This adds the producer and fixes the defects that made the pipeline unable to
carry a real job:
- Nothing routed to the scheduler. Adds `POST /workers/tasks` (loopback-gated,
**development-only** until the gateway lands) and `Scheduler.wait`, backed by
per-task futures rather than the unregisterable `on_change` listener list.
- Every task over two minutes died. No worker ever sent `TaskProgress`, so the
120s progress lease expired mid-render — including during the cold model
load, which happens after `TaskStarted`. Workers now report progress and
emit a keepalive, bounded by the phase's absolute budget so it renews the
lease without deleting the only enforced bound in the system.
- The executor rebuilt its engine per task (`return cls()`), so every job paid
a cold load. Engines now share one instance cache with the router, resolved
by the assignment's engine — never `get_active_tts_backend()`, which returns
the worker machine's own Settings preference and would silently run the
wrong engine.
- One lease expiry took a worker offline permanently: parked slots were never
reclaimed. Parks now expire on a TTL, and are deliberately NOT reconciled
against the worker's own load report — at a ceiling of one the only task such
a worker can report is the wedged one, so "busy" would drop the park and the
next idle heartbeat would hand out a slot with a live GPU thread (#730/#1190).
- A worker that dropped and reconnected mid-render had every liveness frame
discarded: task frames were fenced on the live session epoch, which bumps on
every reconnect, while the worker echoes the ref stamped at dispatch. The
control plane then expired a task whose GPU was still rendering, and swallowed
the failure report when it went wrong. Fenced per attempt instead.
- A result from one worker could commit another's task, after which the owner's
real delivery arrived as a duplicate and its audio was discarded. "Unknown
attempt" and "another worker's attempt" are no longer the same answer.
- An oversized result was a poison pill, re-sent identically on every reconnect
and permanently disconnecting the worker. It is now a terminal
`RESULT_TOO_LARGE`, which is also classified — it was falling through to
TRANSIENT and retrying a re-render that could never fit.
- `_store_inline` joined the artifact directory with worker-supplied ids, and
`os.path.join` discards its prefix on an absolute component. Paths are now
minted control-plane-side and resolved through `core.path_security`.
- Remote synthesis bypassed `mark_synthetic`, and the guard that exists to
catch exactly that walked only `backend/api` and `backend/services` — so it
stayed green while a fourth unmarked producer shipped. Marking moved to the
worker's tensor stage; the guard now walks `backend/worker` too.
Also adds pre-rendered voice previews (`services/gallery.py`), so browsing the
gallery no longer needs a GPU or a downloaded model. The manifest is verified
against the updater's release key already baked into the binary; a fresh
install hears voices without downloading 2.4GB first, and everything falls back
to local rendering when the gallery is unreachable.
Verified on hardware, not just in CI: 1728 characters submitted to an RTX 4090
returned 105.94s of 24kHz audio in 23.9s, committed and served from the
artifact store.
Not yet done, and deliberately not claimed: the keepalive fix cannot be
exercised end-to-end on fast hardware, because any job long enough to reach the
120s lease produces audio past the 8MiB inline cap. Chunked `UploadResult` has
to land first. Pinning to the worker the user chose is also still absent, so
"Remote" reaches a remote GPU but not necessarily the one on the badge.
Adds a GPU target picker to the header: Local, or one of the machines you
enrolled. Exactly one is active at a time; other connected workers are
standby and receive nothing.
The selection is the user's, not the scheduler's. The engine underneath can
rank many workers and the hosted platform will need that, but a desktop app
is better served by a choice you can predict and explain: "your worker is
offline, this ran locally" is a sentence, "least-busy ranking preferred the
laptop" is not. Picking an offline machine is allowed on purpose — you
choose your desktop, then go and switch it on.
`routing.decide()` is the single answer to "where does the next job run",
shared by the badge and (soon) the generation path, so the badge cannot
claim something the router will not do. It shows the RESOLVED answer rather
than the stored choice: pick your desktop, let it sleep, and the chip reads
Local with the reason, while the menu still shows your desktop selected.
Connection latency is now real. `latency_ms` existed but nothing measured
it — the protocol had Ping with no reply — so it was always zero. Adds Pong
(additive, field 12) and times the round trip on the control plane's
MONOTONIC clock, so an NTP step or a sleep/wake cannot produce a nonsense
reading, and no worker timestamp is trusted. Reported as a median of five
samples and withheld until a second sample exists: the first round trip
after connect lands while the worker is still importing torch, which
measured 139 ms on loopback and, averaged, carried that for a minute.
This is CONNECTION latency, not time-to-result. It is shown as information,
never as a routing input — RTT is milliseconds where inference is seconds,
so ranking on it would optimise noise.
Also fixes a bug the picker exposed: worker config was read from the pool,
which caches the row handed to it at connect time. Renaming a CONNECTED
worker updated the database and the API kept serving the old name until it
reconnected — same for priority and enable/disable. Config now comes from
the database and liveness from the pool, never the reverse, and writers
refresh the live copy so the scheduler's logs do not use a stale name.
Adds worker rename (the backend already supported it; no UI called it),
worker address as seen by the control plane rather than self-reported, and
ready/busy/offline status behind the header dot.
The Settings panel posted a JSON *string* with no content type, so FastAPI
refused every write with a 422 ("Input should be a valid dictionary"). It
also read `.enabled` straight off apiFetch's return value — but apiFetch
resolves to a raw Response, not parsed JSON, and does not throw on 4xx. So
the panel could never have shown a worker even once the 422 was fixed, and
no HTTP error ever reached a catch block.
All three now go through one request() helper: it sets the content type,
checks res.ok, parses, and raises FastAPI's `detail` so the user reads
"Remote workers are turned off." rather than a status code.
Why the tests missed it: they mocked apiFetch as if it returned parsed data,
so they agreed with the mock instead of the client. The mock now returns a
Response-shaped object, and the assertions check the wire shape — method,
Content-Type, parsed body — because a was-it-called assertion cannot see a
missing header.
Three endpoints had no test at all (/enabled, /resume, /tasks/{id}/cancel);
/enabled is the one that broke. All nine are covered now, including the
string-body 422 itself.
An enrollment token carries the endpoint a worker will dial, but
default_endpoint() read the CONFIGURED port rather than the bound one. Start
on any other port and every token points somewhere nothing is listening —
the worker retries forever against a dead address with backoff, so it looks
like a network problem rather than a wrong number.
Found by running the feature end to end on a non-default port, which is also
the second bug in this seam: the first was advertising a .local hostname
gRPC's resolver cannot resolve. Both were about what the token tells a
worker to dial, so both now have regression tests.
Remote workers was nested under Sharing, which reads backwards: everything
in Sharing is about letting something else reach THIS machine (a remote
backend, an MCP client, a share PIN), while remote workers sends work OUT
to machines you own. It is now its own System entry.
Docs-sync: every "Settings → Sharing → Remote workers" reference is
updated — the guide, the changelog, the two API error messages that tell a
user where to generate a token, and the agent's not-enrolled error.
Also ignores remote/ (local goal docs, review briefs, council reports) and
repoints the code comments that cited remote/goal_v2.md at the shipped
docs/remote-workers.md, so no committed file references a path that is not
in the repo.
Send individual jobs to GPUs on your other machines while everything else
stays local. Opt-in, off by default: with the toggle off there is no
listening socket, no certificate and no background loop.
Design follows remote/goal_v2.md, the council-revised goal doc. The
decisions that shaped the code, and why:
* A disconnect is an unknown outcome, not a failure. The original design
reassigned on disconnect while also describing the case where the worker
had already finished — following both guarantees duplicate execution. An
attempt now holds a grace window; a worker returning inside it commits
its result and no second attempt is ever made.
* At-least-once execution, exactly-once result commit. The result is
persisted BEFORE it is acknowledged, so a crash between the two cannot
silently lose a finished render.
* Deadlines are phased (accept -> model load -> execute -> deliver) and
liveness is a progress lease. The old fixed 30s execution budget was two
orders of magnitude below what this product actually does; silence is
the failure signal, not slowness.
* Capacity is derived from free VRAM, never configured: a static value
corrupts output under torch.compile thread affinity (#315) and aborts
the process on small cards (#567).
* A circuit breaker replaces the reliability-score/quarantine machinery,
which had no recovery path (no probation workload exists in a TTS
product) and penalised consumer networks for existing.
* Identity is a keypair the worker generates and never sends. A
server-assigned id is a name, not an authenticator, so revocation of one
would be theatre. Enrollment tokens are single-use and carry the control
plane's certificate fingerprint for pin-on-first-use.
Adds the domain core, scheduler, durable task store, gRPC transport,
worker agent, management API, Settings panel, and docs. Protobuf reserves
the tenant/trace/usage fields a hosted control plane would need, since
adding them later means upgrading a whole fleet.
Includes tests for the failure paths that matter: duplicate delivery,
stale-session fencing, reconnect reconciliation, grace expiry, breaker
attribution, and a real end-to-end TLS round trip.
Close unauthenticated remote mutation paths and keep filesystem destinations behind one-shot Tauri capabilities. CodeRabbit and Greptile findings were fixed on-branch; all review threads are resolved. Full CI, Security, Rust, and cross-platform smoke checks are green.
Validate HTTPS probe destinations against the shipped origin allowlist and clean up the related CodeQL test findings. Reviewed by Greptile; CodeRabbit was harvested but rate-limited. CI, Security, and cross-platform smoke checks are green.
Decode MLX Whisper audio once through VoiceStudio's validated bundled ffmpeg and reuse the waveform for forced alignment. This restores source-install transcription on Apple Silicon without requiring system ffmpeg.\n\nThanks @gambletan!
Protect all nine sidecar frame channels from library stdout noise and pin the complete sidecar manifest in regression coverage.\n\nCloses #1428. Thanks @1335-Group for the diagnosis and tested fix.
- Licence-accept gate: add pockettts to _LICENSE_ALLOWED_ENGINES + PocketTTSLicenseDialog (MIT code + CC-BY-4.0 weights + gated-access notice).
- Gated-weights preflight: POCKETTTS_GATED_WEIGHTS in core/failure.py (hint + classify rule), so a gated-repo download surfaces as a typed error naming the agreement, not a raw failure.
- Frontend: PocketTTSLicenseDialog registered in EngineCompatibilityMatrix; i18n keys in en.json.
Remaining deferred items: CI smoke (stub sidecar integration test), four-platform install verification.
The About page reported 'Compute device: cpu / GPU active: no / VRAM 0.00 GB' on a machine with a working GPU. Every line was true and none was usable — it is also exactly what a machine with no GPU at all reports, so the report could not distinguish a driver that isn't loaded from a container that cannot open the device from a ROCm older than the card.
The probe already knew all of it; torch.cuda.is_available() returning False simply produced no note. Each cause now reads differently: a missing device node names the --device flags, a permissions failure names --group-add and how to find the host's real render/video GIDs (copied numbers are the most common way this ends up on CPU in Docker), a card newer than the shipped ROCm points at rocminfo, an HSA_OVERRIDE_GFX_VERSION that is doing more harm than good is named first because it is both likelier and cheaper to test, and an unreachable NVIDIA driver gets its own advice.
The probe never diagnoses from a measurement it did not complete: when torch.cuda.is_available() itself raises, the exception is reported and no device findings are asserted beside it. Metadata access that raises is contained too — this runs on the path whose whole job is to explain a failure, so it cannot become one.
A generation abandoned at its execution budget was reported as the machine being too slow, whatever had actually happened. A job wedged on a lock it could never acquire got the same message as one genuinely grinding through a long synthesis, so the advice — use a smaller model, close other apps — was wrong exactly when the cause was a bug rather than the hardware.
The wedge decision now reads the deepest frame of the stalled thread in a stdlib module rather than pattern-matching the tail of the stack, so a short active stack cannot be mistaken for a blocked one. A thread parked in a lock, an event wait or a future is a wedge and says so; one executing engine code is slow hardware and keeps the old guidance.
Committed by mistake — it belonged to an earlier draft of this fix that
exposed a public has_http_401() helper, and was swept in by a broad
'git add' while the working tree carried it across a branch switch. This
branch keeps the boundary logic private as _HTTP_401, so the file
referenced a function that does not exist here and failed 11 cases.
tests/test_classify_401_substring.py already covers everything it did.
_beat() can be past its stop.wait() and already committed to a write
when the context exits. Signalling alone lets that write land after
_run_on_gpu_pool's _job pops the ident — the pop exists so a stale beat
cannot vouch for a later job on the same reused worker ident, and a
post-pop write resurrects exactly what it was there to prevent. The
wedge detector then reads a heartbeat the next job never emitted and
keeps extending a stuck one.
Joining orders the last write before the pop. Bounded, so a wedged
writer degrades to the previous behaviour rather than blocking.
The regression forces the interleaving with a parking map rather than
waiting on the scheduler, so it fails deterministically without the
join.
Digit-only boundaries rejected 4012 and 1401 but still accepted x401y,
pytest-401 and 401.0. With 401 on the symptom side, an error that merely
mentions Hugging Face and carries one of those elsewhere still resolved
to HF_AUTH_FAILED — the residual CodeRabbit flagged as Critical.
Three guards, one per family the others let through: identifier/path/
dotted-version prefixes, identifier and hyphen suffixes, and dotted
numerics. A trailing sentence full stop still reads as punctuation.
Also drops a duplicated paragraph in the branch comment.
A missing or ABI-mismatched torch/transformers surfaced as 'omnivoice not importable', which reads as a damaged VoiceStudio install and sent people reinstalling the app — the one thing that could not help, because the broken package is in the Python environment underneath it. The failure is now attributed to the dependency that actually failed, with the remedy that repairs it.
The second half: a model that failed to load during startup preload left the app looking healthy. Nothing surfaced the failure, so the first generation produced nothing and the cause was already gone from view. The failure and its remedy now reach the model status, where the UI can show them.
A classifier that fails while classifying no longer publishes its own raw exception text through model status.
classify() matched a bare '401' substring and used it to satisfy BOTH halves
of the HF-auth condition, so any message containing those digits anywhere
classified as HF_AUTH_FAILED on its own — paths, byte counts, job ids,
durations.
CI hit it when pytest's numbered temp directory reached pytest-401: an
audio-save failure came back telling the user to set a valid HF_TOKEN. That is
worse than an unclassified error — a confident wrong instruction with a docs
deeplink, in an auto-filed bug report — and because it rides a counter that
changes between runs, it passes locally forever.
Two independent guards: the digits must be a standalone number (not 4012,
1401, pytest-401's neighbours), and they are no longer sufficient evidence by
themselves. A real 401 always arrives with 'Unauthorized' or an HF URL beside
it, so requiring that costs nothing.
_spawn() calls venv_python(), and on a cold first run that is not cheap: the
probe spawns each candidate interpreter to import the engine, and if none is
installed it runs the whole uv venv + uv pip install bootstrap — bounded at
900s by design, because installing torch takes minutes.
All of it happens on a GPU-pool worker inside a generate request whose
execution budget is 300s. Nothing along the way reported progress, so the
budget expired part-way through the install and the job was abandoned. The
first generation that triggers a bootstrap could never succeed, on any
hardware, and the message blamed the hardware anyway.
The sidecar's own cold load already heartbeats for exactly this reason
(#1367); resolution is the step before it that never did. Pool jobs only, and
crediting the resolving thread rather than the beater — an off-pool ident is
not tracked by the clock, and a pool worker reusing it would inherit unearned
extension (#1379).
CHANGELOG only; model_manager.py auto-merged. Also normalised #1414's
entry to the credit-then-ref order the other 42 community entries use —
CodeRabbit flagged the same inconsistency on this PR.
Every subprocess engine confirms a candidate interpreter by spawning it and importing the engine package. For IndexTTS that is 'import indextts.infer_v2', which pulls in torch and transformers — seconds with a warm page cache, tens of seconds on a first run, a spinning disk, a network share, or Windows with real-time AV scanning every DLL.
The bound was 10s (15s for three peers), and elapsing it was treated as a negative: the candidate was discarded exactly as if the import had raised. A working OMNIVOICE_INDEXTTS_DIR install was reported as 'IndexTTS-2 is not installed', or fell through into the lazy bootstrap and reinstalled over a working clone. Only successful resolution was memoised, so every retry re-ran the probe and failed identically — which is why all three reported repro paths look like one bug.
A timeout is the absence of evidence, not evidence of breakage. The probe is now tri-state: yes (imported), no (ran and failed), unproven (did not finish). An unproven candidate is kept as a fallback and used only after every candidate has had its chance, so a wedged user clone cannot shadow a healthy bootstrapped venv. If an unproven venv really is broken it now fails at the sidecar handshake with a real error rather than a confident lie about the install.
Fixed as a class: backend/engines/_venv_probe.py replaces the drifted copy in each of the four bootstraps, and the bound is tunable per engine, defaulting to 60s. Zero and negative values are ignored — an unbounded probe would let one wedged candidate hang engine resolution forever.
Reported with a precise root cause by @OracleNightmare. (#1414)
Two ways the repair could do the wrong gigabytes:
A shard that stays unparseable after a full re-fetch would be re-fetched
again on every generate request. The re-download now runs at most once per
repo per process, the same contract as the snapshot-link repair, and later
attempts go straight to the manual delete-and-reinstall message.
With OMNIVOICE_PRELOAD_TTS_ASR on, the load also pulls the Whisper checkpoint
— a different repo. A damaged shard there arrived looking identical, and
re-downloading the TTS checkpoint would have fixed nothing while reporting
the wrong model as broken. One local load without ASR settles which it is.
zipfile, tarfile, gzip and several parsers share the wording, and any of them
can surface inside a model-load chain — where a false positive forces a
multi-GB re-download of an undamaged cache.
`_model_lock` is a module-level asyncio.Lock, so it binds to whichever loop first contends for it — in practice the server's. But OmniVoiceBackend._ensure_loaded() runs on a GPU-pool worker thread with no running loop and bootstraps a fresh one via asyncio.run(get_model()). Awaiting a lock owned by another loop does not block, it raises 'is bound to a different event loop', which reached users as a 500 — or deadlocks, depending on which loop touched it first.
_heal_tts_placement already carried a running_on_gpu_pool() guard for exactly this; the cold-load path never got one. It now loads inline on the calling thread.
The load must run inline rather than through _load_model_with_timeout(), which would hand _load_model_sync back to _get_gpu_pool() — the pool the caller already occupies. MPS pins that pool to a single worker, so it would wait on itself. Exclusion therefore comes from a loop-agnostic threading.Lock, not from holding a GPU slot: a slot is not exclusion when the pool has more than one worker, which CUDA hosts do.
Regression tests drive the real failure shape — a live foreign loop genuinely holding the lock, since an uncontended acquire() never binds — and install a pool that refuses submit, so a re-submission regression fails in under a second instead of hanging the suite.
An interrupted or mangled model download leaves one of two states, and only
one had any handling. A MISSING shard raises transformers' "does not appear
to have a file named …" and gets a whole recovery ladder. A shard that is
PRESENT with wrong bytes — a download stopped mid-file, a shard truncated by
antivirus, an HTML error page saved under its name — opens fine and then
fails inside safetensors:
Error while deserializing header: header too large
That reached the user as a raw 500 on every generation, from voice design and
gallery previews alike, and could not enter the ladder for two independent
reasons: the wording is not the missing-shard wording, and SafetensorError is
a Rust-extension exception rather than an OSError.
It also needs the opposite repair. The ladder RESUMES a download, and a resume
trusts a blob that is already the expected size — so it would never re-fetch
the one file that is actually wrong. The new path forces a full re-download,
then retries the load once.
Both halves now classify as MODEL_CACHE_CORRUPT: one class to the user, one
remedy, two repairs underneath. The cause is matched through the whole
exception chain, since transformers wraps the tensor library's error in its
own before it reaches us.
Two bugs from the v0.4.2 rename sweep:
- The lazy model import was rewritten to `from omnivoice.models.omnivoice import VoiceStudio`, a class the library does not export. ImportError is not ModuleNotFoundError, so the #564 source fallback never caught it and /generate 500'd on every default-engine request. The class keeps its library name — it is a checkpoint-referenced identifier, not branding.
- alembic resolved a bare relative script_location against the process cwd, and the desktop shell launches the backend from frontend/src-tauri, so a pending migration killed startup. script_location and prepend_sys_path are now anchored with %(here)s, with path_separator = os so Windows drive letters and paths with spaces survive. alembic floor raised to >=1.16.
Regression tests verified fail-before/pass-after for both.
Settings → Appearance → Navigation style picks between the icon rail down the
window edge (default, unchanged) and browser-style tabs across the title bar.
Both skins render one shared workspace list, so a new workspace appears in
both from a single edit, and the choice persists like scale and theme.
Two layout traps this had to solve:
* The footer and audio dock are placed at `grid-column: 2 / -1` so they sit
beside the rail. Tabs mode has one fewer column, so that placement makes
Grid invent an implicit column and take its width off the content — the app
rendering in 65% of the window with the footer stranded in a black band.
A stylesheet guard now fails on any child left past column 1 without a
`.nav-tabs` counterpart.
* Nine tabs plus the status cluster do not always fit. Rather than every label
shrinking to a stub, the strip measures what it needs with all labels shown
and, when short, keeps the label only on the tab you are in. Measured rather
than thresholded because the answer depends on locale, UI scale, font and
whether the live-metrics cluster is on.
Adds the nav-style strings to all 21 locales and a TitleTabs visual spec.
The PR merged without an Unreleased entry. It is not user-facing, so it
belongs under CI rather than Fixed — but the contributor credit the repo
keeps for community work should still be there.
Every sidecar carries its own copy of the length-prefixed JSON-over-stdio wire protocol (_send, _recv, MAX_FRAME_BYTES), and only one of them was covered. This parametrizes the protocol invariants across all nine — send/recv roundtrip, EOF as an orderly shutdown, the oversized-frame cap that stops a corrupt length header allocating unbounded memory, and the truncated body that would otherwise hang the parent — so a bug in any single sidecar's copy is caught without a per-engine test file.
Modules are resolved through importlib inside a fixture rather than bound at collection, which keeps the suite honest under sys.modules pollution.
Thanks @paoloantinori!
The dictation widget window could mistake itself for the main window: detectIsWidget() asked getCurrentWindow().label, which throws while Tauri's internals are still injecting, and the catch fell back to a URL query Tauri 2 cannot set. A window that guessed wrong rendered the whole app into 300x64 — opaque background, no pill, and no CaptureWidget to run the hide reconcile, so nothing but quitting the app could clear it. It now stamps its identity from an initialization_script, which runs before any page script and cannot race.
The pill is gone as well: the widget window is never shown. It still has to exist — getUserMedia, MediaRecorder and the transcription WebSocket all live in CaptureWidget — so it is now a hidden recorder host, and dictation records, transcribes and pastes with nothing on screen.
The tray Start/Stop item no longer infers recording from window visibility (which a permanently hidden window made meaningless, leaving Stop unreachable); it reads a dictating flag the frontend already maintains. States needing user action — Accessibility, mic denial, failed transcription — used to surface in the pill and now arrive as a toast in the main window, carrying the button that opens the relevant OS pane.
Also closes the second-launch path: the single-instance handler targeted the widget in pill mode and showed it.
Refs #1398
The trust line under Start installation claimed "no account, no cloud, no telemetry" without qualification. That stopped being true when opt-in PostHog analytics shipped in every build behind the first-run consent prompt — the screen was asserting something the app doesn't do, on the screen where the user decides whether to trust it.
It now says what holds either way the consent prompt is answered: your voices, recordings and projects never leave this machine, and no processing happens in the cloud. Analytics carries allowlisted content-free metadata only.
Translated across all 21 locales. README's version was already correctly conditioned on consent and is unchanged.
The curated qwen3-tts model IS the VoiceDesign variant, and mlx-audio refuses to run it without an instruct — but MLXAudioBackend.generate never forwarded one, so the engine could not produce audio under any input. The comment above the code claimed it was passed; the one test covering that path only passed because the value was being dropped.
Forwards instruct, and raises an actionable error when a voice-design model is asked to generate without a description. Model type is read from the model's own config, falling back to the id convention.
Closes#1405
Portable mode put everything in OmniVoiceStudio-Data beside the app — the only storage row on the setup screen you could look at but not change, while installed mode had a picker for all three of its directories.
The pin had a reason: portable_base() is computed from the executable's location and never read from config, which is what makes a portable install self-discovering. A user-chosen folder breaks that, because the only record of the location would live inside the folder being located.
So it is recorded somewhere findable, in order: a portable.path marker beside the app; then portableDir in the per-user config for app folders that are read-only; then the historical default, so existing portable installs resolve byte-identically.
The marker stores a RELATIVE path whenever the folder sits inside the app's own directory — the USB-stick case portable mode exists for, where app and folder move as a unit and the mount path is free to change. Anywhere else only an absolute path can be stored, and the setup screen says the install is tied to it rather than promising portability it cannot keep.
Two traps worth naming, both caught in review: the per-user fallback would recurse forever through load_config (config_path to portable_config_file to portable_base), so it reads the platform config file directly; and clear_portable_dir was reading through that same chain, silently failing to clear the machine record so the old folder kept winning after the user chose the default.
This also retires the greyed-out Portable option after a default Program Files install (#766) — you point it at a writable disk and get the machine-bound variant.
Rust 109 passed, frontend 1705 passed. All 21 locales in lockstep; docs/install/windows.md rewritten.
The splash flips any stage that sits still past a budget to `failed`, so a wedged bootstrap surfaces Retry and logs instead of an info-less spinner (#879). Correct for every stage the machine owns.
`awaiting_setup` is not one of them. Rust parks there deliberately — nothing downloads or installs in that stage, and complete_setup is the only way out — while a human chooses install mode, storage locations, region and mirrors. A screen built for deliberation, handed the default 120-second fuse.
So reading the setup screen for two minutes produced 'Setup failed — the backend never reported ready', replaced the setup screen, and stopped the IPC poll. Retry re-entered the bootstrap, parked at awaiting_setup again, and failed again on the same clock. Nothing the user could do escaped it, on the first screen a new install ever shows.
Reproduced live on a clean install: tauri.log ended at 'awaiting setup screen confirmation', the app data dir was empty, and complete_setup's own 'starting bootstrap' line never appeared. Nothing was ever attempted.
A stage only a person can leave cannot be judged stalled. Three tests: the setup screen survives ten minutes, it still hands off when the plan is submitted, and a genuinely wedged starting_backend still fails — so this does not trade #1376 for #879.
An auto-captured crash report clamps the backend's stderr to a budget by keeping the newest end. That is right for a plain log — the head is boot noise, the abort is at the bottom.
It is exactly wrong for a chained Python traceback, which prints root-cause-FIRST. So the section built to carry the cause reliably discarded it and kept the generic wrapper. #1376 arrived in that state: triage had to infer the torch/torchvision mismatch from the shape of the error pair rather than read it. The failure is worst where it hurts most — the bigger the traceback, the more certain the cause is to be cut.
clampCrashTail now recovers the first chain segment's exception line from the discarded head and prepends it, labelled. Non-chained logs keep the existing tail-only behaviour byte for byte, and a root cause still visible in the kept tail is not repeated.
Markers are matched as complete lines rather than substrings — stderr routinely quotes tracebacks, and a substring match would attribute a root cause from the wrong exception. Room for the prefix is reserved before slicing, so the result never exceeds the advertised budget.
9 regression tests; frontend suite 1688 passed.
The dictation hotkey could leave a blank dark square stuck on the desktop with no way to dismiss it. Three defects compounded: the tray listener's effect depended on [state], so it detached across an await on every state change and a press landing in that gap was lost; an idle pill renders null, so the window Rust had already shown was empty; and the opaque chrome background made that empty window a hard-edged square. Nothing could hide it — dismiss() is only reachable from the X button, Esc, or a post-session timer, none of which exist for a session that never started.
Fixed at the invariant rather than the call sites: the listener subscribes once for the component's lifetime, the widget window's chrome background is transparent, and an idle-but-visible window reconciles itself to hidden. The reconcile is polled (a dropped press changes no React state, so there is nothing to key an effect off) and aborts if its effect is torn down mid-check, so it can never hide a dictation that has just started.
Also in scope:
- The rename sweep had repointed three data-dir literals at a brand-named directory that does not exist, so smoke-test.sh verified a directory the backend never writes and desktop-prod.sh silently stopped clearing backend state on Windows. Both invisible on macOS, where they are usually run. A guard test now pins the assignments specifically.
- The dictation model picker's download sizes were wrong for all seven models, in both directions — Parakeet TDT v3 (the recommended default) understated 180 MB against an actual 670 MB, while the low-RAM fallbacks were overstated threefold, discouraging exactly the choice that would have helped. Measured from the published repos and pinned by a test.
- The 0.6B Parakeet models now decode on more threads, capped by host cores and still overridable.
- uninstall.ps1 gained a UTF-8 BOM (Windows PowerShell 5.1 mis-decodes its non-ASCII output without one), and sponsor.yml lost its last OmniVoice references.
Scaffolds the per-repo agent configuration the engineering skills assume: where issues live (GitHub Issues via the gh CLI), the five canonical triage labels, and the single-context domain-doc layout. Adds docs/agents/{issue-tracker,triage-labels,domain}.md and an '## Agent skills' block in both CLAUDE.md and AGENTS.md so the two stay in sync.
Documentation only — no runtime code is touched.
Review fixes: CONTEXT.md is now described as 'read it when it exists' rather than stating its absence as permanent, and both directory-tree fences carry a language identifier (markdownlint MD040).
CTranslate2 (the engine under WhisperX and faster-whisper) requires cuDNN 8 while torch ships cuDNN 9. When the side-loaded cuDNN 8 is absent it does not raise — it __fastfail()s, killing the whole backend with 0xC0000409 and no traceback, so the shell restarts it and the next attempt dies the same way.
The defect was that the backend computed the answer and discarded it: the preload passed silently on a missing directory and on every OSError, then called into a library that treats the same condition as fatal. The answer is now kept, and the CTranslate2 engines report themselves unavailable so auto-detect falls through to pytorch-whisper.
Two more instances of the same class went with it: the crash-isolated ASR sidecar is a child process that never preloaded at all (failing every transcribe, quietly), and the preload only searched <project root>/.venv, missing any other interpreter.
Conservative by design — a false positive costs WhisperX's forced alignment, so ROCm is excluded via torch.version.hip before cuda.is_available(), which is True on HIP builds.
14 regression tests; backend suite 4374 passed.
Renames what users see. The app, the installers, the window title, the
docs and all 21 locales now say VoiceStudio, with "(previously
OmniVoice-Studio)" noted near the title of each doc surface so people
recognise it.
Deliberately NOT renamed, because renaming any of them silently breaks
an existing install — there is no legacy-path fallback anywhere in this
codebase:
- bundle identifier com.debpalash.omnivoice-studio (MSI UpgradeCode,
macOS TCC grants, managed venv, WebView localStorage, the
single-instance lock)
- data directories OmniVoice / .omnivoice and omnivoice.db
- the ~150 OMNIVOICE_* environment variables
- the X-OmniVoice-* HTTP headers (a wire protocol)
- the published Docker image paths
- the OmniVoice ENGINE, which is a model name and not this product
tests/test_identity_paths_survive_the_rename.py pins every one of those
so a future well-meaning sweep cannot orphan a user's library.
Linux .deb users install a new package name and should apt remove
omnivoice-studio; that note is in the changelog.
The chunk splitter could end a chunk on a fragment with no speakable
character in it — a lone ".", "—", or "?" left behind by a boundary
landing just past the last word. The engine renders that to nothing, so
the pass was pure waste and, on a slow CPU, a visible stall.
The splitter now folds an unspeakable fragment back into its neighbour.
Where the fold would push the chunk past max_chars it moves a word
across instead of overflowing, and it never borrows a word that is
itself unspeakable (which would just recreate the dead chunk).
Measured on realistic prose: zero dead chunks, zero over-limit chunks.
Adds tests/test_no_unspeakable_chunks_1330.py — fails before, passes
after, and includes a randomised probe asserting any residual overflow
is punctuation-only and bounded.
Three open issues report the same message — #1337, #1351, #1378 — and two of them captured 'Last backend response: 2 s before this report' in the very same report. A backend that answered two seconds ago is not starting up. The message told those users something its own captured data contradicted, and sent them to wait and retry instead of at the crash notice and the backend log.
#1164 built exactly this honesty for dev and server deployments and excluded desktop, which is where most users are. Desktop now goes through the same mode-aware builder: it gains the last-contact story (answered-then-stopped vs never-answered) while keeping the forensics only it has — the crash notice, Settings → Logs → Backend, Retry and Clean & Retry — which a dev or server message would have replaced with a terminal and a docker log the user does not have.
The two recovery buttons are named through i18n rather than quoted in English, since they render as Réessayer / Nettoyer et réessayer and so on.
The repository was renamed. 724 references across 59 files now point at the new URL — README badges, docs, install guides, the updater's releases API call, CONTRIBUTING, the Colab link and the probe harness. GitHub redirects the old URLs, so nothing was broken in the meantime.
Deliberately NOT renamed, because each breaks something on a user's machine: the Tauri bundle identifier (the path to every existing user's data), /usr/lib/omnivoice-studio and the compose container names, and the published Docker image paths.
The image path needed a code change to STAY still: docker.yml derived it from github.repository, so the next build would have published to ghcr.io/debpalash/voicestudio while Docker Hub, a hardcoded literal, stayed put — everyone pulling the documented GHCR path would have kept receiving the last pre-rename image forever. It is now pinned, with a test that fails if it ever derives from the repo name again.
Also makes the probe's repo-name assertion shape-based: it hardcoded the old name and failed on every PR after the rename while the code it tests worked perfectly.
Reported as 'this app dosent generate me the last few sentences': the audio comes back clean and simply short, so nothing in the product ever said a sentence had gone missing. #1360 added a log line, which records the bug for us and tells the user nothing.
The join now collects the text of every chunk the engine rendered to nothing, and both delivery paths carry it back — response headers on the classic path, a warning frame before done on the streaming one — announced through one shared helper so the two cannot drift, quoting the lost text, plural-aware, in all 21 locales. Deliberately a warning rather than a failure: the take is real and playable.
Both carriers are exercised rather than grepped: a real NDJSON body through the streaming client, and POST /generate with an engine that renders one chunk to nothing.
This does not fix the underlying cause (the engine returning empty audio for certain chunks, still unreproduced); it stops that cause from being invisible, and the quoted text is the reproduction input we have been asking reporters for.
The largest open cluster (#1338, #1348, #1391) is one shape: a long text on modest hardware, rendered chunk by chunk under a single execution budget, abandoned at 300s as 'too heavy for the available compute' after most of its chunks had already rendered. The user got a hardware verdict about a job that was working the whole time.
#1367 solved that disagreement for model downloads by making the pool guard listen to heartbeats rather than only its own clock. Synthesis has the same kind of evidence — a completed chunk — and now reports it, so the deadline extends while chunks keep landing, bounded by the same cap.
The synthesis freshness window is separate and much longer than the load one: sidecars report every ~5s, while a single chunk on a modest GPU can take minutes, and judging synthesis by the 30s load grace would call every slow-but-healthy render wedged. The wedge guard itself is unchanged — a job that completes nothing still dies at its original deadline, one that stops completing chunks dies a grace window later, an extension is still capped, and load heartbeats keep their own shorter grace.
Two open issues arrived as a plain exit code 1 and were both handed the VRAM default: #1282 died 4s after launch on import torchaudio, and #1376 died 28s in on transformers' lazy loader raising ModuleNotFoundError. Neither user had a memory problem — their venv was half-installed, and they were sent to flush a model that had nothing to do with it.
Nothing in the exit code separates these cases; the traceback was already in the crash marker, unread because the hint only looked at exit_code/signal. It now reads the tail: an import failure naming a package the app cannot run without means the environment is incomplete, and the fix is Clean & Retry, which rebuilds it without touching voices or projects.
Only the most recent lines of the tail count, since backend_err.log is appended across runs and a process that died early carries the previous run's output. The branch sits after signal 9 (an OOM kill is an unambiguous fact about this process) and before the native-fault branch (a missing dependent DLL really does present as an access violation).
A rehosted UI whose API requests land on its own static host (or a reverse proxy with no API route) got that host's 404 page back, and we echoed it verbatim — the reporter saw 'NOT_FOUND bom1::...' and had no way to know their requests were reaching the wrong server.
The backend now stamps x-omnivoice-backend on every response, exposed through CORS, so the client can tell 'the backend answered 404' from 'something else answered 404' without guessing at body shape. A 404 in any other voice is reported as a routing problem, naming the URL that answered and where to fix it. The message goes through the same i18n helper as the other backend-diagnosis copy, in all 21 locales.
test_chat_non_429_does_not_retry failed on an unrelated PR with assert not [30.0, 30.0, 30.0, ...] even though _chat had never slept. translator.py does a plain import time, so tr.time is the stdlib module and patching it replaces time.sleep process-wide: subprocess_backend's sidecar idle reaper, which loops on time.sleep(30.0), wrote into the assertion's list and busy-looped while the patch was held. Any test that spawns a sidecar armed it, so this was a latent flake for the whole suite.
Sleeps are now recorded per thread — the test's own waits are captured, everyone else's really sleep — through one helper every sleep-patching test in the file uses. Proven fails-before/passes-after with a background thread actively calling time.sleep.
The nightly preview build had been refusing to publish its own healthy manifest since 2026-08-05 — all four matrix legs green, but the macOS bundles uploaded a few minutes ahead of the slowest versioned artifact, and the freshness check compared the version-less darwin tarballs against their siblings with two minutes of slack. Legs finishing minutes apart is normal, so the comparison itself was wrong, and Preview-channel users quietly stopped getting builds.
The tarballs are now tied to the run that produced them: anything uploaded after this run's first job began executing belongs to it. A concurrency group serializes preview runs so that holds, and the anchor is the earliest job start rather than the run's created_at (which is stamped while a run is still queued, and would let a queued run claim the previous run's uploads). The preview-notes job also gains the actions: read scope its run-metadata lookup needs, with a warning-and-degrade path so a permissions regression cannot take the channel down again.
Regression tests cover the 2026-08-05 shape, the genuinely stale case, clock skew at the boundary, the no-timestamp fallback, per-ref concurrency scoping, and the required permission.
A meticulous report from an LXC/CPU-only source install surfaced three real defects: the build script deleted the libggml shared libraries a dynamically-linked build needs (first spawn died with exit 127), the hardcoded 120s per-spawn kill switch reaped legitimate CPU-only renders, and OMNIVOICE_ALLOWED_ORIGINS — the only fix for cross-origin browser access — was documented nowhere.
All platform branches of scripts/build-omnivoice-tts.sh now copy the shared libs next to the binary, the CI artifact glob uploads them, and the backend puts bin/ on the loader path for every spawn of the engine binary. The timeout defaults to 600s (above the pool guard's well-diagnosed 300s deadline), is tunable via OMNIVOICE_GGUF_GENERATE_TIMEOUT_S with non-finite values rejected, and the timeout error names the knob. CORS documented in api-auth.md with a pointer from remote-gpu.md. Regression tests pin the spawn-env rule, the per-branch copy rule, the artifact glob, and the timeout behavior.
Third generation of the pushed-off-screen Continue bug, this time measured rather than reasoned: the max-w-[1100px] wrapper was flex-1 without min-h-0, so per the flex spec its automatic minimum height was the full model list's height — the root's overflow-hidden then clipped everything below the window and the inner scroll clamps never had a bounded box to work in, at any UI scale.
Verified in Chromium (Playwright, DOM replica of the exact class structure): footer at y=3078 in a 900px window without the class, y=884 with it and the list scrolling. Fix is one class; the guard is a rule — every growable flex ancestor between the scroller and the wizard root must also carry min-h-0, asserted by walking the rendered chain, terminating at the root, and proving the chain was inspected.
On the Models & Engines step at UI scale > 1, the pinned Continue button and HF-token card rendered below the window edge with no scrollbar to reach them. The sticky layout itself was correct — the wrap was a full-viewport box with a bare inline zoom on the mount, so the zoomed content overran the window by (1 − 1/scale). Invisible at scale 1, which is how it shipped through two earlier fixes.
Fix is the .app-container #504 contract: shrink the box to viewport ÷ scale, zoom back, plus the WebKitGTK zoom-no-op fallback; the mount passes --ui-scale as a CSS variable. Guarded in SetupWizardChrome.test.jsx (4 of 7 cases fail against the previous source) alongside the existing structural pins keeping Continue/HF-token outside the scroller.
Root cause fix for #1331, covered from both directions.
Extraction half: on a content-hash cache hit the new job's vocals_path points into the old job's directory (cache working as designed), and both extraction call sites — per-speaker and the default per-segment — then wrote the new job's clone references into that older directory too. Deleting the older history entry orphaned them: every single-segment regen silently rendered in the default voice, and a full re-dub "fixed" it only because prep re-extracts. Clones now land in the current job's own dir; the AST test sweeps every extraction call so a third copy inherits the rule.
Deletion half: existing users' jobs already carry cross-directory references, and vocals are shared by design — so deleting a dub now checks whether any other saved dub still references files in its directory. If so, the history row is removed (the entry disappears as asked) but the directory is kept, with the holder logged. Clear-all untouched. 13 tests across both halves.
Several open reports were unanswerable by construction: the run sentinel fired on an unclean previous shutdown, the notice called it a crash, and the one-click bug report shipped an empty evidence block (#1243/#1336/#1345 are this shape).
No suppression — every marker still surfaces. Sentinel markers get honest wording naming the benign causes (sleep, force-quit, a stopped VM); the one-click report is offered only when there is evidence to put in it (a log tail, or a concrete exit code/signal — an exit code counts, since a native fault can die before logging a byte); an evidence-free dialog says why and points at Settings → Logs → Backend; the prefill's empty block says what is missing instead of reading as "nothing to report"; sentinel reports are titled "ended uncleanly", not "died". Three new strings in all 21 locales, each using its locale's own Settings-path labels. Full frontend suite green.
Every subprocess engine cold-loads its model inside the synthesize handler, so a first-use generate on a slow connection spent its whole 300s execution budget downloading — then failed blaming the hardware, while the sidecar's watchdog was being fed progress frames the entire time.
The outer clock now listens to that evidence: SubprocessBackend forwards each sidecar progress frame to a per-worker-thread heartbeat (pool jobs only, cleared when the job ends), and the guarded waiter extends the deadline past the soft budget only while heartbeats stay fresher than MODEL_LOAD_HEARTBEAT_GRACE_S, bounded by MODEL_LOAD_EXTRA_TIMEOUT_S. The extension is logged once. A silent job still dies at the original deadline; stopped heartbeats kill within the grace; another thread's heartbeat is no alibi; caller cancellation cancels and consumes the abandoned future. 11 regression tests, headline case verified failing before.
The TRANSFORMERS_IMPORT hint and the ASR pipeline error told users to reinstall torch + torchaudio + transformers. torchvision — the package whose ABI mismatch actually produces this exact lazy-import wording (#1357's torchvision::nms, wrapped into "Could not import module 'AutoFeatureExtractor'") — was the one package the advice omitted. Following it to the letter left the broken package untouched (#1376).
Both surfaces now name the mismatch as a cause and prescribe the pinned reinstall with literal versions (desktop installs ship no deploy/, so the constraint-file form fails there) targeting the venv explicitly. A lockstep test asserts the exact command on every advice surface against deploy/torch-constraints.txt, so a pin bump stays red until the advice matches. Docs gain the same-wording-different-cause section (1a-bis).
* fix(errors): a failed download is not a broken install (#1347, #1335)
Two reports, one shape: the error text carried both a network cause and a
downstream symptom, the taxonomy matched the symptom first, and the user
was sent to fix something that was never broken.
#1347 -- transcription failed with "transformers ASR pipeline failed to
import (AutoFeatureExtractor) -- your transformers install is incomplete;
reinstall with `uv pip install --reinstall transformers` ... Underlying:
Cannot send a request, as the client has been closed."
The install is fine. The pipeline was DOWNLOADING the feature extractor
when the shared HTTP client closed underneath it (#880). Reinstalling
transformers cannot fix a dropped connection, so the advice was not
merely unhelpful -- it was work the user could repeat forever without
succeeding. New MODEL_DOWNLOAD_INTERRUPTED class, checked before the
import rules, requiring the httpx closed-client wording AND an
import/transformers term so a bare closed-client error elsewhere is left
alone. Its hint says the partial download resumes, since otherwise
someone on a slow link assumes retrying restarts a multi-GB fetch.
#1335 -- a cut TLS connection reached /generate as a bare 500 carrying
`_ssl.c:1016`. core/failure.py has classified that since #1301, but
/generate keeps its own taxonomy and never learned it, so it fell to the
unrecognized-error catch-all. Added to the network signatures there: it
is a dropped download, and the remedy is retry, not Flush.
Both changes are orderings rather than new detections -- the cause now
beats the symptom -- and both keep the case the original rule existed
for: a genuinely broken transformers install still classifies as
TRANSFORMERS_IMPORT, and a failed handshake is still distinguished from a
cut connection.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(errors): a Windows paging-file limit is not out-of-memory (#1334)
Same class as the two fixes already on this branch: advice that cannot
work.
The reporter asked, reasonably, whether OmniVoice needs an internet
connection -- generation failed only when they disconnected, with a bare
500 carrying "The paging file is too small for this operation to complete
(os error 1455)". Two separate defects made that unanswerable:
1. /generate matched it in _is_oom_failure and said "Try the Flush button
to reload the model". Flush cannot help. The hint we had already
written for this exact class says so outright -- "closing other apps
usually won't fix it" -- but the generate path never consulted it.
Now branched before the OOM check, naming the virtual-memory setting
and stating plainly that it is not a network problem.
2. WINDOWS_PAGING_FILE_TOO_SMALL was absent from
_CONTEXT_FREE_HINT_CLASSES, so on the raw-500 surface classify()
identified it correctly and then attached nothing. The user got the OS
sentence and no next step, despite the detailed remedy sitting in
_HINTS. Its trigger (1455 with winerror/os error, or the literal
phrase) is unmistakable, which is the bar that set requires.
Both Python (`WinError 1455`) and Rust (`os error 1455`, from the
safetensors mmap) spellings are covered, and a genuine CUDA OOM still
gets the Flush hint.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(errors): tighten both new matches, and move the 1455 expectation
CI caught a real one, and it was my process error: I ran the full sweep
before adding the paging-file change, not after.
tests/test_generation_audio_guard.py listed WinError 1455 among the OOM
signatures and asserted it yields "ran out of memory / try Flush". The
new branch routes it to the paging-file advice instead. That test's
INTENT -- a genuine memory failure must never fall through to the unknown
catch-all -- is preserved and still asserted; 1455 simply gets a more
specific memory message now. Expectation moved, guard kept.
Two over-broad matches tightened (CodeRabbit), both in the same
direction: a rule that fires too widely replaces correct advice with
advice that cannot work, which is the exact defect this branch exists to
fix.
* The TLS EOF wording is OpenSSL's, but nothing stops an unrelated
component saying something similar, and calling a local fault a network
problem sends the user to check a connection that was never involved.
Now gated on an `ssl` marker; the real message always carries it.
* MODEL_DOWNLOAD_INTERRUPTED required "client has been closed" OR
"cannot send a request". The latter alone is generic enough to appear
beside an unrelated import failure, where overriding TRANSFORMERS_IMPORT
would swap correct reinstall advice for a "just retry" that never
succeeds. Now requires the closed-client wording itself.
Negative regression tests for both, plus the positive cases they must not
cost us. Also resolves core.failure through a fixture at call time rather
than importing it at module level, per the suite convention -- sibling
tests reload these modules and a stale binding makes the file
order-dependent.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: debpalash <nizam4103@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(generate): a deadline is not an unrecognized error (#1368)
Reported on macOS/MPS with indextts2:
TTS engine stopped mid-generation with an error OmniVoice doesn't
recognize. Retry once; if it keeps failing, please report it with the
full trace. Underlying error: TimeoutError:
Nothing follows that last colon. TimeoutError is routinely raised with an
empty message, so the user was asked to report a trace that says nothing,
about the one failure mode whose cause is entirely known.
The classification chain already refuses to blame VRAM for network (#880)
and config (#919) failures. A deadline is the same kind of thing -- a
known class with a specific remedy -- and it was falling through to the
catch-all. Worse, a timeout whose message happened to contain an OOM-ish
word would have hit the memory branch and sent the user to Flush for
memory they never ran out of, which is exactly the class bug #880 fixed.
_is_timeout_failure() matches TimeoutError (and asyncio/futures aliases),
the project's own GpuJobTimeoutError by name since it is not a subclass,
and stringified forms from sidecars that wrap the child's error. Checked
BEFORE the OOM branch. "read timed out" is explicitly left to the network
branch: that is a dying model download, which it explains better.
The message names the time limit, the three usual causes, and
OMNIVOICE_GENERATE_TIMEOUT_S so someone on slow hardware has a way
through rather than only an explanation. The "Underlying error" tail is
appended only when the exception actually carries a message -- otherwise
it rendered as a bare `TimeoutError:`, a sentence stopping mid-thought.
Testing that emptiness against str(e), not _safe_exc_text(), which always
prefixes the type name and so is never empty.
Likely the in-the-wild face of #1367: indextts2 is a sidecar, and a
first-use weights download overrunning the 300s generate budget produces
precisely this.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* style(generate): drop placeholder-free f-prefixes (#1368)
CodeRabbit on #1373: after the interpolation moved into `_tail`, the
message literals no longer interpolate anything, so the f-prefixes were
dead weight and Ruff F541. Ruff does not run in CI, so this is
consistency with the surrounding code rather than a broken gate.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: debpalash <nizam4103@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(startup): explain a lost port race instead of exiting 1 in silence (#1364)
#1223 gave a port conflict a dedicated exit code, and handles the race
between our pre-probe and uvicorn's real bind by re-probing after uvicorn
dies. That only helps while the other process is STILL holding the port.
The common case is an orphaned backend from the previous session which is
itself shutting down. It releases the port between uvicorn's failed bind
and our re-probe, the probe reports "free", and the user gets a bare
`exit code 1` -- for a crash we had already fully diagnosed. Reported on
Windows with the tell-tale ordering: `Application startup complete`
(uvicorn's lifespan runs before the bind), then `[Errno 10048] error
while attempting to bind`, then a plain exit 1.
uvicorn already hands us the answer: its startup does
`logger.error(exc); sys.exit(1)` with the OSError itself as the record's
message, so the errno is available as an object -- no locale-dependent
string matching, which is the trap #1223 exists to avoid. Observe that
record, believe it first, and keep the re-probe as the fallback.
The watcher also pins the uvicorn.error level to at most ERROR: a filter
only runs on records the logger emits, so a higher level would drop the
bind failure and silently restore the unexplained exit 1. No-op today
(uvicorn defaults to INFO and nothing here raises it) -- it stops the
mechanism being disarmed at a distance later.
Two regression tests, both driving the real uvicorn. The race is
simulated deterministically by making both probes report the port free
while it is genuinely held, which is exactly the state the race leaves
us in; verified to exit 1 without the watcher and 78 with it. The
assertion is on our own wording, not "already in use" -- that phrase is
also in uvicorn's own English log line, so matching it would pass against
the unfixed build and would be the very locale-dependent match #1223
forbids.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(startup): pin what the bind watcher actually depends on (#1364)
Review round on #1370.
greptile P1 claimed uvicorn's logging setup removes the filter, making
the watcher inert. Measured against the installed uvicorn: it does not.
`dictConfig` replaces a logger's HANDLERS and leaves its FILTERS alone,
and the end-to-end test already exercised the real `uvicorn.run` and got
exit 78.
The conclusion was right for a different reason, though, and it caught a
genuine mistake: uvicorn resets `uvicorn.error`'s LEVEL from its config
during startup -- after the defensive `setLevel(ERROR)` this added. That
guard was dead code offering false assurance, so it is gone. The real
precondition is that the guarded `uvicorn.run()` must not raise log_level
above ERROR, since a filter only runs on records the logger emits.
Both behaviours are now pinned by tests that measure the installed
uvicorn rather than assuming it, so a version that starts clearing
filters, or a change that quietens the guarded serve call, fails loudly
instead of silently restoring the unexplained exit 1. The log_level test
is scoped to the guarded call -- the --health-check smoke path sets
log_level="warning", which is below ERROR and irrelevant.
Also pins the production wiring itself (CodeRabbit): the end-to-end tests
rebuild the guard from extracted source, so they would still pass if
main.py stopped installing the filter or stopped consulting it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* test(startup): check log_level by AST, not regex (#1364)
CodeRabbit on #1370: the regex only recognised string literals, so
`log_level=settings.level` -- or any computed value -- matched nothing
and the assertion passed while verifying nothing.
That is the same class of bug as #1357's pin that did not apply: a check
that looks present and is inert. Parsed with ast now; a non-literal is an
explicit failure rather than a silent skip, and the guarded call is
located by walking to the addFilter and taking the uvicorn.run after it
instead of by source order.
Verified against three mutations of main.py:
literal critical -> FAILED
computed value -> FAILED
literal warning -> passed (below ERROR, must not fail)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: debpalash <nizam4103@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(install): make the torch pin reach the Colab and Docker installs (#1357)
#1358 pinned the trio in `[tool.uv] constraint-dependencies`, which fixes
`uv sync` / `uv lock` / `uv run`. It does nothing for `uv pip install` --
that is the pip-compatible interface and ignores project-level uv
settings -- and `uv pip install --system --no-cache .` is exactly what
both the Colab notebook and deploy/Dockerfile run.
Measured on one Python 3.12 environment, same command, pin present:
without --constraint: torch 2.13.0 torchaudio 2.11.0 torchvision 0.28.0
with --constraint: torch 2.8.0 torchaudio 2.8.0 torchvision 0.23.0
So the reported install path was still resolving the three on their bare
lower bounds (`torch>=2.4`, `torchvision>=0.19`), free to move torch past
a torchvision built for an older ABI -- which is the reported failure,
`operator torchvision::nms does not exist`, against the preinstalled
torchvision in Colab's /usr/local/lib/python3.12/dist-packages/.
The pins move to deploy/torch-constraints.txt and are passed explicitly
at both call sites. No local version segment, so PEP 440 matches the
base images' +cu128 and +rocm6.4 builds instead of replacing them -- the
property the ROCm image depends on.
Also extends the Docker guard, which asserted on torch and torchaudio
only, omitting the one package that actually broke. It now imports
torchvision.ops and touches nms, so an ABI mismatch fails the build
rather than shipping.
Recurrence: docker.yml builds only on push to main, never on a PR, so
nothing would have caught a silent regression here before it shipped.
tests/test_torch_constraints_are_applied.py fails if the file drifts from
pyproject, if either call site drops --constraint, if the Dockerfile stops
COPYing the file, or if the guard stops covering torchvision.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* test(install): assert the constraint in the argv, not the cell text (#1357)
CodeRabbit on #1369, both findings valid.
The notebook check scanned the whole cell, so it passed when --constraint
was deleted from the run([...]) list but its explanatory comment
survived -- exactly the "the pin looks present but does not apply" shape
this PR exists to fix. It now parses the cell with ast and asserts
--constraint is in the argument list AND immediately followed by the
constraints file. Verified by deleting the flag from the argv while
keeping the comment: the test fails.
Also drops test_the_notebook_is_still_valid_json_and_has_its_cells --
it passed before the change and duplicated JSON parsing the constraint
test already does. A tautology.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: debpalash <nizam4103@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
#1328 merged without a CHANGELOG entry. Per the changelog rule that is
the immediate next commit rather than backlog, since release.yml extracts
the section verbatim as the release body.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat(engines): PocketTTS CPU-only sidecar shape (#1306)
Sidecar SHAPE for review, mirroring omnivoice-subprocess: PocketTTSBackend(SubprocessBackend) (CPU-only, parent interpreter, optional-dep gate) plus a stdio sidecar (ready/ping/synthesize/shutdown, lazy TTSModel.load_model, per-ref voice cache, generate_audio to int16 PCM). Registered in services/tts_backend.py. Batch protocol; streaming raised as a follow-up. CI smoke, gated-weights preflight, 4-platform install, licence-accept gate deferred to on-top after shape review.
* feat(engines): PocketTTS sidecar handles 6 languages (en/fr/de/pt/it/es)
load_model(language=...) per language (cached), maps OmniVoice's language value to a pocket-tts model language, and picks the default preset voice per language when no ref clip is given. Represents PocketTTS accurately: it is multilingual, not english-only. The HF model card's 'English only' line is stale, confirmed by the GitHub README and pocket-tts 2.1.0.
* fix(engines): list pockettts in docs inventory; drop unused logger
docs/features.yaml tts_engines now includes pockettts, clearing the docs-drift test that failed CI (every registered engine must be in the inventory). Removed the unused logger line CodeQL flagged. No readme/doc entry yet, matching opt-in engines like supertonic3 and omnivoice-gguf; a doc page can land with the rest of the integration.
* fix(engines): address PocketTTS sidecar review findings
- Cold-load watchdog: heartbeat progress frames during the gated weights download so the parent does not kill a healthy sidecar mid-load, plus a 600s recv timeout on the backend.
- Unsupported language: raise a clear error instead of silently falling back to English and mispronouncing.
- Voice-state cache: LRU-bounded to 8 entries so a long session cannot leak memory.
- ref_audio SSRF: reject URLs (local file paths only) to preserve local-first.
Addresses the 3 Greptile P1 + 1 CodeRabbit Major on #1328.
* fix(engines): invalidate voice cache on ref-file change; reject non-finite recv timeout
- Voice-state cache key now folds the ref_audio file mtime+size, so a file replaced at the same path no longer returns a stale voice from the previous contents (Greptile P1).
- recv_timeout_s rejects inf/nan env values via math.isfinite and falls back to 600s, so the deadline can't be silently disabled (CodeRabbit Major).
* fix(engines): nanosecond mtime in voice cache fingerprint
int(st.st_mtime) lost sub-second precision, so a file replaced at the same path within one second with the same size kept the old key and returned a stale voice. Use st.st_mtime_ns for full resolution (Greptile P1 on the follow-up fix commit).
* fix(engines): raise on multi-channel audio instead of unsafe downmix
The defensive mean(axis=0) assumed channels-first; on channels-last (N,2) it averaged across time, producing garbage. The engine returns mono, so the branch is unreachable in practice. Raise on ndim>1 so an upstream shape change surfaces as a loud error frame instead of silent noise. (debpalash review on #1328)
* fix(engines): include import error in pockettts is_available message
CodeRabbit Minor on #1328: the exception was caught as 'e' but never shown.
* fix(engines): lock _send to prevent concurrent-write framing corruption
Greptile P1 on #1328: the cold-load heartbeat thread and the main loop both call _send (stdout write). The stop+join serializes the normal case, but a join timeout leaves a window where both threads write length+body segments concurrently, interleaving the wire framing. Add a threading.Lock around the write so concurrent _send calls are serialized regardless.
* test(engines): cover the PocketTTS sidecar's silent failure modes
The four review findings fixed on this PR are all silent by construction:
an unsupported language rendered fluent, confident, wrong audio; the
channels-last downmix produced noise; interleaved frames desynchronized
the pipe permanently; a re-recorded clip kept serving the old voice. None
of them raise, and none would be caught by an end-to-end smoke test that
only asserts audio came back.
49 tests over the sidecar's pure logic — language selection, PCM
conversion, wire framing, the LRU voice cache — plus the backend surface
(recv-timeout guards, CPU-only declaration, sample-rate lockstep with the
sidecar, lazy registration). The model is mocked and the sidecar is
stdlib-only at import time, so none of it needs the optional pocket-tts
wheel or a child process.
Verified fail-before/pass-after by reverting the lock and the multi-channel
guard: the framing test fails with a length header decoded from inside
another frame's body, which is the corruption itself rather than a proxy
for it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: debpalash <nizam4103@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(engines): downmix along the channel axis, not axis 0 (#1328)
Found while reviewing #1328. Every subprocess sidecar guards its PCM
conversion with a defensive `arr.mean(axis=0)`, which is correct only for
channels-first audio. For a channels-last (N, 2) array `squeeze()` keeps
both axes and the mean runs across TIME: every output sample becomes the
mean of two neighbouring samples and the render collapses to 2 samples.
That is not a downmix, it is a destroyed waveform played back as noise.
Unreachable in all five today because every engine returns mono -- which
is precisely why it could sit there being wrong. Nothing runs it, so
nothing reports it, and the first engine or SDK version to emit stereo
gets noise with no error anywhere.
Pick the channel axis instead of assuming it, and loop so a stray extra
axis reduces the whole way to mono; previously a (2, N, 2) array stayed
2-D after one mean and produced a PCM buffer whose length disagreed with
the n_samples in the frame -- a desynchronized audio frame rather than a
merely wrong-sounding one.
Downmixing correctly rather than raising (the choice PocketTTS made on
#1328): these five are shipping engines, and turning a render that works
today into an error is a regression risk that the actual defect -- the
wrong axis -- does not require taking.
The sidecars run under different interpreters (confucius4 and dots.tts
each have their own venv), so they cannot import a shared helper and the
duplication cannot be refactored away. The recurrence guard is therefore
a test that holds all five to the same behaviour at once, so a sixth copy
pasted into a new sidecar fails there rather than shipping: 20 of its 30
cases fail before this change.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* test(engines): let a broken sidecar fail instead of skipping
CodeRabbit Major on #1366: the blanket `except Exception -> pytest.skip`
turned a syntax error or an import-time regression in any of the five
sidecars into a skip, so this regression suite could pass CI while
running nothing.
All five are stdlib-only at import (torch and the model load lazily on
the first synthesize), so there is no optional dependency to tolerate --
an import failure here is a real defect in a shipping engine. Unguarded.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: debpalash <nizam4103@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(release): rebuild + cryptographically verify the preview updater manifest
Since ~2026-07-13 every nightly matrix leg logs 'Signature not found for
the updater JSON. Skipping upload...' - tauri-action uploads the bundles
and .sig companions but never refreshes latest.json. Combined with the
'Clear this arch's stale preview updater bundle' step (which deletes and
replaces the version-less macOS tar.gz every night), the preview
manifest's darwin signatures no longer match the published files: macOS
Preview users hit 'The signature verification failed' on every update
(latest.json frozen at 2026-07-13, tar.gz replaced nightly).
Two changes, both in the single post-matrix preview-notes job (no
per-leg race):
1. Rebuild latest.json from the release's real assets and their .sig
companions, then clobber-upload. The manifest can no longer drift
from the files it describes, regardless of what tauri-action's own
updater-JSON path does or skips.
2. Extend the existing manifest verification with a cryptographic
check: every signature in latest.json must verify (minisign
file sig + trusted-comment sig) against the artifact it points at,
using the updater pubkey from tauri.conf.json. Parity and version
format both passed for 2+ weeks while every darwin entry was
unverifiable - this is the check that was missing.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* ci(release): refuse a preview manifest built from two different runs
Bot review findings on this branch, all fixed here:
- The AppImage and MSI were picked independently by highest run number and
the larger N became *the* version, so a matrix where one leg failed or
was re-run published a manifest advertising X.Y.Z-5 while handing Windows
users the -4 MSI. That is the same manifest/artifact drift this job
exists to end, reintroduced by the fix for it. Require both legs to come
from one run and fail loudly otherwise: leaving the previous manifest in
place is a visible, already-understood state; shipping a mismatched one
is not. The darwin tarballs carry no run number, so the signature check
in the following step is what pins those to the published bytes.
- persist-credentials: false on the checkout — nothing here pushes to git.
- Floor-pin the cryptography install; this step decides whether a signed
manifest is trustworthy, so it is the one dependency worth a bound.
tests/test_release_preview_manifest_rebuild.py runs the step body extracted
from release.yml against stubbed gh, so it cannot drift from the workflow.
Fails before / passes after on the mismatch case.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* docs(changelog): note the preview updater manifest fix (#1327)
Co-Authored-By: Pinkers01 <pinky.bouw@gmail.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* ci(release): verify the preview manifest before publishing it, not after
Two more review findings on this branch, both valid, both about the
manifest being wrong in a way the existing checks structurally cannot see.
greptile P1 — verification ran AFTER the clobber-upload. A manifest that
failed the check was already live and stayed served; the job merely went
red, and every macOS Preview user stayed broken until someone noticed.
Verification now runs against the file about to be published, and the
upload is the last thing in the step. A refusal leaves the previous
manifest in place, which is a visible, already-understood state.
CodeRabbit — the darwin entries were not tied to this run. The version
comes from the AppImage name; the macOS tarballs were only checked for
existence. Signature verification cannot help there, because a stale
tarball and its stale .sig match each other perfectly — so a run whose
macOS legs never uploaded would advertise this version while serving Mac
users the previous build, and since those clients keep reporting the old
version the updater would re-offer it forever. They are now bound by
upload time, with two minutes of slack for legs that finish apart.
The selection rules move out of the YAML heredoc into
scripts/build_preview_manifest.py. Three findings in a row have been about
WHICH artifacts may be described together, and a heredoc can only be
tested by extracting it and stubbing a shell — which is what the previous
test file did, asserting against gh stubs rather than against the rules.
build_manifest is pure: assets in, manifest out, ManifestRefused on
anything it will not describe.
14 tests, including both new refusals and two that pin the workflow still
calls the module and still uploads last — an inline copy would pass every
other test and ship the original bug.
Co-Authored-By: Pinkers01 <pinky.bouw@gmail.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Pinkers01 <pinky.bouw@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
test_speech_survives_a_load_slower_than_the_generate_budget passed in a
full session and failed when the file was run alone — the wrong way round,
because the isolated run is the honest one.
The cause is not ordering as such. Watermarking runs INSIDE the generate
budget, and its first call in a process loads the AudioSeal model. That
takes longer than the 0.2s budget this test deliberately sets, so the
request 503`d on the watermark rather than on anything to do with the
load/generate split it exists to prove. In a full session an earlier test
had already warmed AudioSeal, so it passed for a reason unrelated to what
it asserts.
Disabled for the duration. The budget asymmetry is what is under test; the
watermark is an unrelated cold start that happened to ride in the same
window. Verified isolated, in a session with its sibling timeout suites,
and repeatedly.
Pre-existing on main, unrelated to any current change — found while running
related suites for #1338.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(dub): say when a clone reference is gone instead of rendering a default voice
Reported on Discord: "if you re-dub individual sentences the voice isn`t
taken from the video — you have to re-dub everything for the voice clone
to work."
Clone references are FILE PATHS into the job`s extracted-clip directory,
and the whole job dict — those paths included — is persisted to
dub_history.job_data so saved projects reopen after a restart. The job
therefore outlives its clips. Reopen a saved dub once the clip directory
has been cleaned, regenerate one line, and every resolution branch hands
the engine a path that is no longer there. Nothing checked it, and an
engine given a missing reference renders UNCLONED rather than failing — so
the line comes back in a default voice matching nothing else in the dub,
with no error anywhere. A full re-dub re-extracts the clips, which is
exactly why that appears to fix it and is the workaround the reporter
found unaided.
Diagnostic ONLY, deliberately: the reference is passed to the engine
unchanged. Nulling it would not alter what the user hears — the engine
already falls back — and it would decide on the engine`s behalf that a
path it cannot stat is unusable, which is untrue for anything resolved
inside a sidecar`s own namespace. The defect is the silence, not the
fallback. (The first cut did null it, and broke seven existing tests that
legitimately assert a synthetic path reaches the engine; that was the
right signal.)
Warns once per segment per job, so a 300-segment dub whose clips were
cleaned logs which lines lost their reference rather than one line per
retry — "some of them" is not actionable.
Root cause of the cleanup itself is not addressed here; this is what makes
the next report carry the paths.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(dub): key the missing-ref memo on the path, not the segment alone
greptile P1, valid. The single-segment preview endpoint has no segment
identity to pass — it is a "render this text" call — so every preview
shared the key "preview" and only the FIRST missing reference in a job was
ever reported. Every later one, with a different path, was silenced: the
de-duplication meant to stop repetition was swallowing new facts.
Keying on (segment, path) fixes it without an API change, and is more
correct on the render path too: a segment rebound to a second missing clip
is no longer mistaken for the one already reported.
SegmentPreviewRequest also gains an optional segment_id, diagnostic-only
and defaulted to None so existing callers are unaffected — a caller that
supplies it gets the line named instead of a bare "preview".
Three tests; the distinct-paths one fails against the segment-only key
while the repeat-suppression one keeps passing, so the fix cannot be a
blanket removal of the de-duplication.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(colab): pin torchvision==0.23.0 to resolve torchvision::nms operator missing error (#1357)
This resolves the runtime import failure of transformers.HiggsAudioV2TokenizerModel due to torchvision mismatch with torch 2.8.0. Changes:
- pyproject.toml: Added torchvision>=0.19 to dependencies, pinned to 0.23.0 in constraint-dependencies, and configured the pytorch-cuda source index.
- uv.lock: Regenerated lockfile to resolve torchvision 0.23.0+cu128.
- bootstrap.rs: Added torchvision to rocm_torch_reinstall_args and updated the matching unit test.
* fix(bootstrap): pin versions in ROCm reinstall path and add CHANGELOG entry (#1358)
* test(rocm): pin bootstrap.rs to pyproject constraint, mechanically
The PR adds a "Keep in sync with [tool.uv.constraint-dependencies]" comment
above rocm_torch_reinstall_args. That is the right instruction and a
comment cannot enforce it, so it becomes a test — CLAUDE.md`s convention is
that a rule a reviewer has to remember belongs in one.
It matters more here than the usual lockstep case: an AMD user`s install
does not come from uv.lock at all. bootstrap.rs shells out to pip against
the ROCm index, so whatever it names there is the Torch stack that user
actually runs, and a drift produces no install-time error — it surfaces
later as "operator torchvision::nms does not exist" or a silent CPU
fallback, which is #972 and #1357 from two different directions.
Three cases: the constraint block still pins the trio (so the rest cannot
pass vacuously), the ROCm pins equal it, and all three are reinstalled
together — a subset leaves the others on CUDA wheels the ROCm build cannot
pair with. Both drift cases fail against the previous, unpinned form.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: debpalash <4178343+debpalash@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(tts): a chunk that renders to nothing is no longer dropped in silence
Reported on Discord: "this app dosent generate me the last few sentences.
for rest this app is a banger" — clean audio, just missing the end.
Ruled out by direct probe rather than by reading: split_text_into_chunks
preserves every non-whitespace character (including text with no terminal
punctuation), concatenate_audio_chunks joins everything it is given, and
trim_trailing_silence cuts only from the last VOICED sample so it cannot
remove speech. Those three are pinned by a test now so the elimination
does not have to be redone.
What was left is the filter at the top of the join:
chunks = [c for c in chunks if c is not None and c.shape[-1] > 0]
When an engine returns nothing for one slice of text, skipping it is still
the right joining behaviour — the alternative is a crash or a gap. Doing
it in silence is not: the waveform looks perfect and is simply short, so
the failure can only be found by reading along while listening.
It now counts the drops, logs at WARNING (this is output the user paid
compute for), and names the lost text where the caller can supply it —
wired through all three generation call sites and audiobook.
Audiobook also pre-filtered before calling, which both hid the same drop a
second time and misaligned rendered from chunks so the concat could not
name what was lost. It passes the full list now.
Not the root cause of the engine returning nothing — that needs a
reproducing input, which is exactly what this makes obtainable.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(audiobook): the one-survivor branch skipped the drop reporting
Both reviewers caught the same hole, and they were right: a span that
splits into several chunks where only ONE renders returned that chunk
directly, skipping the join — and therefore skipping the reporting the
join does. The chapter came back short and said nothing, which is this
very bug one branch over. Zero survivors had the same problem.
Rather than add two more conditions to an inline branch, the decision
moves into chunked_tts.join_rendered_chunks: kept/dropped, report, and
return None when nothing rendered so the caller`s dead-render handling
still owns that case instead of receiving a silence buffer. One place that
can be wrong, and a testable one — a second inline copy is how the hole
appeared to begin with, so a test pins that audiobook routes through it.
Also, on CodeRabbit`s test note: test_reporting_never_breaks_the_join now
asserts the report was ATTEMPTED, not merely that nothing blew up —
otherwise it would pass on a build where the reporting does not exist.
test_chunking_itself_loses_no_text stays, with its docstring saying plainly
that it pins an eliminated hypothesis rather than a fixed defect: "the
chunker drops the tail" was the first explanation for #1330, ruling it out
took a probe, and a future splitter change that did lose the tail would
reproduce the reported symptom exactly.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(llm): LM Studio translation failed on a placeholder model name (#1332)
Reported as a clean A/B: translation works through Ollama and fails
through LM Studio on the same machine. The difference is one line in the
provider table. LM Studio shipped `local-model` as its default_model,
which is a placeholder, not a model id — LM Studio serves whatever the
user has loaded and 404s a name it does not know. Ollamas default is
`llama3.1`, a real name people actually pull, so the identical code path
worked there.
No name we ship can be right, because the answer depends on what the user
loaded. So ask the server: resolve_model now discovers from /v1/models for
providers whose default is a placeholder, positioned BELOW any explicit
env or stored setting so it can never override a deliberate choice, and
above the default so a server that is down leaves the caller where it was.
Cached per provider — translation resolves the model per segment and a
round-trip each time would trade a broken setup for a slow one — and
dropped whenever a base_url or model edit could invalidate it, since a
stale id would make the users change look like it did nothing.
Also: a 404 from a LOCAL provider is almost never a wrong URL, because the
request reached the server. The generic "check the model name and Base URL
path" sends the user to audit a URL that works, so a local 404 now names
the models that ARE loaded, or says the server has none.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(llm): bound the discovery cache both ways; do not over-claim a 404
Three review findings, all valid, all about the cache being permanent in
one direction or absent in the other.
- A failed probe was not remembered, so a stopped LM Studio cost a 5s
timeout on EVERY translated segment — a 200-segment dub would spend
1000s discovering nothing, worse than the bug being fixed. Remembered
for 30s: short enough that starting the server recovers in seconds
rather than needing a restart.
- A successful discovery was cached forever, so swapping the loaded model
inside LM Studio 404d every translation until an app restart. Now a 300s
TTL, plus an immediate invalidation when a local 404 proves the cached
name is one the server rejects.
- _local_models collapsed a FAILED listing into [], which let the error say
"reports no loaded models" about a lookup that never happened — a
confident wrong diagnosis replacing a vague right one. None vs [] are
now distinct, and the generic 404 text stands when nothing was
established.
The cache therefore cannot be a dict[str, str]: "no entry" and "we looked
and there was nothing" have to be distinguishable for the negative case to
be cacheable at all.
Five tests, three failing before this change. They age the cache entry
rather than patching time.monotonic — that name is the stdlib`s, shared
with sqlite and logging, and freezing it breaks the settings store
underneath the test.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(gpu): record WHERE a wedged job was stuck when its budget expires
Three reporters on v0.4.2 hit "TTS generate ran for more than 300s of
actual compute time and was abandoned" (#1338, #1329, #1348) — two of them
on an RTX 3050 and an RTX 3060, rendering a single sentence. That is not a
machine too slow for the job. The message says "too heavy for the
available compute" because it is the only story the timeout path can tell.
And nothing in the log could contradict it. The timeout branch logged THAT
the budget was exceeded, reset the pool, and returned. The worker cannot
be cancelled, so it was still running on a real stack — and we threw that
away, which is why every report of this class arrives undiagnosable and
the only advice available is "reproduce it under a debugger".
sys._current_frames() reads the frame of every live thread including one
wedged inside a C call, which is exactly this case. Filtered to gpu-pool
workers so the log names the stuck job rather than the web server, capped
at 25 frames, and it can never raise — a diagnostic that throws would
replace a real GpuJobTimeoutError with an unrelated crash.
Ordering is load-bearing and asserted: the capture runs BEFORE reset(),
because reset() swaps in a fresh executor and the wedged thread then stops
being identifiable as a pool worker — the diagnostic would still run, still
log, and be empty, which looks like it worked.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(gpu): redact home paths from captured stacks; label stale workers
Both review findings on this PR, both valid.
CodeRabbit (CWE-532): traceback frames carry absolute source paths, which
on a user machine start with their home directory — their account name.
This log lands in backend.log, which goes into diagnostic bundles and
prefilled bug reports, so it has to be sanitized like every other surfaced
text. Reuses core.failure.sanitize rather than inventing a second answer;
if sanitizing itself fails the stacks are dropped, not logged raw.
greptile P1: a wedged worker survives reset() — it cannot be cancelled and
keeps running under the same gpu-pool name the replacement pool uses. The
second timeout in a session would log both with nothing to tell them
apart, and the stale one is the more misleading, since it names an
operation that is not the job that just failed. The live pool is now
identified through its own thread set and the others are marked STALE.
That set comes from ThreadPoolExecutor._threads, which is private, so
unknown internals degrade to labelling nothing rather than to failing —
a diagnostic that vanishes because an attribute moved is worse than an
unlabelled one, and that degrade path has its own test.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(appimage): the preload probe never ran, and tested the wrong value
Two CodeRabbit Majors on #1354, landed after merge because I merged before
reading them. Both silently DISABLED the feature rather than breaking
loudly, which is the shape that survives a green suite.
1. `command -v true` answers with the shell BUILTIN — the bare word "true",
not a path — so `[ -x "true" ]` was false on every host, the probe always
failed, the preload never happened, and #1333 was left exactly as it was.
A builtin never involves the dynamic loader, so it could not have tested
anything even if it had run. Now resolves a real binary (/usr/bin/true,
/bin/true, or /bin/sh -c : as the guaranteed last resort).
2. The probe took our library alone, but the exported value appends any
inherited LD_PRELOAD — so the probe could pass while the environment the
app actually gets fails. It now probes the final value.
The suite missed both because every existing case overrides the probe via
OMNIVOICE_APPRUN_PRELOAD_PROBE. The new default-probe case is what closes
that hole; the inherited-entry case is the discriminator for (2). Both fail
against the previous AppRun.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(appimage): drop a probe fallback that could never resolve; cover /bin/sh
CodeRabbit, valid. The PATH entry looked up `coreutils`, which is not an
executable name, so that branch could never resolve — a fallback in shape
only. Deleted rather than repaired: a PATH lookup is what caused the
original builtin bug, and the list already terminates at /bin/sh, which is
present on any host that can run this script.
That left the real last resort untested, which is how the branch above it
shipped broken in the first place. `sh` needs `-c :` where `true` needs no
argument, and with no argument `sh` reads stdin and hangs — so the new case
points the probe override at the real /bin/sh and fails loudly if that
branch is wrong (verified: breaking the argument turns it red).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(appimage): let the host GStreamer win, and stop sharing its registry (#1333)
Recording from the AppImage failed with "No microphone found" on a Debian
13 host whose audio stack the reporter verified healthy (pactl, wpctl,
gst-launch with both pulsesrc and pipewiresrc), while the same build`s raw
binary recorded fine. GST_DEBUG=2 named it:
WARN GST_REGISTRY gst_registry_binary_check_magic:
Binary registry magic version is different : 1.23.90 != 1.3.0
GStreamer element appsink not found. Please install it.
linuxdeploy bundles libgstreamer-1.0 because WebKit links it, but not the
plugins: those are dlopen`d, so nothing static can see them to copy. The
bundled core falls back to the host plugin directory, whose plugins were
built against the host core, the version check rejects them, and the scan
yields nothing. appsink is one of the casualties and it is the element
WebKit hands a capture stream to, so getUserMedia() rejects NotFoundError.
Same class as #1258 (frozen bundled library against a host that moved on)
in a different library, which is why OMNIVOICE_PREFER_SYSTEM_WEBKIT=1 did
nothing for the reporter. Since we ship no plugins, the host core is the
only one that can agree with the plugins that will load — so prefer it,
with OMNIVOICE_PREFER_SYSTEM_GSTREAMER=0 as the escape hatch.
Also isolate the registry cache. GStreamer keys ~/.cache/gstreamer-1.0/
registry.<arch>.bin by architecture alone, so two cores of different
versions clobber each other`s file: that makes the failure depend on which
app ran last, and the AppImage corrupts the cache for every other
GStreamer app on the machine. Both directions go away with a private path.
AppRun.test.sh covers host-present, host-absent and opt-out; all three
fail against the previous AppRun.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* test(appimage): cover the ldconfig discovery path; docs fixes
CodeRabbit, all three valid:
- every GStreamer case forced ldconfig to fail, so the runtime-only-host
fallback (no -dev package, hence no .pc file) was never exercised. The
cases now select their discovery path, and the new ldconfig one fails if
that branch is removed.
- MD040: the GST_DEBUG fence had no language tag.
- the registry cache path follows XDG_CACHE_HOME when set; ~/.cache is only
the default. Documented, along with WHY the shared file is a problem.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(appimage): compose LD_LIBRARY_PATH once; host WebKit stays first
CI caught a real regression, not a flaky test. The GStreamer block prepended
its own directory, which put it AHEAD of the host WebKit dir — and "host
WebKit first" is the invariant #1258 turns on. On a host where the two
libraries live in different directories that silently changes which WebKit
resolves.
It only showed on Linux because the WebKit ldpath cases do not stub away a
real host GStreamer, so the runner had one to find and macOS did not.
Reproduced locally with an ldconfig shim, and confirmed the ordering is what
fixes it: with the old order the suite is 19/2, with this one 21/0.
Both decisions now compose one path in one place — host WebKit, host
GStreamer, bundle, inherited — so neither preference is weakened and the
ordering is stated where it is applied rather than implied by two
independent prepends. Same directory for both (the common case) is not
listed twice.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(appimage): preload the host GStreamer instead of hoisting its libdir
greptile P1, valid. The host GStreamer lives in a general system library
directory (/usr/lib/x86_64-linux-gnu on Debian), so putting that directory
ahead of ${HERE}/usr/lib replaced EVERY other bundled library with the
host copy — loader symbol errors, startup crashes, or a blank window on a
distro we never built against. One library needs to come from the host and
the mechanism has to be that narrow.
LD_PRELOAD names exactly that library and leaves the search path alone, so
the WebKit ordering from #1258 is untouched too (and this removes the
composed-LD_LIBRARY_PATH block that only existed to keep the two
prepends from fighting). The preload is inherited by the Python backend,
where nothing links GStreamer and it is inert — the accepted cost.
Tests now assert both halves: the library IS preloaded, and the libdir is
NOT hoisted.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(appimage): verify the host GStreamer loads before preloading it
greptile P1, valid. The host core links GLib and the bundle ships GLib too,
resolved bundle-first — so a host GStreamer built against newer GLib than
we bundle fails its relocations and the app does not start at all. That is
strictly worse than the broken microphone this PR fixes. Taking host GLib
as well is not an option either: GLib is what WebKit is built against, so
pulling it from the host reopens #961/#1258.
Rather than predict the pairing, test it. The loader processes LD_PRELOAD
for any binary, so running `true` under the exact environment the app will
get is a complete check of whether the library loads there — a missing
dependency or an unresolved version tag ("version GLIB_2.84 not found")
fails it and nothing else runs. On failure the preload is skipped, the app
starts on the bundled core, and a warning names the mismatch so the user
has a thread to pull rather than a silent half-fix.
OMNIVOICE_APPRUN_PRELOAD_PROBE lets the suite choose the outcome, matching
the existing OMNIVOICE_APPRUN_WK_MARKER precedent; the new case fails if
the guard is removed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(errors): strip terminal colour codes before a failure reaches the user (#1344)
yt-dlp colourizes stderr whenever it thinks a terminal is attached, and the
frozen backend's pipes are enough for it to think so. A restricted-video
failure surfaced as `download: ^[[0;31mERROR:^[[0m [youtube] …`, which reads
as an OmniVoice bug rather than a message from YouTube.
Fixed at build_failure, the choke point every surfaced failure passes
through, so the whole class is covered — ffmpeg, uv, pip, cargo and
anything else that colours its output, not just the reported command.
Order matters and is pinned by a test: strip_ffmpeg_banner anchors on
"ffmpeg version " at the start of a line, so a leading colour code would
hide it and quietly reinstate #1309 for any colour-emitting ffmpeg build.
The pattern covers CSI and OSC (window title / hyperlink) sequences, not
just SGR colour, and never empties a non-empty message.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(errors): escape-only text falls back to the error class, not to bytes
CodeRabbit, both valid:
- strip_ansi kept the original when stripping left nothing visible, so
build_failure copied raw escape bytes into reason/error/detail — the very
thing this PR exists to stop. build_failure already falls back to the
exception class name for an empty reason, and a class name is a real
answer where a run of escapes is not, so let it do that.
- the test file bound `from core import failure` at import time. Sibling
suites reload and purge core.* between tests, so that alias can outlive
the module the app uses and the file would assert against a stale copy
while looking green — #1269 exactly. Resolved via a fixture at call time.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
The dub overlay said "Transcribing with Whisper…" whatever ASR engine was actually running, in all 21 languages — a user debugging a slow or failing transcription would go read Whisper's docs.
Contributor fixed 16 locales; the remaining 5 (ar, hi, id, ja, pl) transliterate the brand rather than keeping the Latin spelling, so the sweep missed them. Those are corrected, the guard is extended to every transcription stage label rather than the one reported, and its boundary is ASCII-letter-based — Python's \b is unicode-aware, so \bwhisper\b does not match "Whisperで文字起こし中".
Thanks @paoloantinori!
* fix(scripts): desktop-prod:run wiped the data it was documented to preserve (#1333)
`scripts/desktop-prod.sh` emulates a first install, so wiping is its default: it
removes the app data dir, `~/.omnivoice` (the SQLite database, every voice
profile, all outputs), the Tauri logs and the WebKit profile. `--keep-data` is
the only thing that suppresses that block.
`--skip-build` is an independent flag that only skips the cargo compile, and
`desktop-prod:run` passed it alone — while the script's own header calls that
command "re-launch last build (skip compile)" and its closing banner tells you
to use it that way. So "just start it again without recompiling" silently
deleted the developer's voice profiles and project database, every time.
The fix is in the package scripts rather than the flag parsing: making
--skip-build imply --keep-data would remove a legitimate combination (fresh
data without paying for a recompile). The two stay independent, and the help
text now says so.
desktop-fresh:run is deliberately untouched — that script is a stricter
new-user emulation, so wiping is the point of its name.
Tests pin all three rules, and were confirmed fail-before by reverting the
desktop-prod:run line.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(scripts): kill the live instance before every launch, not only before a wipe
Greptile P1 on this PR, and it was a regression I introduced.
The app registers tauri_plugin_single_instance, and that callback ignores the
incoming argv — it just refocuses the window the RUNNING process already owns.
So starting a second copy over a live one does nothing visible.
That was previously masked: kill_running_instances sat inside the
`KEEP_DATA = false` branch, so every run happened to kill first *because*
every run wiped. Adding --keep-data to the re-launch aliases removed the wipe
and would have taken the kill with it — `desktop-prod:run:pill` would have left
the user in studio mode with --pill silently discarded, and plain
`desktop-prod:run` would have refocused the OLD build instead of the one just
compiled, which is the entire point of that command.
The kill is now unconditional, before the wipe branch. Its two reasons are
independent — zombie-backend-after-wipe, and single-instance-swallows-argv —
and only the first was ever about wiping. Adjusted its closing line, which
said "safe to wipe" and now also runs when nothing is being wiped.
New test asserts the call is not nested inside the KEEP_DATA branch;
confirmed fail-before by moving it back.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(scripts): scope the kill to this checkout, warn about an installed app
Making kill_running_instances unconditional (so --keep-data re-launches
still get past single-instance) widened the blast radius of its pgrep:
"OmniVoice Studio.app" also matches an installed /Applications copy, so
desktop-prod:run would kill the shipped app a developer was using and take
their unsaved work with it. That was previously masked — the kill only ran
on wipe runs, where a clean slate had been asked for explicitly.
Scope the pattern to ${TAURI_DIR}/target/debug/, which covers both launch
shapes and nothing else. An installed instance still gets named rather than
ignored: single-instance keys on the bundle id, so it swallows this launch
too, and silence would just trade one confusing failure for another.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Makes tests/probe/test_triage.py pass on a fork checkout: the repo owner is not stable across forks, and detect_repo() legitimately returns None where there is no GitHub origin at all (source tarball, git archive, Docker build context) — so that case skips rather than trading one environment assumption for another.
Thanks @paoloantinori!
Three tests in `frontend/src/test/streamDropError.test.ts` failed on any machine that happened to be running OmniVoice, and passed in CI.
They exercise the no-crash-marker branch, which since #1242 asks whether the backend is still answering before repeating the caller's "it crashed" guess — and they left that probe unstubbed. `_probeBackendAlive` does a real `fetch` at the configured API origin, so the assertion was really "is anything listening on port 3900 right now": nothing in CI, the developer's own app locally. Same fails-locally/passes-in-CI shape as #1269.
- Every test in that branch now states which answer it wants (DEAD / ALIVE) instead of inheriting one from the environment.
- The previously uncovered side — a live backend, where the #1242 proxy-buffering message replaces the caller's guess — gets a test of its own rather than being asserted by accident on developer machines.
- `backlog/` added to .gitignore: the `backlog` CLI task tracker writes a config plus one markdown file per task into the repo root, and a contributor running it locally had three swept into a PR that was otherwise a single script (#1322).
Frontend suite with the app running locally: 1 file / 3 tests failing → 208 files / 1643 tests passing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The report arrived as a raw traceback pasted out of a log file — because that is the only place the reason existed. A failed chapter was a red row and the word "failed"; the SSE event carried the literal string "chapter failed to render", the symptom the user could already see.
- Both the per-chapter and the terminal all-failed events are now built with the shared `core.failure` builder: sanitized text, a guaranteed non-empty reason, error class, docs deeplink and hint. `error` keeps mirroring `reason`, so older frontends and the Stories exporter are unaffected. The chapter list shows the reason inline, full text in the row tooltip.
- **A silent infinite hang on the same path.** asyncio refuses to put `StopIteration` into a Future — `_copy_future_state` raises TypeError inside the event loop's own callback, so the `run_in_executor` future is never completed and the caller waits forever, with no error, event or timeout. Reachable from ordinary input: VoxCPM's `next_and_close` is a bare `next(gen)`, so a generator ending without yielding raises it straight into a GPU-pool worker. Fixed at the pool boundary (`_ResilientGpuPool.submit` plus a matching `_cpu_pool` subclass) as `WorkerStopIteration(RuntimeError)`, keeping the original as `__cause__`.
- An all-failed render now marks the job failed. It previously returned without touching job history, so the row stayed `running` and the next startup's orphan sweep read a hopeless render as interrupted and offered it as resumable (Greptile P1).
Tests: 6 pool-guard tests (two hang without the fix, bounded so a hang fails rather than stalls CI), longform e2e extended for both event shapes, the empty-`str(exc)` floor, and both sides of the job-status branch, plus the chapter-list component.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`tests/backend/conftest.py` purges `main` / `core*` / `api*` / `services*` from `sys.modules` after every test it owns, and `backend/tests/` modules bind `import services.model_manager as mm` at COLLECTION time — so in a combined `pytest tests/ backend/tests/` session the alias and the live module are two different objects. Two tests read the wrong one; CI's split invocation hid both.
- `test_lifespan_shutdown_mid_load_is_clean_and_clears_sentinel` patched the stale alias then drove main's lifespan, which loads through the live module. It now runs inside a purge/restore context and resolves from `sys.modules` after importing main, so the failure is deterministic standalone.
- The autouse `_clean_model_manager_shutdown_state` fixture cleaned only `sys.modules` while `test_shutdown_state_isolation.py` dirtied the alias. It now cleans the live module and any module-typed alias in the requesting test module — the idiom already used there for `asr_backend` — taking both the `import x.y as z` binding (package attribute) and the `sys.modules` entry.
- New fail-before/pass-after pair guards the alias half of the fixture in isolation.
Test-infrastructure only; no production code touched.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Docs-sync follow-up to #1314 — webCompat.js's header moved to 13.3/Safari 16.4
and its import site did not (CodeRabbit).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(macos): declare the floor the app actually delivers (13.3, not 12)
The app declared minimumSystemVersion 12.0 and the docs promised Monterey,
while the frontend required Safari 16.4 in three independent places: Vite's
default build target (baseline-widely-available = safari16.4), Tailwind v4's own
documented floor, and `@property` throughout its generated utilities. On
Monterey's WKWebView 15.6 the focus ring and accent surfaces resolve invalid,
and a bundled dependency ships a RegExp lookbehind that is a PARSE-time
SyntaxError no polyfill can reach.
Option B — actually supporting 15.6 — means setting build.target back,
replacing 64 color-mix() calls, dropping Tailwind v4 and replacing that
dependency, indefinitely, for an OS that stopped receiving security updates in
late 2024. The council was unanimous on A, and the precedent is uniform (Chrome
117, Electron 27, VS Code, Firefox 116).
minimumSystemVersion is also the guard: macOS itself refuses to launch a bundle
below it, so a Monterey user gets an explicit OS refusal rather than an app that
opens to a blank window — which matters because the Tauri updater has no
per-OS gating of its own.
Docs updated in the same change (README support table, docs/install/macos.md)
and the webCompat floor assertion re-derived to 16.4, so the post-floor API
list must be revisited the next time the floor moves.
Closes#1268
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(macos): raise the floor in the macOS overlay too, and assert it
Greptile P1, and correct: Tauri merges tauri.macos.conf.json OVER the base
config for a macOS build, and that file carried its own
minimumSystemVersion: 12.0. Changing the base config alone decided nothing —
the shipped bundle would have stayed Monterey-installable while the base
config, the README and the install docs all said 13.3.
Worse, the guard I added read only the base config, so it would have gone on
passing. A test that validates the wrong file is not a guard; it now asserts
both, with a comment saying why the overlay is the one that ships.
Also per review: the changelog entry was an editorial paragraph rather than a
one-line entry, and the section was missing ### Docs. Both fixed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* docs(webcompat): the module header still described the old 12.0 floor
The floor moved to 13.3/Safari 16.4 in this PR and the test was re-derived,
but webCompat.js still told the next reader the oldest supported WebView was
15.6 — which would make every fill here look mandatory instead of retained
for Linux's unpinnable WebKitGTK (CodeRabbit).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* test: reset model-manager shutdown state between backend tests
Two leaks, one of them mine.
1. `model_manager._shutting_down` is a module-global Event and the GPU pool is a
module-global executor. Any test that runs the app lifespan flips both on the
way out (begin_shutdown + _reset_gpu_pool) and nothing puts them back —
correct in production, where the process is ending; wrong across a combined
session. A test arriving with the flag set finds a shut-down executor, so its
first run_in_executor raises "cannot schedule new futures after shutdown",
which the preload path classifies as benign and swallows. The symptom is a
load that silently never starts. Reset before AND after: before so an
inherited flag cannot decide the test, after so a test that legitimately
shuts down does not hand it on.
2. tests/test_torch_compile_path_gate.py assigned services.settings_store into
sys.modules directly instead of via monkeypatch.setitem. That leaks
process-wide out of collection and breaks every later import of the real
module. backend/tests/test_no_module_stubs.py exists to catch exactly that,
and caught it — I introduced it two commits ago while isolating the Settings
gate for a review finding.
#1269 stays open for its last failure, which is a different root cause:
test_lifespan_shutdown_mid_load fails because a reload fixture in tests/
replaces services.model_manager, so the test patches one module object while
main's lifespan uses another (verified: `same=False`). That is the duplicate-
module class, not a state leak, and needs its own fix.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* test: let the shutdown-state reset fail loudly
CodeRabbit Major: the broad try/except meant a reset that raised left the next
test with stale shutdown or executor state — precisely the order-dependent
failure the fixture exists to remove, while looking like it had worked. That is
the same silent-fail-open shape as the watermark and ffmpeg bugs fixed earlier
in this cycle.
If reset_shutdown_flag() or _reset_gpu_pool() can raise, that is a real problem
in model_manager and it should be loud.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* test: assert the shutdown-state reset fixture actually resets (CodeRabbit)
Ordered pair: one test leaves the module globals exactly as the lifespan
leaves them, the next asserts it arrived clean — delete the fixture and the
second fails. Plus a mechanical guard that the reset stays un-swallowed, so
a future try/except cannot make the fixture look like it worked.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
The project carries 14 TTS + 11 ASR engines across 4 platforms with one
maintainer. That breadth is an asset only while every one of them still works;
otherwise it is a pile of support queues, and the first-run promise is what pays
for it.
So: engines are hired for a named job, not added to a list. Documents the job
map (each job has one holder), the seven conditions, the deprecation rule for
engines that lose their steward and their smoke test, and the out-of-tree path.
The point is to make "no" a property of the bar rather than a judgement of the
contributor — and to make "yes" fast when a proposal clears it. #1306 is the
first proposal judged against it.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
#1261: dev mode, "Failed to fetch", the backend had answered 10 minutes
earlier, three dub uploads in quick succession — and the message offered only
"it most likely crashed or was killed mid-request".
`bun run dev` runs uvicorn with --reload. Any file change, including a save
while a request is in flight, restarts the process and drops the connection.
At the transport layer that is indistinguishable from a crash, and the fix is
simply to retry — which the message never suggested, so a developer went
looking for a Python traceback that was never written.
The dev copy now names auto-reload, says to retry first, and keeps the terminal
and omnivoice.log as the fallback for when it really did die. Server-mode copy
is untouched: there is no reloader there, so the crash reading is right.
Translated in all 21 locales.
Closes#1261
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
streamDropError() already consults the crash forensics, but "no crash marker"
is not "the backend died and we missed it". Outside the Tauri shell there is no
death watcher at all, so that branch is where every browser and Docker user
lands — and the caller's fallback asserted a cause on their behalf: "Likely ASR
backend failed to load".
#1242 reported exactly that, in `server` mode, with the backend having answered
20 s earlier. Nothing had crashed and nothing had failed to load, so the message
sent them after a model that was fine.
It now asks instead of assuming. If the backend is still answering, the process
did not go away, which rules the guess out — and in a served or containerised
deployment a stream that dies while the server is healthy is characteristically
a reverse proxy buffering or timing out the SSE connection, so the message says
that and gives the two settings that fix it. A real crash marker still wins over
the probe, and when the backend is gone too the caller's message stands.
The dub fallback no longer names a cause either, since it is only reached when
both signals are inconclusive.
Closes#1242
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
is_available() only checked that `moss_tts_nano` imported. The reporter had the
package installed, so the engine advertised itself ready, they switched to it,
and the first generate died with `cannot import name 'MossTTSNano'`. An
availability check that does not verify the API it will actually call is a check
that lies.
MOSS-TTS-Nano is installed straight from git with no pinned release and its
exported class has changed, so this does not chase the current name: it resolves
among the names upstream has used, requires the candidate to actually have
`from_pretrained` (matching on name alone would relocate the failure, not fix
it), and when nothing matches it reports unavailable — naming what the module
DOES export, so the report becomes a one-line fix instead of a dead end.
A missing package and a renamed class stay separate messages: different
problems, different fixes.
10 tests, including every historical class name and the ready-then-crash shape
that started this.
Closes#1287
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* fix(linux): AppImage blank window on Mesa 26.1+ hosts (#1258, #1244)
The AppImage bundles an Ubuntu-built WebKitGTK but ships no libEGL, so that
bundled WebKit runs against the HOST's Mesa. On Mesa >= 26.1 it calls
eglGetPlatformDisplay() in a way the newer driver rejects and the app dies
before it paints:
Could not create default EGL display: EGL_BAD_PARAMETER. Aborting...
No environment variable helps, because the failure is in EGL display creation
— before WebKit consults any rendering-path flag. #1258 confirmed
WEBKIT_DISABLE_DMABUF_RENDERER, WEBKIT_DMABUF_RENDERER_FORCE_SHM,
WEBKIT_SKIA_ENABLE_CPU_RENDERING, EGL_PLATFORM=surfaceless and
MESA_LOADER_DRIVER_OVERRIDE=swrast all fail identically.
Chasing the build runner's WebKit (#961 bumped 22.04 -> 24.04) cannot fix this
class: what we bundle is frozen and host Mesa keeps moving. So when the host
has a WebKitGTK at least as new as ours, let it win — the bundle still fills
every gap, and a host without WebKitGTK is untouched. That is exactly why
building from source works on the hardware where the AppImage does not.
The compositing workaround is re-decided against whichever library ends up
running, and AppRun.test.sh — which had never been wired into CI — now runs
there, so this logic stops being a regression test nothing executes.
* fix(review): the ordering change was a no-op; name the host libdir explicitly
CodeRabbit Major — correct, and it made the whole fix inert. LD_LIBRARY_PATH is
searched AHEAD of the linker's default paths no matter where in that variable a
directory sits, so on a normal launch (empty LD_LIBRARY_PATH) the bundle
remained the only explicit search directory and still won. Merely appending it
changed nothing. The host's WebKit libdir is now named explicitly, ahead of
ours. The new tests fail 3/3 against the previous version.
Greptile P1 — a host with the runtime but no -dev package has no .pc file, so
pkg-config can't answer and the check rejected a perfectly good system WebKit.
The libdir probe now falls back to ldconfig, and OMNIVOICE_PREFER_SYSTEM_WEBKIT
gives those users an explicit opt-in (=0 opts out) rather than gambling on an
unverified version, which would risk the #961 regression.
CodeRabbit — my changelog script had also inserted the CI entry into the
published 0.4.0 section. Removed; it belongs only under Unreleased.
CodeRabbit — the docs' source-build fallback used 'cd frontend', not the
repo-root flow the rest of the page documents. Fixed.
An automated reviewer raised a Critical asking for torch.compile to be disabled
by default on every platform "so the default is uniform", citing the
cross-platform parity rule. Following it would have slowed down every Linux
CUDA user to match hosts that cannot compile at all.
Read literally, the rule forbids GPU support: CUDA, MPS, DirectML and Triton
availability are all host-dependent by design. It was always about what a user
can SEE AND DO, not about throughput — so it now says that, in CLAUDE.md and
AGENTS.md, and .coderabbit.yaml tells the reviewers directly so the finding
stops regenerating every month.
An optimization skipped where it physically cannot work is not a parity
violation. A feature usable on one OS but not another still is.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* fix(errors): strip ffmpeg's banner so the failure is the message
ffmpeg and ffprobe print a version + configuration banner to stderr on every
invocation, before doing any work. When a command fails we capture that stderr
and it becomes the error, so #1309's reporter was shown several hundred
characters of build flags — "ffmpeg version N-125781-gacf6b520c1-20260727 …
--pkg-config-flags=--static --enable-gpl …" — and not one word about why the
extract failed. The diagnosis is always AFTER the banner.
Stripped centrally in build_failure() rather than at the extract call site:
every stage that shells out to ffmpeg (dub prep, export, retime, media probe)
captures the same stderr and had the same problem.
Done before classify() runs, too — matching docs topics against a build
configuration string is how a real topic gets missed.
A message that is ONLY a banner keeps the banner: unhelpful beats empty.
Closes#1309
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* test(errors): assert the banner is GONE, not just that the error is present
CodeRabbit: the classification test only checked that the post-banner text
appeared in `reason` — which was true before the fix too, since the banner was
simply prepended to it. It passed against the code it was written to catch.
Now asserts the banner markers are absent and that `reason` STARTS with the
real error, since burying the diagnosis after 300 characters of build flags is
the actual user complaint. Fails without strip_ffmpeg_banner().
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* feat(contact): add the project X account alongside Discord
Adds https://x.com/fs01c137y as a channel on the in-app Contact page and in
the README, next to the existing Discord links — updates, releases, and what
is being built next, for people who would rather not sit in a chat server.
Follows the ContactPage convention: the URL is a module constant so no surface
can drift, and the card explains WHEN to use the channel rather than being a
bare link. lucide dropped its Twitter glyph, so the icon is Megaphone, which
reads as announcements anyway.
contact.follow_* translated in all 21 locales; ContactPage test extended to
pin the URL.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(contact): correct the X handle to @idebpalash
Owner's current account. All six references updated in lockstep — README nav
row, badge row, CTA block and contributing list, plus the ContactPage constant
and the test that pins it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* feat(privacy): build the watermark toggle the app already promised
errors → enterprise_faq.a_watermark has told users "Commercial licensees can
disable it in Settings → Privacy" since watermarking shipped. That control did
not exist: /watermark/status and /watermark/settings had zero callers in the
frontend, is_enabled() passes no env= to resolve() so there was no environment
escape either, and the only way off was hand-editing prefs.json. A shipped
instruction that cannot be followed is worse than no instruction.
Adds the control, wired to the endpoints that were already there. It mirrors
AnalyticsOptIn's shape but inverts its default — analytics is OFF until you
opt in, provenance marking is ON until you opt out — and hides itself when
AudioSeal is unavailable, since an inert switch over a mark that cannot be
embedded is the same lie in the other direction.
Also corrects the FAQ string in all 21 locales: the toggle is available to
everyone, not only commercial licensees, and it only affects audio generated
after the change. (Whether disabling should be licence-gated is a product
decision — the text now describes what the app actually does.)
6 tests: reflects backend state rather than assuming it, turns off, turns back
on, renders nothing when AudioSeal is missing or the backend is unreachable,
and does not optimistically flip when the update fails.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(privacy): translate the watermark strings, retry the status fetch, guard unmount
CodeRabbit Major — the five new strings relied on defaultValue, so every
non-English user saw English. Translated privacy.watermark_title/subtitle/
on_toast/off_toast/failed in all 21 locales.
Greptile P1 — the status fetch was one-shot, so opening Privacy while the
backend was restarting hid the control for the rest of the session. Being
findable is the control's entire purpose (the FAQ tells people it is here), so
it now retries once after 2s before giving up.
CodeRabbit — a toggle resolving after the tab closes no longer sets state or
toasts over whatever screen the user moved to.
Tests: the two "renders nothing" cases now wait for the request to SETTLE
rather than merely to start (the initial render is empty, so they would have
passed even if the control appeared afterwards), plus a case proving recovery
from a failed first fetch. 7 passing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* fix(errors): explain a cut TLS connection instead of printing _ssl.c:1016
#1301 surfaced as "500 Internal Server Error: [SSL: UNEXPECTED_EOF_WHILE_
READING] EOF occurred in violation of protocol (_ssl.c:1016)" — meaningless to
a user, and unclassified: the existing SSL branch requires "handshake" or
"certificate verify failed", so this fell through with no hint at all.
Deliberately a SEPARATE class from SSL_HANDSHAKE_FAILURE rather than widening
it. That class means a proxy re-signed the certificate with a CA certifi does
not trust, and its advice is to set SSL_CERT_FILE or add an antivirus
exclusion. Here the handshake never failed on trust — the socket was cut
mid-exchange, usually flaky Wi-Fi, a reconnecting VPN, a captive portal, or a
server dropping a long transfer. Sending that user to fix their certificate
store is sending them to fix something that is not broken.
Classified before the handshake branch, because the raw text contains "ssl"
and the broader branch would otherwise claim it. Added to
_CONTEXT_FREE_HINT_CLASSES since its trigger is an exact OpenSSL string — that
matters here, because the raw 500 handler is precisely where the reporter met
it.
Closes#1301
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(errors): scope the resume guarantee, and require a TLS marker
Greptile P1 — the hint promised "OmniVoice resumes partial downloads rather
than starting over", unqualified. That is verified for HF model downloads
(snapshot_download) and segmented_download, but the hint is static and also
reaches media fetches where nothing guarantees it. Shipping an instruction that
is not true is the exact class of bug this session has been removing, so the
guarantee is now scoped to models.
CodeRabbit — the matcher accepted either EOF phrase with no ssl marker.
"unexpected EOF" is a phrase a parser or another transport can produce, and
those would have been handed VPN/proxy advice. The OpenSSL text always carries
the marker, so requiring it costs nothing.
Also fixes a tautology: test_not_mistaken_for_a_cert_trust_problem asserted
!= SSL_HANDSHAKE_FAILURE, which the OLD classifier satisfied by returning "".
It now pins the exact class. 2 of the 9 tests fail against the previous commit.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* fix(compile): skip torch.compile when the torch lib path has whitespace
Inductor passes the torch library directory to clang++/g++ as an unquoted -L
flag, so a path containing a space splits into two arguments and the compile
dies with "no such file or directory: 'Support/...'". The quoting bug is
inside PyTorch and we cannot fix it — but a path we already know cannot compile
is one we should not spend a compile attempt on.
The cost was never a broken generation (eager mode is the documented fallback);
it was a guaranteed-failing compile on every load, whose clang wreckage got
swallowed by `except Exception: logger.info(...)` and then ate a chunk of the
captured log tail. That is exactly how it surfaced in #1259, where it was not
the actual fault but crowded out the output that was.
Not platform-specific: macOS keeps app data under ~/Library/Application
Support/ and a Windows profile is routinely C:/Users/First Last.
OMNIVOICE_FORCE_TORCH_COMPILE=1 still overrides, consistent with the arch gate.
An unreadable torch path fails open — no evidence is not evidence of a problem.
Closes#1266
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(compile): redact the torch path in the skip reason; tighten the tests
CodeRabbit Major: the reason string embedded the absolute torch lib path, and
both log branches print it — so a home directory (and anything secret-shaped in
the path) went into omnivoice.log and any pasted bug report. It now goes through
core.failure.sanitize(), the same redaction every other user-facing failure text
uses, with a basename fallback if that import ever fails.
Also per review: the Settings gate is isolated in the test helper (it was
reading the real settings_store, so a persisted perf.torch_compile_disabled=1
could have decided these tests instead of the path logic), and two logging
contracts are now asserted rather than merely exercised — the forced-override
warning, and the skip message naming OMNIVOICE_FORCE_TORCH_COMPILE. A skip the
user cannot discover how to override is a dead end.
DECLINED: CodeRabbit's Critical asks for torch.compile to be disabled by
default on every platform "so the default is uniform". That would make every
Linux CUDA user slower to satisfy a rule about USER-VISIBLE default behaviour —
and torch.compile is not user-visible, it is an internal optimization whose
absence shows up only as speed. The function has always diverged by host by
design: device != "cuda" skips, and no-Triton skips (which is every Windows
install). This gate adds no new divergence; it declines a compile that is
GUARANTEED to fail on that host, which is the same shape as the existing arch
gate. Disabling a working optimization everywhere would be the regression.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* test: stop config-path leakage across test modules (#1269)
Ten test modules share a fixture shape: monkeypatch OMNIVOICE_DATA_DIR to a
tmp_path, then importlib.reload(core.config) (plus core.db, a router, main).
monkeypatch restores the ENV VAR at teardown — and nothing reloads the modules
back, so the path constants keep pointing at that test's tmp_path for the rest
of the session.
In a combined `pytest tests/ backend/tests/` run that produced three different
answers to "where is the voices directory":
OMNIVOICE_DATA_DIR .../omnivoice-test-data-vna0ywre (correct)
core.config.VOICES_DIR .../test_fitted_srt_last_cue_withi0/… (leaked)
profiles.VOICES_DIR .../test_clone_profile_save_saniti0/… (leaked)
— which is why the personas import tests wrote a file to one directory and
asserted it existed in another.
Restores at MODULE teardown, and that boundary is the design. Function scope
was wrong: tests/smoke/test_boot_smoke.py has a module-scoped fixture that
deliberately aims core.config at a frozen fixture directory for the length of
that file, and a per-test restore reset it between that module's own tests.
Within a module a fixture cannot tell deliberate setup from a leak; across
modules there is no ambiguity.
Snapshot/restore of the constants rather than re-reloading: a reload would
re-register FastAPI routes and rebuild module state as a side effect, while a
setattr is inert. It also re-syncs modules that copied a value out of
core.config — a reload fixture typically imports the router under test for the
first time, so it has no earlier value to put back.
3 of the 4 failures are fixed. test_lifespan_shutdown_mid_load_is_clean_and_
clears_sentinel still fails in a combined run for an unrelated reason (its
preload never reaches run_in_executor); #1269 stays open for that one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* test: import core.config when snapshotting, don't just probe sys.modules
Greptile P1: a sys.modules-only probe returns {} when this module is the first
to import core.config — and the empty-snapshot guard then skips restoration
entirely, so the module most likely to reload config was the one least
protected.
Importing is cheap and idempotent, and tests/conftest.py has already pointed
OMNIVOICE_DATA_DIR at a throwaway dir before any fixture runs, so the captured
values are the right ones.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* fix(crash): stop blaming VRAM for native faults
#1275 (Windows 0xC0000005 on an RTX 2080 SUPER) and #1293 (SIGSEGV on Linux)
both fell through to "you ran out of VRAM while loading the ASR model" — so
the advice was to flush a model that had nothing to do with it. A segfault is
bad machine code, not slow memory exhaustion; the real causes are a GPU driver
that disagrees with the bundled CUDA runtime, or a weight file that downloaded
incompletely and is being memory-mapped.
Windows has no signals here, so the shell sees the raw NTSTATUS as a negative
exit code — those are matched explicitly or they read as an ordinary non-zero
exit.
Deliberately narrow: only SIGILL and SIGSEGV, whose numbers are identical on
every POSIX platform. SIGABRT stays on the VRAM path because abort() is how a
fatal CUDA error exits, including an async out-of-memory — an existing test
pins that, and it caught this when the first cut was too greedy. SIGBUS is
excluded because its number is platform-dependent (7 on Linux, 10 on macOS,
where 10 is SIGUSR1 on Linux).
Repeat offenders are now pointed at the crash-isolated subprocess engine that
landed in #1292 — it takes the sidecar down instead of the whole backend.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(crash): offer both isolated engines, and translate the guidance
Greptile P1: the crash marker records HOW the process died, not which
subsystem was running — a segfault during transcription looks identical to one
during synthesis. Naming only the TTS escape hatch sent ASR crashes to a fix
that leaves the crashing path untouched. Both are now offered so the user
picks the one they were using; #1304 supplies the ASR side.
CodeRabbit Major: the new guidance was hardcoded English, which the
localization rule forbids. All four hints in crashCauseHint now route through
i18next with the English as defaultValue — so a missing key still renders
exactly what it rendered before (no regression, no test churn) while the
strings become translatable. crash_port_in_use, crash_oom_kill and
crash_native_fault are translated in all 21 locales.
Also fixes a test that claimed to prove repeat-fault behaviour while calling
the hint once; it now asserts what the message actually has to contain.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* fix(engines): warn before a long CPU synth burns the whole budget
#1288 closed the under-provisioned-GPU gap but left the CPU one open, and I
missed it: a CPU-only host is a BENIGN routing verdict, so routingNotice()
correctly stays silent — yet #1299 and #1260 are exactly that shape, CPU hosts
that hit the 300s budget on long text with no warning at all. "Nothing is
misconfigured" and "this will finish in time" are different claims.
Threshold is the backend's own definition of past-short: generate_timeout_for()
gives the first 1200 characters the flat budget before extending it, so
ordinary sentences on a CPU laptop stay quiet and only the shape that actually
times out is flagged. Hardware caveats still take precedence — one toast, and
it names the real reason rather than generic advice.
5 tests; engines.cpuLongText translated in all 21 locales.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(engines): don't tell CPU-tuned engines to switch to themselves
Greptile P1. The advice names OmniVoice GGUF and Supertonic-3 as the CPU-tuned
alternatives — shown to someone already running one of them, it is advice to
switch to what they are using. Those two now get the same warning without the
self-referential clause; the engine set matches the backend's own timeout
message so the two can't disagree about who is CPU-tuned.
Also documents the preflight in docs/performance.md (docs-sync rule): both
warning shapes, why the threshold is 1200 characters (it is the figure the
budget itself uses), that they are advisory and once-per-engine-per-session,
and the CPU-tuned exception.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* fix(engines): path-aware GPU-pool slot in SubprocessASRBackend.transcribe()
transcribe() had the same on-pool self-deadlock that generate() had (fixed in
#1298): a bare no-op submitted to the GPU pool, but run_transcribe_guarded
dispatches it via run_in_executor(_gpu_pool), already on a pool worker, so on
a 1-worker (MPS) pool the no-op queued behind the job running it and timed
out before the sidecar spawned. IsolatedFasterWhisperBackend on MPS hit this
on every transcription.
Mirror generate()'s path-aware slot block (on-pool skip via
running_on_gpu_pool; off-pool _occupy hold) in transcribe(). The pattern is
duplicated rather than extracted into a shared helper to avoid reworking
generate(), which just shipped (#1298) with a CodeQL fix; extracting a shared
contextmanager is a clean follow-up. Regression test added (transcribe
dispatched on a pool worker).
* fix(engines): import threading in subprocess_asr
transcribe()'s off-pool slot hold uses threading.Event(), but the module never
imported threading — every subprocess-ASR transcribe raised NameError, and the
three round-trip tests failed in CI.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* test(engines): cover the off-pool transcribe branch
Only the on-pool path had a test, so `threading.Event()` in the off-pool
branch shipped with `threading` never imported — every direct caller hit
NameError before the sidecar started. Both bots caught it on review; nothing
in the suite did. A branch with no test is how a one-word bug reaches CI.
Also asserts the slot is genuinely released afterwards. Fails without the
import fix; the pre-existing on-pool test still passes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: debpalash <4178343+debpalash@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
The Windows-only 25 came from a warm-cache measurement: Linux and macOS finish
in ~65s when setup-uv restores its cache, so 10 looked generous. Run
30439640107 then hit "Failed to restore: Cache service responded with 400",
Linux installed torch from scratch, and the leg was killed at 10m17s. The 65s
was the cache, not the platform — my per-leg split was reasoning from the
wrong baseline.
A cache miss is not rare enough to treat as an outage (the cache service 400s,
a lockfile change invalidates the key, a new runner image starts empty), and a
timeout here is self-perpetuating: the leg dies before the post-step saves the
cache, so the next run is cold too.
25 everywhere, still bounded.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(engines): make SubprocessBackend.generate() path-aware on the GPU pool
generate()'s slot handling had two bugs:
1. On-pool self-deadlock: /v1/audio/speech and /generate (and audiobook,
dub, batch) dispatch generate() via run_on_gpu_pool_guarded, already on
a pool worker, so the inner slot submit queued behind the very job
running it on a 1-worker (MPS) pool and timed out before the sidecar
spawned. Every subprocess engine surfaced the in-process 300s-abandon
instead of synthesizing.
2. Off-pool no hold: the off-pool slot was a bare no-op that released the
worker before _spawn(), so off-pool callers (engine self-test,
diagnostics) could synthesize concurrently with a pool job and
over-subscribe the GPU.
Make the slot block path-aware: on-pool callers skip (the outer
run_on_gpu_pool_guarded already holds _running for the whole sidecar
exchange); off-pool callers hold a real slot for the whole synthesis via
an _occupy task that blocks the worker until _held is set in the finally.
Single release point in the finally.
Regression tests: generate dispatched on a pool worker (on-pool skip) and
a concurrent pool job blocked during an off-pool generate (off-pool hold).
Both verified fail-before / pass-after.
Supersedes #1296 (on-pool-skip-only). Closes#1295, #1297.
* Address review: couple on-pool skip to the pool prefix; fix comment
/simplify + /code-review flagged that the on-pool skip keyed on the literal
"gpu-pool" string, decoupled from _build_gpu_pool's thread_name_prefix. A
rename would silently re-introduce the exact self-deadlock this PR fixes (and
the tests can't catch it, since they hardcode the prefix). Centralise the
prefix in _GPU_POOL_THREAD_PREFIX + a running_on_gpu_pool() helper, used by
_build_gpu_pool, the skip in generate(), and _heal_tts_placement.
Also fix the comment: the Settings engine self-test rejects subprocess-isolated
engines with a 400, so the only real off-pool caller is the diagnose.py
deep-synth probe.
* fix(engines): bind slot_future before the off-pool branch
CodeQL py/uninitialized-local-variable (error, blocking CI). `_held is not
None` does imply slot_future was assigned, so the current code is correct —
but the two are only coupled by convention, which the analyser cannot see and
a third exit path would quietly break. Binds it to None up front and guards
the cancel.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* test(engines): make the slot-hold regression deterministic and leak-free
CodeRabbit, valid on both counts. The test used sleep(0.8)/sleep(0.5) as
synchronization — the tests/** contract forbids it, and on a slow runner the
marker could be enqueued before the generator had reserved anything, so the
assertion passed for the wrong reason. It now waits on an event signalled when
the slot task actually starts, and asserts "did not run" via a result()
timeout rather than a bare sleep.
Cleanup moved into finally: an assertion failure used to leak the sidecar
process and the pool thread into the rest of the session.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: debpalash <4178343+debpalash@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* feat(engines): add omnivoice-subprocess, a crash-isolated TTS engine
The default in-process OmniVoice engine runs on the GPU ThreadPoolExecutor.
When a generate or load exceeds its execution budget the pool is "reset", but
the abandoned worker thread cannot be killed (Python cannot interrupt a native
torch/MPS call), so it keeps holding the device until it finishes on its own
and later synths queue behind it and hang. The reset restores pool capacity
but not the device. This is the residual root cause behind the closed#730
and #1190: the messaging/reset mitigations address the symptom, not the
device-holding zombie.
Add an opt-in `omnivoice-subprocess` engine that runs the same model in a
child process via SubprocessBackend. A child process can be hard-killed: on a
recv-timeout the watchdog calls proc.kill(), reclaiming VRAM/device, and the
next request transparently respawns a fresh sidecar. The in-process engine
remains the default, so existing users see no change; this is an opt-in for
unattended / scheduled / reaction-triggered synthesis where a stuck job must
self-recover instead of hanging until a manual restart.
Base-class and mitigation changes that ship with it:
- SubprocessBackend.generate() now consumes non-terminal {"op":"progress"}
frames a sidecar emits during a cold load (previously the first cold
generate after spawn failed, then worked on retry). Additive: engines that
reply with audio directly are unaffected.
- recv_timeout_s is overridable per engine (default 60s unchanged); the new
engine sets it to the generate budget so a long-but-valid synth is not
falsely killed while a wedged one still is.
- make_room_before_generate(): free idle GPU memory before a warm, heavy
generate. The cold-load path already evicted; the warm path skipped it, so a
long synth on a VRAM-tight MPS box could contend its way into the budget.
Verified end-to-end against the live model (cold / warm / recovery-after-kill)
and under a sustained + concurrent-pressure soak: killed-worker recovery 5/5,
chunked long text 9/9, no memory leak.
* Address review: install_hint + move make_room into get_model
- Add `omnivoice-subprocess` to `_INSTALL_HINTS`; the
test_install_hints_cover_all_registered_backends gate requires every
registered backend to carry one (this was the CI failure).
- Move the warm-generate VRAM eviction out of the /generate and
/v1/audio/speech routes and into get_model()'s warm-return path, so EVERY
native TTS generate is covered (REST, WS TTS, dub, batch, audiobook), not
just the two REST routes. Drops the now-redundant per-route wiring.
(Greptile P1: the per-route placement missed the other generation surfaces.)
* Address review: drop dead long-text eviction path; log probe failure
- _should_make_room_for_generate: the long-text headroom boost became dead
code once the eviction moved into get_model() (which has no text), so the
long-text branch never fired. Removed the text param, the long-text
threshold/multiplier branch, and the now-unused _env_float helper. The core
RAM-tight gate (the part that matters on a starved box) is unchanged.
- Log the available_memory probe failure at debug instead of silently
swallowing it (CodeRabbit: silent swallow breaks the debug trail).
- Tests updated for the text-agnostic policy.
* fix(engines): stop subprocess generate() self-deadlock on 1-worker pools
SubprocessBackend.generate() acquires a GPU-pool slot for accounting, but
/v1/audio/speech and /generate dispatch backend.generate() via
run_on_gpu_pool_guarded, i.e. already ON a pool worker. On a 1-worker pool
(MPS) the inner pool.submit queued behind the very job running it and
slot_future.result(timeout=10) raised before the sidecar ever spawned, so
omnivoice-subprocess (and every other subprocess engine on MPS) surfaced the
in-process 300s-abandon instead of synthesizing.
Skip the slot acquisition when current_thread() is already a gpu-pool worker;
the outer guard already accounts for the slot. Direct callers (off the pool)
still acquire one. Regression test added (generate on a pool worker).
* Address review: reword slot-skip comment (fixes watermark-coverage CI) + simplify
- The slot-skip comment said "dispatch backend.generate() via", and
test_watermark_route_coverage's _SYNTH_CALL regex matches the literal
backend.generate( anywhere in a module, so it counted subprocess_backend.py
as a synthesis producer that must reference mark_synthetic (it doesn't — the
routes apply mark_synthetic; the engine sits below the chokepoint, like
tts_backend.py). Reworded to "dispatch generate() via".
- Fold in the simplify refinement: single negated predicate, import+pool
moved into the acquire branch.
* fix(engines): warn about under-provisioned hardware before the synth, not after
Six reports are the same story: #1240, #1246, #1248, #1277, #1283, #1284 —
4 GB and 6 GB cards running an engine that wants 6 GB, each one waiting out
the full 300s compute budget to be told the job "was too heavy". The routing
layer knew the whole time. The error text even names the card and the figure.
The caveat only ever surfaced on the engine-PICK toast, so it reached people
who changed engines and nobody whose engine was already selected — the
default, or one persisted from a previous session. That is most users.
/generate does return X-OmniVoice-Routing, but a response header arrives when
the job ends, five minutes too late to be a warning.
So the check moves to the chokepoint every synth path shares (api/generate.ts,
same argument as the in-flight count). Fire-and-forget: never awaited, so it
cannot add latency to the request it warns about; never throws, so an
unreachable backend costs a warning rather than a generate; once per
engine+reason per session, so it informs instead of nagging. Advisory, not
blocking — the driver can page to system RAM and short inputs fit where long
ones don't.
Extracts routingNotice() as the single frontend mirror of the backend's
routing_notice(). Two callers now need "is this verdict worth interrupting
for", and two inline copies would drift — invisibly, until someone on DirectML
or an unavailable engine gets a hardware warning for a normal pick.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(cuda): stop sending every RTX 40-series card to the CPU
The SM-arch gate required the device's exact tag in get_arch_list(). NVIDIA's
rules are not exact, and PyTorch depends on that: SASS is binary-compatible
UPWARD within a major version, so the official wheels ship sm_80/sm_86 and
deliberately no sm_89 — the 8.6 kernels already cover Ada. Exact matching
therefore declared sm_89 unsupported, check_device_compatibility() returned
False, and get_best_device() silently returned "cpu".
That is every RTX 4060/4070/4080/4090, not just the reporter's card (#1285) —
each one running TTS on the CPU on hardware that works fine, with a message
telling them their GPU was unsupported.
cuda_build_covers() now applies the real rules: sm_XY covers same-major
devices with minor >= Y; compute_XY PTX JITs forward to anything newer; an
a/f suffix (sm_90a) is architecture-specific and matches exactly. Unparseable
entries are skipped, and an empty arch list still degrades to "compatible" —
the pre-existing fail-open contract.
The remediation text also pointed at a NIGHTLY index for what is a stable
supported card; it now names the stable cu128 index.
12 tests: the Ada regression, Jetson Orin (8.7), downward-within-major and
cross-major rejection, PTX forward-JIT, arch-specific suffixes, and a genuine
sm_120-on-old-wheel mismatch so the gate is proven to still work.
Closes#1285
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* test(cuda): resolve app modules at call time, not import time
The tests/** review contract forbids module-level imports of app modules —
they go stale under sys.modules pollution from other suites, which is the live
cause of #1269's cross-suite failures. Binds core.device_caps per call.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix: restore generate.ts and generatePreflight.test.js from main
Conflict markers were committed in the previous merge — `git add` on the
directory staged both files as resolved while the markers were still in them.
Both belong to #1288 and are unchanged by this PR, so they take main's version
verbatim.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* fix(engines): warn about under-provisioned hardware before the synth, not after
Six reports are the same story: #1240, #1246, #1248, #1277, #1283, #1284 —
4 GB and 6 GB cards running an engine that wants 6 GB, each one waiting out
the full 300s compute budget to be told the job "was too heavy". The routing
layer knew the whole time. The error text even names the card and the figure.
The caveat only ever surfaced on the engine-PICK toast, so it reached people
who changed engines and nobody whose engine was already selected — the
default, or one persisted from a previous session. That is most users.
/generate does return X-OmniVoice-Routing, but a response header arrives when
the job ends, five minutes too late to be a warning.
So the check moves to the chokepoint every synth path shares (api/generate.ts,
same argument as the in-flight count). Fire-and-forget: never awaited, so it
cannot add latency to the request it warns about; never throws, so an
unreachable backend costs a warning rather than a generate; once per
engine+reason per session, so it informs instead of nagging. Advisory, not
blocking — the driver can page to system RAM and short inputs fit where long
ones don't.
Extracts routingNotice() as the single frontend mirror of the backend's
routing_notice(). Two callers now need "is this verdict worth interrupting
for", and two inline copies would drift — invisibly, until someone on DirectML
or an unavailable engine gets a hardware warning for a normal pick.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(engines): route streaming synthesis through the generate chokepoint
streamGenerateSpeech POSTed /generate via apiFetch directly — a second,
parallel door. Everything attached to "the one call every synth path shares"
therefore did not apply to it: the in-flight count that stops the updater
relaunching mid-synthesis, and the new under-provisioned-hardware preflight
(Greptile P1). A chokepoint with two doors is not a chokepoint.
Also fixes two cache defects in the preflight itself:
- An engine pick left the cached /engines response describing the PREVIOUS
engine for up to 60s, so switching engines and generating immediately warned
about the one you just left — or stayed silent about the one you just chose.
notifyEngineSelected() now drops the cache, and hands over the caveat it just
displayed so the preflight does not repeat the same sentence seconds later.
- A rejected listEngines() promise stayed cached for the full TTL, silencing
the caveat for a minute after the backend came back. It is now evicted, but
only if it is still the current entry, so a racing newer fetch survives.
6 new tests; 2 of the 3 streaming ones fail before this change.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(engines): hold the in-flight claim for the whole stream, not just headers
generateSpeech releases its claim when the Response resolves — when the
HEADERS arrive — but a streaming synth generates audio for as long as the body
is read. Routing streaming through it gave it a count for the first time, then
dropped that count to zero for the entire synthesis, so the updater saw idle
and was free to relaunch mid-stream (Greptile P1).
streamGenerateSpeech now wraps the whole operation in withTtsInflight().
Nesting is harmless because the store tracks a count, not a boolean — the
inner claim just bumps it to 2 and back.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* ci(windows): make the ffmpeg retry test the outcome, not choco's exit code
The chocolatey feed 503'd; choco printed "Unable to find package 'ffmpeg'"
and "installed 0/0 packages" — then exited 0. The retry loop added on
2026-07-20 for this exact class was `choco install ... && break`, so it broke
out on attempt 1, no backoff ran, and the job died one line later on
`ffmpeg: command not found`. It took #1281 red on an unrelated change.
A retry that trusts a lying exit code is not a retry. The loop now exits on
`command -v ffmpeg` and still fails the job loudly when ffmpeg never arrives.
Tests extract the real step body from ci.yml and run it against a stubbed
choco; 2 of the 4 fail against the previous loop.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* test(ci): pin PATH to the stub dir so the retry test can't false-green
The harness inherited the ambient PATH, so a real ffmpeg satisfied
`command -v` and the loop exited on attempt 1 — every assertion passed
against a broken workflow. It happened twice: /opt/homebrew/bin locally, then
/usr/bin on the Linux runner, which is what took this PR red.
PATH is now the stub dir alone, with the few real tools the stubs need
symlinked in, and stub shebangs are absolute (`/usr/bin/env bash` cannot
resolve bash when PATH is one directory). test_harness_actually_hides_ffmpeg
asserts the sandbox is a sandbox, so the next leak fails loudly instead of
quietly passing.
2 of 5 fail against the old `&& break` loop.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* ci: give the Windows smoke leg its own timeout, per-leg not shared
Smoke (Windows) has been dying at 10m08s inside `uv sync`, and the shared
10-minute budget made it self-perpetuating: the leg is killed before the
post-step saves the uv cache, so the next run starts cold and dies the same
way. Nothing primes the cache, so it never gets faster.
Measured on run 30385710466 — Linux 65s, macOS 65s, Windows still installing
torch when the job was killed. Windows now gets 25 minutes, priced for one
cold install to finish and populate the cache; warm runs land nowhere near it.
Per-leg rather than raising the shared value, so a genuine hang on Linux or
macOS still fails fast instead of inheriting Windows' allowance.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* ci(windows): skip the backoff after the final attempt; tighten the tests
CodeRabbit, both valid:
- The loop announced "retrying in 90s" and slept after attempt 3, though no
fourth attempt exists — 90s added to an already-doomed job.
- The retry tests asserted `attempts >= N`, so a regression that kept going
after ffmpeg appeared would still pass. Pinned to exact counts, plus a case
asserting the final attempt announces no retry.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* fix(engines): show the under-provisioned-VRAM warning instead of discarding it
Four of the open low-VRAM reports (#1240, #1246, #1248 on 4 GB cards; #1277
on 6 GB) share one shape: the user generates, waits out the entire 300s
compute budget, and is then told the job "was too heavy for the available
compute".
The warning existed the whole time. Routing computes it (#1226's `_caveat`:
"…has 4.0 GB VRAM; this engine wants about 6 GB. It will run, but expect slow
generations that may time out"), and `/engines/select` echoes it in
`routing_reason` — but notifyEngineSelected only surfaced a reason when
`routing_status === 'cpu_fallback'`. The VRAM caveat rides on an ACCELERATED
verdict, so it fell through to the green "switched" success toast and was
thrown away. The user was told everything was fine, then waited five minutes
to find out it wasn't.
Now any caveat on the echo raises a warn-tone toast naming it, with a longer
duration since it lists the ways around the limit. This covers the kernel-risk
caveat on the same path.
Deliberately still ADVISORY, not blocking — matching the routing layer's
documented contract (the driver can page to system RAM, and short inputs fit
where long ones don't). The engine is still selected; the user just finds out
now instead of after the timeout. This is the first-run path too: the wizard's
library step shares notifyEngineSelected.
Fail-before verified: both new tests fail against the previous version.
Known remaining gap: a user whose engine is already selected sees this only
when they re-pick. A generate-time preflight would close that, but it needs a
"once per session, not per generate" design — filed as follow-up rather than
guessed at here.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* ci(release): stop the macOS preview updater bundle colliding with itself
The nightly preview run has failed on both macOS legs since early July:
Uploading OmniVoice Studio_x64.app.tar.gz...
##[error]Validation Failed: {"resource":"ReleaseAsset",
"code":"already_exists","field":"name"}
`preview` is a ROLLING release, reused every night, and macOS updater
artifacts are the only ones Tauri names without the version:
OmniVoice Studio_0.4.1-103_x64.dmg unique per run — uploads fine
OmniVoice Studio_x64.app.tar.gz constant — collides on run 2+
Consequences, verified against the live release: the macOS updater bundles on
`preview` were last written 2026-07-04 (x64) and 2026-07-05 (aarch64), and
latest.json 2026-07-13 — three weeks stale as of today. Preview-channel macOS
users had no working update path. The failure also lands AFTER the dmg
upload, so each run looked partly successful while going red.
Deletes this arch's updater bundle before the upload. Matches the STORED
asset name by querying the release rather than guessing the spelling — GitHub
rewrites spaces to dots, so "OmniVoice Studio_x64.app.tar.gz" is stored as
"OmniVoice.Studio_x64.app.tar.gz" and a literal delete-asset by the uploaded
name would silently no-op.
Scoped to the preview path (a v* tag creates a fresh release with nothing to
collide with) and to the job's own arch, so the parallel aarch64/x64 legs
can't touch each other's assets. Verified the filter against all 209 live
preview assets: it matches exactly the 4 colliding updater files and no
versioned artifact.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* ci(release): fail loud when the preview asset sweep can't do its job
The cleanup step treated every `gh` failure as "nothing to clear" — 401, 403,
429 and network errors included. That reintroduces the outage it was written
to fix, with the evidence removed: the stale bundle survives, the Tauri upload
dies with `already_exists`, and the one step that could have explained why is
green. Three weeks of broken macOS Preview updates started exactly this way.
Only an absent release/asset is benign now. A 404 on view means "no preview
release yet" (GH_TOKEN is scoped to this repo, so 404 really is absence); a
404 on delete means someone already removed it, which satisfies the goal. Every
other failure fails the step with the reason printed. An unexpected arch is
also fatal rather than a silent skip — same class of blind spot.
Adds tests/test_release_preview_asset_cleanup.py, which extracts this step's
real shell body from release.yml (so it cannot drift) and runs it against a
stubbed `gh`: 6 of the 8 cases fail against the previous version.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(engines): show the under-provisioned-VRAM warning instead of discarding it
Four of the open low-VRAM reports (#1240, #1246, #1248 on 4 GB cards; #1277
on 6 GB) share one shape: the user generates, waits out the entire 300s
compute budget, and is then told the job "was too heavy for the available
compute".
The warning existed the whole time. Routing computes it (#1226's `_caveat`:
"…has 4.0 GB VRAM; this engine wants about 6 GB. It will run, but expect slow
generations that may time out"), and `/engines/select` echoes it in
`routing_reason` — but notifyEngineSelected only surfaced a reason when
`routing_status === 'cpu_fallback'`. The VRAM caveat rides on an ACCELERATED
verdict, so it fell through to the green "switched" success toast and was
thrown away. The user was told everything was fine, then waited five minutes
to find out it wasn't.
Now any caveat on the echo raises a warn-tone toast naming it, with a longer
duration since it lists the ways around the limit. This covers the kernel-risk
caveat on the same path.
Deliberately still ADVISORY, not blocking — matching the routing layer's
documented contract (the driver can page to system RAM, and short inputs fit
where long ones don't). The engine is still selected; the user just finds out
now instead of after the timeout. This is the first-run path too: the wizard's
library step shares notifyEngineSelected.
Fail-before verified: both new tests fail against the previous version.
Known remaining gap: a user whose engine is already selected sees this only
when they re-pick. A generate-time preflight would close that, but it needs a
"once per session, not per generate" design — filed as follow-up rather than
guessed at here.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(i18n): write the caveat toast string, don't just translate a placeholder
CodeRabbit flagged engines.selectWithCaveat as untranslated in 20 locales.
It was worse than that: en.json said "{{engine}}: {{reason}}" too, so the
English string had never been written and every "translation" was a faithful
copy of a non-sentence. All 21 languages would have shown a bare
"omnivoice: <English backend text>".
Writes the en sentence, translates it into all 20, and translates
engines.selectCpuFallback alongside it — same function, same toast, and it
was English-only in every locale (missing-key ratchet tightened 518 -> 517,
zh-CN 511 -> 510).
Adds test_no_placeholder_only_values to pin the class. Parity tests cannot
catch this: the key is present everywhere and the placeholders match exactly.
Only the absence of prose gives it away, so the guard checks en.json too —
that is where this one started. A bot catching a mechanical rule twice means
the rule belongs in CI (CLAUDE.md, Token economy).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(engines): only warn on accelerated+caveat, mirroring routing_notice()
Greptile P1: testing a bare `routing_reason` also fires on benign verdicts.
Routing rule 5 gives a Windows DirectML host cpu_only + an explanatory reason
on a perfectly normal pick, and rule 6 attaches one to `unavailable` — neither
is a hardware warning, but both drew a 10s amber toast. routing_notice() in
engine_routing.py is the canonical predicate (cpu_fallback always, accelerated
only with a reason); the frontend now matches it. Two tests, both failing
before.
Also translates settings.engine_switched, which shipped as the identical
"{{family}} → {{engine}}" in all 21 files — an untranslated success toast
everywhere (CodeRabbit). That was the sole _PLACEHOLDER_ONLY_ALLOWLIST entry,
so the allowlist is now empty and the guard has no exceptions.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* style: oxfmt the new toast test cases
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Tightens the Token economy directive: shortest response that fully answers,
outlines/tables over prose, no preamble or recap. Applies to every response,
not just status updates. CLAUDE.md and AGENTS.md kept in sync.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Version files (package.json + the three mirrors) and both lockfiles bumped
0.4.1 → 0.4.2; [Unreleased] renamed to [0.4.2] — 2026-07-28.
Ships five user-facing fixes: the update toast, the restart guard that used
to discard in-flight work, the shutdown-is-not-a-crash 503, the self-healing
half-downloaded model, and the dub-history purge ordering — plus the
"Dismiss" mistranslation in five locales.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(errors): key the no-report rule on the shutdown marker, not on 503
Self-caught regression from #1272. Suppressing the "Report" action for every
503 was too broad: 503 is also how a real engine-load timeout and an
unavailable engine are reported (#1246, #1260, and #1277, filed hours ago
with a 503 in its very title). That would have removed the report button from
exactly the class of failure users need to be able to file — silencing real
bugs to hide a benign one.
The backend now tags only the shutdown case with a `[shutting_down]` marker,
following the existing `[clone_ref_unusable]` convention, and the UI matches
the marker instead of the status. A 503 that is a genuine failure keeps its
report action.
Localized message added to all 21 locales; the backend test pins the marker
so the cross-layer contract can't be renamed away.
Fail-before verified: the two "still offers Report for a 503" tests fail
against the shipped blanket-503 version.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* i18n(de): formal register for the two new German strings
Review: the surrounding errors.* strings and update.retry use "Sie"; both new
strings used the informal form. Turkish is consistently informal (install,
retry) so it stays as-is.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(dub): purge order was hash-dependent, and the cap could evict a live marker
main went red on my own test. Two distinct defects, both mine.
1. `targets = set(job_ids)` made iteration order depend on PYTHONHASHSEED, so
which markers a cap-forced trim discarded was luck. That is why the test
passed locally and failed in CI — verified: the old code passes at seeds
0/7/42 and fails at 12345. Now a de-duplicated list in caller order.
2. The size cap could evict markers the CURRENT purge had just recorded. Those
are the newest and the likeliest to still be held by a running job, so
dropping one is precisely the resurrection this mechanism exists to prevent.
A 'clear history' larger than the cap forced exactly that. The cap now never
touches the current purge, making the real bound cap + one purge — stated
plainly rather than implied.
Tests now pin both: identical survivors across runs, and an oversized purge
keeping all of its own markers. Verified across six hash seeds; full suite green
under 12345, the seed that reddened main.
* feat(update): announce updates as a toast with actions, not a wall of text
A version's release notes ARE the whole changelog section — v0.4.1's was 42
bullets. Any surface that renders them inline becomes unusable: older builds put
them in a blocking OS dialog that filled the screen and had to be dismissed
before the app could be touched.
Removing that dialog left the opposite failure. The only remaining signal was a
6-pixel dot beside the version number in the footer, which is easy to never
notice — so users either got shouted at or told nothing.
A toast is the middle: it names the version, offers Install and restart /
What's new / Later, and leaves. The notes stay one click away in Settings →
Updates, where there is room. Keyed by version so the 6-hourly re-check
replaces rather than stacks, and it never auto-dismisses — an update the user
hasn't answered is still true. Install declines while a generation is running,
since the relaunch would lose it.
Strings added to all 21 locales, translated rather than English-filled.
Tests pin the shape that matters: the toast takes no notes prop at all, renders
under 200 characters, and cannot stack duplicates.
* fix(update): don't relaunch while work is in flight; share the busy check
Review of #1272 found the restart guard was `dubStep === 'generating'` and
nothing else. Installing an update relaunches the process, so that permitted
throwing away a dub upload, a transcription, a translation, an export or a
standalone TTS synth. The same narrow check was written twice — in the new
toast and in UpdatesPanel — so the two could also drift apart.
Replaced with a single `isAppBusy(state)` in utils/appBusy, unioning the
signals the store actually has: the dub state machine, the floating status
pill (which every long background operation already pushes to), and
`ttsGenerating` — a new transient store field mirroring useTTS's local
`isGenerating`, which lived in a hook where no global check could see it.
`dubStep === 'editing'` is deliberately not busy: it waits on the user, and
counting it would block updates for as long as a transcript stays open.
Also from review:
- A failed lazy import of the toast fell into the outer catch, whose
setUpdateIdle() erased the update that had just been found. The
announcement is optional; the available state is not.
- "Dismiss" was machine-translated into the employment sense — terminate an
employee — in de/ja/ru/zh-CN/zh-TW, on close buttons and one aria-label.
Swept every locale rather than the four lines that were flagged.
- Hoisted the test's mock state with vi.hoisted.
CodeRabbit's changelog finding is declined: Highlights bullets carry no issue
refs by design, which tests/test_changelog_style.py enforces.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(shutdown): a quit mid-generate is not a 500, and not a bug report (#1276)
#1174 made a model load interrupted by shutdown benign for the background
preload, but a *request* that triggered a load took the generic unhandled-
exception path: crash log, ERROR traceback, and an error-journal entry that
feeds the bug-report pipeline. Quitting the app with a generate queued
surfaced "500 Internal Server Error: model load skipped: backend shutting
down" and offered to file a GitHub issue for a normal teardown.
Nothing failed — the process is exiting. The handler now answers 503 with
Retry-After and an actionable detail, ahead of the crash-log/journal writes.
Frontend half of the same bug: toastErrorWithReport offered "Report" for any
error. A 503 means "not now, try again" by definition, so it now shows the
backend's message without the report action — covering a still-warming
backend too, not just this shutdown path.
Fail-before/pass-after tests on both sides, including that the shutdown case
leaves no crash-log entry and no journal record.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(models): repair a half-downloaded model whichever way it reports (#1273)
transformers has two unrelated wordings for "this snapshot has no weight
shard", sharing no words:
hub load "<repo> does not appear to have a file named …"
local dir "Error no file named model.safetensors, … found in directory …"
The self-heal and the failure classifier both matched only the first. The
second is what a load of a *subfolder* inside a cached snapshot raises —
exactly where an interrupted download leaves a half-written repo — so the
reporter got neither the automatic repair (delete broken entries →
re-download → retry) nor an actionable hint, just a raw 500. Their disk had
10.6 GB free, i.e. a download that ran out of room.
The phrase list now lives once in core.failure, so the healer and the error
text cannot disagree about what an interrupted download looks like. Both
fragments of the second wording must match — "no file named" alone is
ordinary English and must not claim the class.
Also hardens the #1276 handler found by running both suites in one session:
services.model_manager can be imported under two module names, making two
distinct ModelLoadInterruptedByShutdown classes and breaking a bare
isinstance — which silently restored the 500 this fix exists to remove. Now
matched by isinstance OR class name, with a test that raises a same-named
class from a different module.
Verified against the full tests/ + backend/tests/ session: the only remaining
failures are the four pre-existing #1269 isolation leaks, unchanged.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(update): count synths in flight at the chokepoint, not in one caller
Review round 2. Both Greptile P1s were the same weakness in my first pass:
tracking the synth in useTTS meant only the Generate tab counted (voice
previews, the compare modal, the stories editor and profile previews call
generateSpeech directly and were invisible), and a boolean meant two
overlapping syntheses cleared each other — whichever settled first reported
"idle" while the other was still running, so Install and restart discarded it.
Moved to `api/generate.ts`, around the one `/generate` call all seven paths
share, as a count with a `finally` release (so an abort or a network error
frees it too). A future synth caller is covered without opting in.
Fail-before verified: the overlap test fails against the boolean version.
Also from review:
- UpdatesPanel re-reads the busy state at click time; the render-time snapshot
only exists to disable the button, and work can start after the last render.
- The 503 now carries the allowed-origin CORS headers. Without them the
browser reports a bare CORS failure and the actionable detail — the whole
point of the fix — never reaches the user. Both error responses build them
through one helper now.
- Log `request.url.path`, not the full URL: a query string can carry tokens
and newlines.
- `update.busy` said "finish your dub first" in all 21 locales, but the guard
now covers uploads, transcription, translation, export and synth. Rewritten
as work-in-progress wording, translated per locale.
- ru `common.dismissStatus` was "reject status"; missed in the earlier sweep.
Declined: CodeRabbit's request for a `(#NNN)` ref on the Highlights bullet —
Highlights carry no refs by design (CLAUDE.md), and tests/test_changelog_style.py
enforces it. Also declined gating the cache re-download behind a confirmation:
the self-heal already existed and already ran for the sibling wording, this
only stops it missing half the class, and it stays behind the existing
_hf_offline() check.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(update): grey out Install while work is running
CI lint caught `busy` as unused after the click-time check moved into the
handler — and that exposed a real gap: `busy` had never been wired to
anything. The comment claimed it disabled the button; it didn't. Clicking
Install during a synth just bounced a toast back.
Now it does what it said: the Install and Restart buttons are disabled while
work is in flight, with `update.busy` as the tooltip. The click-time read
stays the authority, since work can start between the last render and the
click — the disabled state is the explanation, not the safety.
Adds the first UpdatesPanel test. The case it pins hardest is the inverse of
the bug: a busy predicate stuck at true would make the app permanently
un-updatable, which is worse than what this fixes. So it asserts enabled when
idle and during dub 'editing' (which waits on the user), disabled during an
upload, a translation, and one or more synths.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
main went red on my own test. Two distinct defects, both mine.
1. `targets = set(job_ids)` made iteration order depend on PYTHONHASHSEED, so
which markers a cap-forced trim discarded was luck. That is why the test
passed locally and failed in CI — verified: the old code passes at seeds
0/7/42 and fails at 12345. Now a de-duplicated list in caller order.
2. The size cap could evict markers the CURRENT purge had just recorded. Those
are the newest and the likeliest to still be held by a running job, so
dropping one is precisely the resurrection this mechanism exists to prevent.
A 'clear history' larger than the cap forced exactly that. The cap now never
touches the current purge, making the real bound cap + one purge — stated
plainly rather than implied.
Tests now pin both: identical survivors across runs, and an oversized purge
keeping all of its own markers. Verified across six hash seeds; full suite green
under 12345, the seed that reddened main.
* release: v0.4.1
Seven user-reported issues fixed since v0.4.0 (#1221–#1229). Version bumped
across the single source of truth (frontend/package.json) and its three
toolchain mirrors; [Unreleased] renamed to the release section that
release.yml extracts verbatim as the GitHub Release body.
Docker tag examples in docs/install/docker.md and deploy/dockerhub-overview.md
updated to 0.4.1 (docs-sync rule).
* release: #1239 review — sync Cargo.lock to 0.4.1
Greptile: the manifest said 0.4.1 while Cargo.lock still recorded 0.4.0, so a
`cargo build --locked` (and the Tauri bundler's own locked build) would fail
on the mismatch. Regenerating locally updated it but it was never staged.
* release: re-sync [0.4.1] after the fix merges, date it 2026-07-27
Picks up everything merged since the section was first written: the first-run
wizard chrome (#1241), the MCP host allowlist (#1249), the macOS 12 startup
crash (#1245), and the six error-message fixes (#1247, #1251, #1254, #1256,
#1257, #1262).
Deliberately NOT included:
- the dub delete-resurrection fix (#1252, #1253) — split to #1270 after it
needed six rounds of correction, the last two finding that the fix did not
close the reported case and that its own bound reintroduced it;
- the Linux AppImage WebKit fix (#1258, #1244) — held on #1265 pending
confirmation on a Mesa 26.1 host, which nobody has run.
* fix(dub): deleting a dub no longer resurrects it (#1252, #1253)
Split out of #1264. The other six fixes there are independent error-message
changes that needed no corrections; this one is a concurrency change that
needed five rounds, each finding something real in work that looked finished
and tested:
1. review: merge and save were split, so a delete between them had the row
written straight back;
2. review: dict membership cannot express 'withdrawn' — an absent key means
'not written yet' for a new job and 'deleted' for an established one;
3. fail-before check: the race test was not testing the race (it asserted
WHAT happened, never WHEN, and 'save after delete' IS the resurrection);
4. review: the gate sat in two ingest helpers while eight direct save_job
call sites bypassed it;
5. direct check of the reported scenario: the tombstone covered in-flight
ingests only, so a delete during a RENDER — the common case — still
resurrected the job.
Riding a release on that record is not a good trade, so it ships on its own.
What it does now: the withdrawal is recorded when a job is DELETED, held in a
bounded LRU (there is no moment at which a delete stops mattering), and checked
inside save_job so every caller inherits it. Re-importing an id is the only
thing that revives it. The lock is re-entrant because the atomic helpers call
save_job while holding it.
Carries #1252's message half too: str(KeyError) is the repr of the key, which
is how the user's own job id became the entire error text.
* fix(dub): expire withdrawal markers by age, not by count
Greptile P1 — the sixth real finding on this fix, and reachable by ordinary
use. DELETE /dub/history selects every row with no limit, so a user with a
large history clearing it mid-render pushed that very job's marker out of a
count-bounded LRU, and the render then wrote it straight back.
Age is the honest policy: what matters is how long ago the delete happened, not
how many others followed it. Six hours outlives any realistic render or
transcribe. The count cap stays only as a memory backstop, raised far above any
real history and documented as such.
Verified fail-before: restoring count-based eviction fails both new tests.
* docs(changelog): the dub delete-resurrection fix (#1252, #1253)
* fix(errors): four failures that reached users as raw OS text (#1262, #1256, #1251, #1252)
#1262 — a voice profile named in any non-latin-1 script 500'd every download
endpoint with "'latin-1' codec can't encode characters in position 22-25".
`attachment; filename="` is exactly 22 characters, so those were the first
four characters of the user's own name. The sanitisers in front of the header
filtered with str.isalnum(), which is True for every alphabetic script — they
stripped punctuation and passed exactly what breaks the header. Ten sites, one
RFC 6266 builder, plus a guard so an eleventh can't be hand-written.
#1256 — a synth died on FileNotFoundError: 'ffprobe' and was reported as "an
error OmniVoice doesn't recognize", on a Mac where the app's own ffprobe was
resolvable the whole time. Our call sites pass explicit paths; a dependency
shelling out by bare name does not. The resolved directories are now published
on PATH, and the failure is classified either way.
#1251 — "The paging file is too small" reached the user as a bare 500. It was
already counted as an OOM, but that remedy (close apps, lighter engine) is
wrong on a 32 GB machine — the fix is a Windows setting, and the hint now says
which. Matched on the code in both the Python and Rust spellings.
#1252/#1253 — deleting a dub mid-import crashed it with `ingest: 'mgw39lx3'`:
str(KeyError) is the repr of the key. The pipeline blind-subscripted a job that
DELETE /dub/history/{id} had popped minutes earlier. It now stops quietly, and
no exception whose str() is a bare value can present itself that way again.
* fix(engines): Unload 400'd, a wrong language said nothing, DRM was retried by hand (#1247, #1257, #1254)
#1247 — list_loaded() advertises in-process engines as `engine:<id>` with
"unloadable": true, but unload() only ever handled tts/diarization/sidecars.
The panel was rendering a button for ids the dispatcher rejected. The engines
already implement unload(); only the routing was missing. The contract test
written for it immediately found a second instance — `capture-asr`, listed the
same way with no branch either — which is why it enumerates the listing rather
than hard-coding ids.
#1257 — MLXAudioBackend.supported_languages() returns ["multi"] on the stated
assumption that "each engine silently ignores languages it doesn't know". It
doesn't; the library raises. So the picker offers all 646 languages and the
rejection arrived as a bare list of 23 codes, naming neither the engine nor the
way out. Enumerating each model's real language set would be a brittle map that
goes stale every engine update — name the engine and the fix instead.
#1254 — reported as intermittent: the same URL failed as DRM-protected, then
succeeded on retry. Real DRM doesn't lapse; the player client varies. That is
the same shape as the 403 case which already escalates through
_YT_PLAYER_CLIENTS, so DRM now routes into it. If every client still refuses,
the failure is classified instead of arriving as a raw yt-dlp line.
* fix(review): close the delete race, narrow the tool match, sanitize the fallback
Greptile P1 + CodeRabbit Major — verified real, and mine: splitting merge from
save left a window where a delete lands between them, so the pending save
UPSERTs the row straight back and a dub the user deleted reappears. Now one
atomic step under _dub_jobs_lock, with both delete endpoints purging rows and
memory under that same lock. That also fixed DELETE /dub/history, which
deleted every row but evicted nothing — an in-flight job survived 'clear
history' outright and re-saved itself on completion.
CodeRabbit Minor (#1256): the media-tool match accepted any message ending in
'ffmpeg'/'ffprobe', so a missing FILE at /tmp/ffmpeg got the 'repair your
media engine' remedy. Now requires the name unquoted-and-unqualified.
CodeRabbit Major (#1262): `fallback` reached the header verbatim whenever the
real name folded away entirely, walking past every guard the name goes
through. Folded like the name.
CodeRabbit Major (#1256): the PATH log printed resolved directories, and a
user-set FFMPEG_PATH sits under their home. Logs a count now.
CodeRabbit Minor (#1262): the subtitle-route assertion also passed against the
pre-fix header; it now asserts filename*= too.
Skipped: 'Highlights bullets must end with (#N)'. CLAUDE.md scopes that to the
### subsections; none of the seven pre-existing highlights carry refs, and
tests/test_changelog_style.py encodes the rule already.
* fix(review): the remaining unlocked save paths, an over-broad signature, two weak tests
Greptile P1 — the mid-pipeline put_job + save_job pairs were still unlocked, so
a clear-history landing between them left a ghost row behind the purge. Both go
through put_and_save_job now; only the final completion gate decides whether a
withdrawn job's work is kept.
CodeRabbit Major (#1257) — 'unsupported language' as a bare prefix also matches
'Unsupported language model configuration', handing a model/config failure
engine-switch advice it has no use for. The loose wordings now require the
rejected thing to be a code or to end there.
CodeRabbit Major (#1257) — the OOM test asserted on SOURCE TEXT, which passes
even if the call is unreachable or its result discarded; #1224 taught this same
lesson on this codebase. Both it and the language rewrite now drive the real
_run_backend_inference with a raising backend.
CodeRabbit Minor (#1257) — 'or "engine" in message' always passed, since the
production template contains the word. Asserts the resolved class name now.
CodeRabbit Major (#1252) — the delete-race test deleted the job BEFORE the
merge, which only re-tested the absent case and would pass with the two steps
still split. It now interleaves a real second thread against a slow save.
CodeRabbit Major (#1256) — a hardcoded /tmp literal trips Ruff S108; built from
tmp_path instead.
* fix(review): a withdrawal must survive the job's first write
CodeRabbit Major — the concern is real, though its suggested fix (gate the
checkpoint on the job already existing) would break creation: an ingest's FIRST
persistence is what creates the entry, so that gate would never pass.
The actual defect is that dict membership cannot express 'withdrawn'. An absent
key means 'not written yet' for a new job and 'deleted' for an established one
— two opposite instructions from one signal. So a clear-history arriving before
the first checkpoint was silently undone by that checkpoint recreating the row,
and the run then persisted its result into history the user had just cleared.
Tombstone it explicitly: the ingest declares itself in flight, a purge marks any
in-flight id withdrawn, and both write paths refuse a withdrawn id. Released in
, so it's bounded by concurrent ingests and can't poison a later run
that reuses the id.
That also fixed clear-history properly: a job with no row yet appears in no id
list, so only an in-flight sweep can catch it.
CodeRabbit Minor — my race test waited on an event that could not be set while
the save held the lock, so it burned its full 2s timeout every run and
synchronised nothing. It now waits for the purge thread to REACH the purge.
* test(dub): the race test was not testing the race
Caught by verifying fail-before rather than trusting the test: splitting merge
from save — the exact resurrection bug — passed all 22 tests.
The assertions checked WHAT happened (the save ran, the row was deleted, the
job left memory) but never WHEN. A save landing after the delete is
indistinguishable from one landing before if you only assert that both
occurred — and 'after' is precisely the resurrection.
Now recorded and asserted as an order. With merge+save atomic the purge cannot
start until the save finishes, so the sequence is always save-then-delete;
split them and it fails with ['delete', 'save'].
That is the second time this test needed rewriting: v1 deleted the job before
the merge and only re-checked the absent case, v2 interleaved a real thread but
asserted the wrong thing. Both looked like tests.
Also documents why the DB write sits inside the lock (atomicity beats a rare
5 s sqlite busy-timeout stall) and that no locked region calls another, so the
non-reentrant lock cannot deadlock — verified by walking every locked region.
* fix(dub): gate the withdrawal at save_job, not at its callers
Greptile P1 — and the same class I'd already fixed, unfixed elsewhere. The
withdrawal check sat in the two ingest helpers, but eight direct save_job call
sites across dub generate / translate / export / core bypass those entirely.
Deleting a dub mid-RENDER therefore still resurrected it, which is at least as
likely as deleting mid-import.
Moved the gate into save_job itself: one choke point, every caller inherits it,
and the ninth cannot forget. That needs a re-entrant lock, since the atomic
helpers call save_job while already holding it — a plain Lock would deadlock
the backend, so a test pins the lock type and another exercises the nested path.
Verified fail-before: removing the gate fails the new test.
* fix(dub): the withdrawal only covered ingests, so it covered almost nothing
Caught by testing the reported scenario directly instead of trusting a green
suite: CI passed, 26 tests passed, and a dub deleted during a RENDER was still
resurrected.
The tombstone was scoped to in-flight ingests. But a dub is imported once and
rendered many times, so the realistic delete lands during a render — long after
its ingest ended — and end_ingest was CLEARING the tombstone at exactly that
point. The rare case was protected and the common one left open.
Now scoped to deletions, not ingests. Kept in a bounded LRU rather than cleared
on completion, because there is no moment at which a delete stops mattering:
any operation still holding that job can persist it. Re-importing an id is the
only thing that legitimately revives it.
Verified fail-before: the previous scoping fails three of the new tests.
* refactor: move the dub delete-resurrection fix to its own PR (#1270)
The six fixes left here are independent error-message changes that needed no
corrections. The dub concurrency change needed five rounds, each finding
something real in work that was already reviewed, tested and CI-green — the
last of them being that the fix did not fix the reported case at all.
Riding a release on that record is a bad trade, so it ships separately as
#1270. This branch keeps #1262, #1256, #1251, #1247, #1257 and #1254; the
KeyError message half goes with the dub PR, since it is that issue's other
half.
* fix(macos): the app was dead on arrival on macOS 12 (Monterey) (#1245)
`useRealtimeEvents` polls backend health with `AbortSignal.timeout(2000)` on
mount. That method landed in Safari 16.0, but tauri.conf.json declares
`minimumSystemVersion: "12.0"` and docs/install/macos.md promises macOS 12 —
which ships WKWebView 15.6. So on the floor we advertise, the first React
render threw, the tree unmounted, and the backend was never started: the
reporter's whole session was one `view:launchpad` and "no backend response
this session".
Fixed as a class, not a call site. `utils/webCompat.js` fills the gap once,
imported first in main.jsx before any app chunk; a deterministic test fails CI
if app code reaches for any other post-15.6 API that nothing fills in.
* fix(review): correct the version facts, widen the guard, stop overclaiming
Standing review found three factual errors and one overclaim. Verified each
against caniuse/WebKit before changing anything — two of my labels were wrong
and one of the reviewer's corrections would itself have been wrong to apply
blindly.
- Array#toSorted/toReversed/toSpliced are Safari 16.0, not 16.4 (caniuse).
- String#isWellFormed is 16.4, not 17.0 (WebKit 16.4 release notes).
- abort(reason) shipped in Safari 15.4, so it IS honoured on our 15.6 floor —
my comment claimed the opposite, which invited 'simplifying' the polyfill to
a bare abort() and silently turning every TimeoutError into an AbortError.
- Array#with was missing from the denylist. It is the change-by-copy sibling
most likely to be reached for (arr.with(i, v) in React state), so it was the
most likely next instance of the exact bug this guards. Added, with
Array.fromAsync, String#toWellFormed, Element#checkVisibility and URL.parse.
The overclaim: the changelog said the app 'opens again, instead of a dead
window'. It launches — but Tailwind v4's own floor is Safari 16.4, vite's
default target is 16.4, and index.css uses color-mix() (16.2) 64 times, so
Monterey renders it wrong. A bundled dependency also ships a RegExp lookbehind
literal, which is a parse-time SyntaxError no polyfill can reach. Changelog now
claims only what is true; the floor question is #1268.
The guard's blind spots — syntax, dependencies, CSS, computed access — are now
stated in the test file rather than left implied.
Encodes what CLAUDE.md already requires — root-cause not symptom, whole class
not one instance, fail-before/pass-after tests, cross-platform parity, keep
main green — as a reviewer that attacks a change before it lands.
Deliberately a critic, not an approver: it can judge that work meets the bar,
it cannot authorise publishing, and it is told to say so when the remaining
decision is the owner's. A reviewer that agrees with the author is worth
nothing — two bugs this week were caught only because a reviewer went looking
for a blind spot, including a Linux fix that turned out to be completely inert.
Tracked rather than left in local state so the standards travel with the code.
* feat(mcp): OMNIVOICE_MCP_ALLOWED_HOSTS env var for transport-security allowlist (#1249)
Agents running in Docker containers (or on other machines) connect via a
hostname like host.containers.internal, which the MCP SDK's DNS-rebinding
guard rejects with 421. Add OMNIVOICE_MCP_ALLOWED_HOSTS (comma-separated
host patterns) that extends both allowed_hosts and allowed_origins in
create_mcp_server(). Default empty → no behavior change.
Test: assert the env var extends the allowlist + origins. Docs: mcp.md
notes the env var for Docker/LAN agents.
* fix(changelog): move MCP_ALLOWED_HOSTS entry after Highlights per quiet style
* fix(mcp): add https:// origins for HTTPS reverse proxy clients (greptile P1)
* docs(mcp): add security note for remote agent connections (coderabbit)
* fix(first-run): keep Continue + the HF token box on screen; no status bar yet
Reported with a screenshot: on Models & engines the "Set token" card was
clipped at the window edge and the Continue button was off-screen entirely —
unreachable without resizing.
Root cause is one bug, and it explains both halves of the report. App.jsx sizes
`.app-wizard-wrap` to stop above the fixed LogsFooter, but SetupWizard's own
root was `fixed inset-0` — so it laid itself out against the VIEWPORT, escaped
that box, and put its pinned footer row underneath the status bar. The row was
already correctly pinned (shrink-0, outside the scroller); it was simply
painted over.
- SetupWizard's root is `absolute inset-0`, filling the frame it is given, plus
`pb-4` so the pinned row clears the window edge.
- The wizard and the pre-wizard splash render no LogsFooter at all: it is
studio chrome, and the rule is that it appears once you land on home.
- `.app-wizard-wrap` reserves nothing below it any more, so no dead 28px gap
is left where the footer used to be.
Verified by driving the real frontend in Chromium against a stub backend, not
only in jsdom: before, `.logs-footer` is present and overlaps the action row;
after, it is absent and every button sits within the viewport.
Regression test pins both halves — either one alone reintroduces the clip.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* style(test): oxfmt; changelog refs the wizard PR (#1241), not issue #1240
* test(first-run): pin the bottom clearance and check each pre-studio branch
CodeRabbit (#1241): the root-class test passed with `pb-4` removed, and the
LogsFooter count-of-1 would still pass if the mount MOVED from the studio into
the splash. Both now assert the thing they mean.
* style(test): oxfmt the SetupWizardChrome assertions
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Resolving the repeated [Unreleased] conflicts (every PR appends to the same
block) with a line-level union dropped the blank lines around each ###
header and moved #1237's Highlights bullet into ### Fixed. The style linter
doesn't check blank lines, so it went unnoticed until the next merge — where
the misplaced bullet failed the ref/credit rule and made main red.
No content change: same entries, correct structure.
Every HfHubHTTPError was retried (pre-existing, preserved when the guard was
extracted), so a wrong token or a gated repo burned all five attempts with
backoff before showing the user the same message and postponed the install
cooldown. 401/403/404/410 now fail fast; 429/5xx keep retrying.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
GpuJobTimeoutError was raised inside `except asyncio.TimeoutError` without
`from`, so the original timeout was dropped from the traceback.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The shared-marker test used inspect.getsource(), which would pass while the
literal survived only in a comment and the classifier had stopped using it —
the exact break it exists to catch. Asserts through classify() now.
CHANGELOG: name AppLocker / Software Restriction Policy too (the taxonomy
covers WinError 1260), and don't imply the Smart App Control toggle is the
remedy on a managed PC.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Any HSA_OVERRIDE_GFX_VERSION was treated as proof of compatibility. The #1228
reporter had 11.0.0 set from older advice — if the installed build ships no
gfx1100, honouring that blindly routes them into kernels that don't exist
rather than falling back to CPU. The override is now parsed to its gfx target
and checked against the build, same rule the auto-remap already follows.
Unparseable values are still left alone: the user asked for something we don't
understand, and guessing is worse than trusting them.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
CodeRabbit: the Colab sanity check resolved the tokenizer first and printed
versions only on success, so the one broken path it exists to catch reported
neither the installed versions nor CUDA status — the single most useful line
for diagnosing a Colab environment. Versions now print first.
CHANGELOG: contributor credit on all three entries, refs last.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
CodeRabbit's Major finding was fair and the most useful of the set: three of
my tests asserted that a NAME appeared in a module's source. The installer one
passed merely because the module imports the symbol — it would not have
noticed the retry loop ignoring the classifier entirely. Tests that can't fail
for the reason they exist are worse than no tests.
Replaced with behaviour:
- the installer's retry decision is now a named helper, tested against a REAL
httpx.RemoteProtocolError instance (plus a cancel, a bad repo id, a 401, and
the original type-based cases so widening didn't drop them);
- VoxCPM2's loader is driven through a fake voxcpm module that truncates twice
then succeeds, asserting three calls — and one that fails fast on a real
error.
The stream-path check stays structural (reaching it needs a live WebSocket)
but now also resolves the symbol it names, so a rename on either side fails.
Two P1s fixed as well:
- the closed-client reset incremented the same counter as the download
retries, leaving a resumable multi-GB download one attempt short of its
configured budget. The two budgets are now genuinely independent.
- OMNIVOICE_MODEL_LOAD_BACKOFF_S=inf parsed fine and made sleep(inf) raise
OverflowError, replacing a retryable failure with an unrelated crash that
hid the original error. Non-finite values fall back to the default.
CHANGELOG entries shortened (CodeRabbit).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two P1s, both correct, and both the same shape as the bug being fixed:
- The preflight OSError I added ("Can't save the download: …") matched neither
an errno nor a download marker, so classify() returned "" and the user got
NO hint — the exact dead end this PR exists to remove. Reworded to carry
both signals; a test now asserts the class and that the hint names the data
directory.
- classify() covered ENOENT but _with_target_facts' own signature list did
not, so a job folder that vanished after preflight produced a
disk-classified error that never named the folder.
That second one is a drift class, not a one-off: two lists answering "is this
a disk problem?" will diverge again. They now share
failure.is_os_write_refusal(), with a test asserting both consumers agree
across all four errnos.
CHANGELOG entries shortened with refs last (CodeRabbit).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two P1s, both correct:
- The enriched write failure lost its class. _describe_write_failure rewrites
the message, so the word "libsndfile" no longer appears — classify()
returned "" and the auto bug report and docs deeplink had nothing to name.
Worse, my own end-to-end test allowed "" as a pass, which is exactly why it
went unnoticed. audio_io now emits a stable AUDIO_WRITE_FAILED_MARKER,
failure.py matches that, and the assertion is exact.
- "error opening" was far too broad. It appears whenever a model, archive or
config file fails to open, so any such failure was handed the audio-file
remedy (check your disk, add an antivirus exclusion). Dropped in favour of
the marker; a regression test pins that a corrupt-model-archive error keeps
its own guidance.
A third test pins the marker across the two modules — core/ cannot import
services/, so the string is duplicated by necessity, and a reword on either
side would silently un-classify every enriched write failure.
CHANGELOG entries shortened with refs last (CodeRabbit).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two P1s, both correct:
- Being IN the override map was treated as proof of compatibility. If the
wheel ships neither the native arch nor the remap target, setting
HSA_OVERRIDE_GFX_VERSION only changes WHICH kernel is missing — gfx1151 with
a gfx1030-only build was routed to the GPU and would fail at launch. Both
arch_unsupported() and _configure_rocm_if_needed() now require the target to
be present, and fall back to CPU otherwise.
- An EMPTY arch list means the build's metadata is unavailable, not that the
GPU is unsupported. The remap branch read that unknown state as a confirmed
mismatch and would push a natively-supported gfx1151 onto foreign gfx1100
kernels. Now fails open and changes nothing, matching the fail-open contract
the rest of the probe follows.
ROCM_GFX_OVERRIDES values are now the target gfx NAME rather than the HSA
version string, so the "is the target present?" check is a direct membership
test; hsa_override_for() derives the env-var form, covered by a test that
every entry in the map converts cleanly.
Also: CHANGELOG entries shortened with refs last, and the MD028 blank line
between the two docker.md blockquotes (CodeRabbit).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two P1s, both correct:
- MPS mislabel. HostCaps.vram_gb on MPS is a heuristic (system RAM / 2) for a
UNIFIED memory pool, so an 8 GB Mac reports 4.0 "VRAM" — comparing that to a
floor measured on discrete CUDA hardware would warn every small Mac about an
engine that runs fine there. The caveat is now dedicated-VRAM families only
(cuda/rocm); MPS has a different memory model and no measured floor.
- Engine-agnostic timeout. _timeout_guidance serves EVERY job on the GPU pool
(reference transcribe, stream assemble, watermarking, dub steps, CPU-only
engines on a GPU host), and a hardcoded 6 GB threshold applied without
knowing whose job it is would confidently misdiagnose most of them. The
floor is now passed in via run_on_gpu_pool_guarded, defaulting to 0 — so the
under-provisioned wording is opt-in and only the TTS generate dispatches opt
in. A test asserts every "TTS generate" dispatch passes it, so the branch
can't become unreachable in production.
CHANGELOG entries reworded to end with their refs (CodeRabbit).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Greptile's P1 was correct and load-bearing: uvicorn does not let a bind failure
reach the caller. It catches the OSError inside its own startup, logs the raw
errno, and raises SystemExit(1) — so the `except OSError` around uvicorn.run()
never fired and the whole backend half of this fix was a no-op. Verified
empirically against the installed uvicorn, not assumed.
Replaced with a pre-bind probe using uvicorn's own socket options (SO_REUSEADDR
off Windows, so a TIME_WAIT socket uvicorn could bind is never reported as
taken), plus a SystemExit fallback that re-probes to cover losing the race
between probe and bind.
Two tests now pin this: one drives real uvicorn against a held port and asserts
exit 78 with the actionable message; the other documents uvicorn's swallowing
behaviour so a future refactor back to the "obvious" `except OSError` shape
fails loudly instead of silently restoring "Backend died (exit code 1)".
Also from review:
- Reverted rendering crashCauseHint in the crash dialog (both bots, and
CLAUDE.md's localization rule): it would put hardcoded English into a
localized surface. The port conflict already reaches users through the
LOCALIZED bootstrap.hint_port, which is the correct channel.
- Pinned that every Rust-side port message contains a phrase detectHints
matches, since that is what converts an English Rust message into the
localized hint. This caught a real gap: the respawn message said "is held
by", which the matcher missed — that path would have silently lost the
translated guidance, the same failure mode as #1223 one layer up.
- CHANGELOG entries shortened to the one-line house style (CodeRabbit).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two users on 4 GB cards (GTX 1650 Ti, Quadro P2000) ran the `omnivoice`
engine, waited out the full compute budget, and were told the job "was too
heavy for the available compute … most often the GPU is VRAM-starved".
The 300s-vs-372s spread between the two reports is purely text length
(`300 + (len-1200)/40`, so 372s ⇒ ~4080 chars) — one bug, not two. Nothing
about the budget is device-aware, and nothing needs to be: the real defect is
that until the moment it failed, routing showed a clean green "accelerated".
`resolve_routing` matched on GPU *family* only, so a 4 GB card and a 24 GB
card were indistinguishable, and no engine declared a VRAM requirement
anywhere in the repo.
- `TTSBackend.min_vram_gb` — advisory metadata alongside `gpu_compat`. Only
`omnivoice` declares one (6 GB), derived from the pool's own measured
per-job budget (`_GPU_VRAM_PER_JOB_GB = 5.0`) plus resident weights.
Inventing floors for engines with no measured figure would put confident
numbers in the UI that nothing backs.
- `resolve_routing` takes the floor and emits an accelerated-with-caveat
reason when the host is below it. Reuses the existing caveat channel, so
the Settings matrix and the synth-time routing notice surface it with no UI
change. Advisory, never blocking: drivers page to system RAM, and short
inputs fit where long ones don't. Kernel-risk still outranks it, and a
failed VRAM probe (0.0) never guesses.
- `_timeout_guidance` names the actual card and its VRAM, and leads with
"pick a lighter engine" instead of wording that reads as transient
contention the user can flush their way out of.
Regression test: tests/test_low_vram_advisory.py (8 of 12 fail before),
including that the 300/372 spread really is just text length.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The reporter's captured log tail, just before the backend was SIGKILLed:
httpx.RemoteProtocolError: peer closed connection without sending complete
message body (received 4084175097 bytes, expected 4580080592)
A 4.6 GB model died at 4.0 GB — the single most retry-worthy failure in the
download path, and it was retried nowhere:
- the installer's loop caught (HfHubHTTPError, LocalEntryNotFoundError,
OSError). httpx.RemoteProtocolError inherits Exception, NOT OSError, so it
escaped all five attempts. The loop now decides by CLASSIFICATION rather
than exception type, so the next transport error with a novel type doesn't
reopen the same hole.
- is_hf_connectivity_error — the single source of truth for "transient
download failure" — had no truncation signature, so a widened catch alone
would still have called it permanent. It now knows the httpx wording plus
the urllib3/http.client equivalents (IncompleteRead, "connection broken",
"response ended prematurely").
- the engine load path had no retry at all. VoxCPM2 and MOSS-TTS-Nano now go
through the existing _retry_once_with_fresh_hf_client hook, widened to
retry transient download failures with a bounded, backed-off budget. The
HF cache is resumable (correctly-sized blobs are skipped by hash), so a
retry continues rather than restarting.
The closed-client path (#880) keeps its single-shot budget deliberately: it's
a client-state bug, not a network condition, so a fresh session hitting it
again means repeating won't help. Both budgets are now pinned by tests.
Also logs the low-memory advisory in the STREAMING synth path. /generate has
done this since the earlier 16 GB-Mac reports, but the streaming path — which
the desktop UI tries first — did not, so the load most likely to tip a machine
into an OS OOM kill was the one load leaving no trail in the captured stderr
tail a SIGKILL report has to go on.
Regression test: tests/test_truncated_download_retry.py (9 of 13 fail before).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`ERROR: [Errno 10048] error while attempting to bind on address
('127.0.0.1', 3900)` — port taken, almost certainly by an orphan from a
previous session. The user saw "Backend died (exit code 1)".
Three gaps, each independently enough to lose the diagnosis:
- uvicorn's bind failure propagated as a bare OSError → exit 1. main.py now
catches it on the locale-independent errno (48 macOS/BSD, 98 Linux, 10048
Windows), prints what happened, and exits EX_CONFIG (78).
- `detectHints` matched only /port.*in use|address.*in use/. Windows'
WSAEADDRINUSE wording — "only one usage of each socket address is normally
permitted" — contains neither phrase, AND the OS translates it (this report
was in Russian), so no English phrase can be relied on. It now matches the
errnos and the new exit code. The correct hint string was already in
en.json, simply unreachable on Windows.
- Every caller of `kill_orphan_on_port` killed, slept a fixed interval, and
spawned unconditionally — a holder we cannot kill (another user's process,
taskkill blocked by policy, a TIME_WAIT socket the Windows LISTENING filter
can't even see) was indistinguishable from success. `free_port_or_report`
re-probes and the bootstrap now fails with an explanation instead of
spawning into a port it never reclaimed.
Also renders `crashCauseHint` in the crash details dialog. It already knew a
port conflict isn't a memory problem — but was only wired to the stream-drop
path, so the one screen a user opens for an explanation showed a bare exit
code. It now also knows exit 78.
Regression tests: frontend/src/test/portInUseHint.test.js (the reporter's
Russian log line verbatim; 6 of 10 fail before) and
tests/test_port_in_use_exit.py, which pins the exit code across Python, Rust
and TypeScript so the three can't silently diverge.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`download: Unable to download video: [Errno 22] Invalid argument`, hit three
times in a row on the same URL. Two things made it a dead end.
`classify()` matched the generic "errno 22" rule (#763, written for the ASR
temp-WAV path) before any download rule, so the attached hint told the user to
check their system TEMP folder — while the failing directory is the job folder
under the OmniVoice data dir. The one actionable instruction pointed at the
wrong place. There is now a VIDEO_DOWNLOAD_OS_ERROR class, checked first,
covering the OS-refusal errnos (22/13/28/2) when the message carries download
context; #763's class is untouched for everything else.
The message also named neither the target nor the reason, so nothing in it
distinguished a full drive from a read-only folder from an antivirus lock.
`failure.describe_path_target()` (shared with the #1221 audio-write path)
attaches what we can observe — exists / writable / free space — and the
download site now:
- preflights the job dir and fails immediately when it can already see the
write can't succeed, instead of starting a download that can only fail;
- enriches an OS-refusal failure with the destination facts, leaving
network/format failures untouched so they keep their own (retryable) class.
Regression test: tests/test_dub_download_os_error.py, including that a
transient network failure still classifies as retryable and that the ASR
path keeps OS_INVALID_ARGUMENT. Its yt-dlp calls are blocked outright — the
first draft passed standalone and silently made a real network call under
full-suite ordering.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`_oom_friendly_reraise` classifies every known way a generate can die; two real
reports fell through to its "an error OmniVoice doesn't recognize" catch-all.
#1227 — `OSError: [WinError 4551] An Application Control policy has blocked
this file`. Windows Smart App Control refused to load a file the engine needs.
Now named, with the setting to change (and the caveat that Windows only lets
you turn Smart App Control off once). WinError 1260 — the same class from
AppLocker / Software Restriction Policies — is matched too, on the numeric
codes, since Windows translates the message text.
#1221 — `LibsndfileError: System error.`, libsndfile's bare wording for an
OS-level audio read/write failure: no path, no errno, no next step. Two parts:
- `audio_io._safe_torchaudio_save` now re-raises write failures naming the
target, whether its folder exists and is writable, and the drive's free
space — the facts that identify a full disk, a removed drive, or an
antivirus/OneDrive lock. (It cannot re-use the original type:
`LibsndfileError.__init__` takes an integer libsndfile code, so
`type(e)(message)` builds an exception whose `str()` raises.)
- `_oom_friendly_reraise` covers every other libsndfile surface (reading a
reference clip, a decode) with the same causes.
Both get a `core.failure` class + hint (WINDOWS_APP_CONTROL_BLOCKED,
AUDIO_IO_FAILED) so the auto bug report and docs deeplink name them.
Regression test: tests/test_synth_error_classes.py — 11 of 12 fail before.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`backend/api/routers/profiles.py` imports two pure-stdlib regex helpers from
`omnivoice.utils.voice_design`. That import pulled in `omnivoice/__init__`,
which eagerly imported `omnivoice.models.omnivoice` — torch, torchaudio,
transformers, flex_attention, the whole model definition — including a
top-level `from transformers import HiggsAudioV2TokenizerModel`.
transformers exposes that class through its lazy module and gates it on the
torchaudio backend, so the *attribute access* raises when torchaudio is
missing, ABI-mismatched, or installed without discoverable distribution
metadata — Colab's system Python, an interrupted `uv pip install`. It raised
during `backend/main.py`'s module import, before FastAPI existed: TTS,
dubbing, ASR and Settings all dead, the user left with a uvicorn traceback and
"Backend did not become healthy within 5 minutes".
Two changes, both structural rather than Colab-specific:
- `omnivoice/__init__` resolves its model exports lazily (PEP 562). Importing
`omnivoice.utils.*` no longer costs — or risks — the model stack.
`from omnivoice import OmniVoice` is unchanged; only the timing moves.
`backend.spec` already lists `omnivoice.models.omnivoice` as a hidden
import, so the frozen build is unaffected.
- `HiggsAudioV2TokenizerModel` resolves at its single use site in
`from_pretrained`, and a failure there raises an ImportError naming
torchaudio and the reinstall. Deferred into a request, `core.failure
.classify()` maps it to TRANSFORMERS_IMPORT and attaches a repair hint —
whose text now names torchaudio too, instead of only transformers + an ASR
workaround irrelevant to this path.
Colab notebook: cell 2's sanity check imports the model stack (and prints
torchaudio/transformers versions), so a broken env fails there with the real
error instead of as a health timeout two cells later.
Regression test: tests/test_omnivoice_lazy_model_import.py pins that the utils
import loads no heavy module, the lazy exports still resolve, and the deferred
failure is actionable and classified.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The GPU compatibility gate built a CUDA-namespace tag from
`get_device_capability()` (`sm_115` on a gfx1151 Strix Halo) and looked for it
in `torch.cuda.get_arch_list()` — which on a ROCm wheel returns gfx *names*
(`gfx1100`, `gfx1151`, …). The two namespaces can never intersect, so
`check_device_compatibility()` returned False on every ROCm build and
`get_best_device()` silently returned "cpu": torch saw the GPU,
`torch.cuda.is_available()` was True, and the app ran on the CPU anyway.
The comparison was copy-pasted in three places, all with the same bug, so it
now lives once in `core.device_caps.arch_unsupported()` and branches on the
build (gfx names on ROCm, sm_/compute_ tags on CUDA):
- `model_manager.check_device_compatibility` — the CPU force-route, plus a
ROCm-specific remedy instead of telling AMD users to install a cu128 wheel
- `device_caps._probe` — the kernel-risk note that downgraded the routing badge
- `engine_env._cuda_arch_supported_for_compile` — torch.compile off on all AMD
`_configure_rocm_if_needed` also applied `HSA_OVERRIDE_GFX_VERSION` from a
static map without checking whether the GPU needed it, remapping cards the
installed build supports natively onto foreign kernels. It now applies only
when the native GFX ID is genuinely absent from the arch list, and knows
gfx1150/gfx1151 (Strix Point/Halo).
Regression test: tests/test_rocm_arch_gate.py pins the reporter's host
resolving to "cuda", a genuine ROCm mismatch still being caught, the CUDA
path (#756 Blackwell fallback) unchanged, and the narrowed override.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace the text-heavy 8-card grid with real app imagery: the three flagship
features (Voice Cloning, Voice Design, Video Dubbing) now lead with tight
cropped screenshots of their actual UI (docs/features/*.png, uniform 2.8:1
tiles) over a one-line label; the five conceptual/no-screenshot features
(Audiobook, Stories, Dictation, 100% Local, MCP) compact to emoji + a single
line. Less text, more app. The "…and 12 more" fold is unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- vs Others: rename "Why OmniVoice?" heading + nav label
- Quickstart: collapse to one install line + a single troubleshooting fold
- System Requirements: fold three admonitions into one compact note
- Architecture: expand — Tauri/Rust shell, sidecar+IPC, data layer, engine
row (14 TTS / 11 ASR / Demucs / Pyannote / AudioSeal) + a layer legend
- OpenAI API: add a diff-block showing the one-line base_url swap
- Agent Skills: credit Claude Code, Codex, Cursor, Grok, Kimi, opencode
- Colab + Sponsor/Donate: trimmed to essentials
docs-drift green; code fences balanced.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Lead with the productive path instead of a generic curl: a discover→synthesize
flow that uses your own cloned voice (the real differentiator over the cloud),
grounded in the actual router — `voice` resolves profile IDs, `model` can pin a
specific engine per request, `/v1/audio/voices` returns `{voices[],engines[]}`.
Tighter intro, accurate format list (adds aac), verbose_json word-timing note,
and a current (non-deprecated) OpenAI-SDK streaming-response TTS example.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Keep the ranking (avatars still ordered by PR count) but not the explanatory
sentence — just "## Contributors" + "Thank you all 💜" + avatars. Live v0.4.0
release updated to match.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Stable v* releases previously credited nobody: the Contributors avatar strip
was wired only for the preview channel (preview-notes job), so the release
page showed just GitHub's native widget — which lists only the externally
@-thanked PR authors, never the owner, and can't be ordered.
Add a `contributors-strip` job that appends one "## Contributors" strip to the
stable release, crediting every PR author for the tag including the owner,
ranked by merged-PR count (desc, ties by handle). It suppresses GitHub's
duplicate native widget by neutralising the inline "— thanks @user!" text
mentions in the RELEASE body only (the repo CHANGELOG keeps the @handles); the
strip's own @handles sit in HTML attributes, which GitHub does not count as
mentions, so avatars stay linked.
Appends via `gh release edit` on the existing release (never a second softprops
publish — that splits installers across two releases), runs once (no matrix
race), and is idempotent (strips any prior block + inline @thanks before
re-appending). Docs: RELEASING.md §5b release-body row updated.
The live v0.4.0 release has been corrected to match (single ranked strip,
native widget gone).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Surface what shipped in v0.4.0 and sharpen the productivity story:
- Audiobook: multi-voice cast, expressive controls, live per-chapter
progress + Stop, one-click sample (feature card + receipts row)
- Dubbing: Paste Translation ("translate or paste your own") on the card
- Gallery: its voices are now selectable in every picker — Studio,
Audiobook, Stories, Dubbing (screenshot caption + Voice receipts row)
Also fix an internal count inconsistency: the ASR registry has 11 engines
(10 on-device + the OpenAI-compatible remote client, as the ASR section
already states), but the Why-OVS table and the receipts row still said 10.
Both now read 11. docs-drift green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
GitHub natively recognizes CONTRIBUTING.md, SECURITY.md, SUPPORT.md, and
CODE_OF_CONDUCT.md in .github/ (Contributing link, Security policy tab, and
the community profile all keep resolving), so relocate the four there and
drop four files from the repo root.
Reference fixes in the same commit (no broken links):
- README.md / README_CN.md → .github/CONTRIBUTING.md
- docs/migration/real-time-voice-cloning.md → ../../.github/SUPPORT.md
- SUPPORT→SECURITY link unchanged (both now in .github/, same dir)
- tests/test_issue_fixes.py Discord-link check repointed to .github/
CONTRIBUTING.md (a missing path would silently skip, dropping coverage)
- CLAUDE.md docs-sync rule paths updated to match
Kept at root by design: README/LICENSE/CHANGELOG/CLAUDE/AGENTS (required or
convention), SPONSORS.md (wired to absolute GitHub URLs in FUNDING.yml, the
sponsor issue template, and sponsors.js), LICENSE-NOTICE.md (pairs with
LICENSE), README_CN.md (README translation, 29 relative links).
Also ignore the local memxt agent-memory DB (memxt.db*) so it stops sitting
loose in the working tree.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Update the exact-version / minor / ROCm pin examples in the Docker Hub
overview (deploy/dockerhub-overview.md — source of the hub.docker.com page,
re-synced on this main push) and docs/install/docker.md to the v0.4.0
release. GHCR's package page inherits the repo README and the current
org.opencontainers.image.description label, both already accurate.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 12:29:20 +05:30
1274 changed files with 181471 additions and 17561 deletions
- Use ordinary Python control flow; prefer readability over compressed one-line conditionals
## Error Handling
- Handle edge cases at function entry points
- Employ early returns for error conditions
- Place happy path logic last
- Avoid unnecessary else statements; use if-return patterns
- Implement guard clauses for preconditions
- Provide proper error logging and user-friendly messaging
## FastAPI-Specific Guidelines
- Use functional components (plain functions) and Pydantic models for input validation
- Declare routes with clear return type annotations
- Prefer lifespan context managers for managing startup and shutdown events
- Leverage middleware for logging, error monitoring, and optimization
- Use HTTPException for expected errors and model them as specific HTTP responses
- Apply Pydantic's BaseModel consistently for validation
## Performance Optimization
- Minimize blocking I/O. In `async def` handlers, use awaitable database/API clients; put synchronous SQLite or other blocking work in synchronous routes or explicitly offload it
- Implement caching with Redis or in-memory stores
- Optimize Pydantic serialization/deserialization
- Use lazy loading for large datasets
## Key Conventions
1. Rely on FastAPI's dependency injection system
2. Prioritize API performance metrics (response time, latency, throughput)
3. Structure routes and dependencies for readability and maintainability
description:Reviews proposed changes to VoiceStudio against the owner's documented standards. Use before merging any PR, before tagging a release, and whenever another agent reports work as finished. Returns a verdict with blocking findings — it judges work, it does not authorise publishing.
model:opus
tools:Bash, Read, Grep, Glob, WebFetch
---
# The owner's standing review
You review changes to **VoiceStudio** the way its owner would. You are a
**critic**, not an approver.
## What you are, precisely
You carry the owner's documented standards and apply them without flinching.
You are not the owner, and you cannot consent on their behalf. Two things
follow, and they matter:
- **You never authorise an irreversible or outward-facing action.** Publishing a
release, posting to users, deleting data, pushing to `main` — you can say
"this meets the bar" but you cannot say "go ahead". A judgement that a change
is *sound* is not permission to *ship* it. If asked to approve one of those,
say so plainly and give your technical verdict instead.
- **Your job is to find what's wrong.** A review that returns "looks good" has
usually not been done. Assume the author — human or agent — has a blind spot,
and go looking for it. Reviews that agreed with the author have already cost
this project real bugs: a fix for the Linux blank window shipped that was
**completely inert**, and a dub-pipeline fix left a resurrection race, both
caught only because a reviewer attacked them instead of agreeing.
Be fair, not hostile. A finding you cannot substantiate is noise, and noise
trains people to ignore you. Every finding needs a concrete failure: specific
input or state, and the wrong result it produces.
## The standards (from CLAUDE.md — these are load-bearing)
**Core value: a first-run that actually works.** A user who downloads the
installer should reach a working output without hitting a wall, and when
something breaks, the error or docs should say exactly what to do. Weigh
findings against this. An unactionable error message reaching a user is a real
defect here, not a nitpick.
**Fix quality.** Root-cause fully; fix the whole *class*, not the reported
instance; add a regression test that genuinely fails before and passes after;
harden against recurrence. Ask of every fix:
- Does it address the cause, or the symptom?
- Are there other instances of this same bug in the codebase, unfixed?
- Would the test actually fail without the fix? Source-text assertions
(`assert "foo(" in inspect.getsource(...)`) usually would not — they pass
when the call is unreachable or its result discarded. This project has been
bitten by exactly that.
- Is the test tautological? An assertion that holds for reasons unrelated to
the fix proves nothing.
**Cross-platform parity (strict).** A feature shipping in default mode must
behave identically on macOS, Windows, and Linux. Platform-specific
*implementation* is fine; divergent user-visible *default behaviour* is a P0 —
fix it on the missing platform or move it behind explicit opt-in. There is no
third option. Check: does this change assume a POSIX path, a shell, a
case-sensitive filesystem, an evergreen browser engine, or a GPU that some
supported platform lacks?
**Compatibility.** Existing engines must not need reinstalling. Existing
`omnivoice_data/` must keep working with no manual migration; schema changes go
through alembic with a tested upgrade path.
**Local-first.** Nothing leaves the machine without an explicit yes, and the app
stays fully functional with everything declined. No third-party endpoints for
bug reporting or crash dumps. No PAT/token-based GitHub posting from the app.
The single sanctioned external endpoint is the opt-in, consent-gated PostHog EU
analytics, which must never grow exception or DOM autocapture.
**Keep main green.** A merge must never break CI. Dependency, lockfile, and
config changes must be validated against *every* consumer — `frontend/` is a bun
workspace monorepo whose lockfile is the repo-root `bun.lock`, and
`deploy/Dockerfile` runs `bun install --frozen-lockfile`, so a `package.json`
change without a regenerated root lockfile is CI-green and Docker-red.
**Versioning.**`frontend/package.json` is the single source of truth. Three
mirrors stay in lockstep: `frontend/src-tauri/Cargo.toml`, `pyproject.toml`, and
`_FALLBACK_VERSION` in `backend/core/version.py`. Never hand-edit a mirror or
re-hardcode a literal in `tauri.conf.json`. `Cargo.lock` must match the manifest
or `cargo build --locked` fails.
**Docs-sync.** A change that alters what README, `.github/*`, or `docs/**`
describe must update those docs in the *same* change. Stale docs are bugs.
**Changelog.** Quiet and scannable: a short `**Highlights**` list in plain
description:"Local TTS, voice cloning, voice design, and video dubbing via the OmniVoiceStudio MCP server (open-source ElevenLabs alternative; nothing leaves the machine, runs on MPS/CUDA/CPU). Use when: (1) generating speech from text in any of 646 languages, (2) cloning a voice from a 3-second reference clip, (3) designing a voice by gender/age/accent/pitch/style, (4) dubbing a video into another language, (5) listing voice profiles or personality presets, (6) producing narration where privacy, cost, or absent API keys matter, (7) non-English narration where Edge TTS/kokoro fall short, (8) batch audio for blog posts or content pipelines. Triggers: 'omnivoice', 'voice clone', 'clone this voice', 'tts', 'narrate', 'generate speech', 'voice synthesis', 'dub video', 'voice design', 'local tts', 'multilingual voice', 'narrate this post', 'elevenlabs alternative'."
description:"Local TTS, voice cloning, voice design, and video dubbing via the VoiceStudio MCP server (open-source ElevenLabs alternative; nothing leaves the machine, runs on MPS/CUDA/CPU). Use when: (1) generating speech from text in any of 646 languages, (2) cloning a voice from a 3-second reference clip, (3) designing a voice by gender/age/accent/pitch/style, (4) dubbing a video into another language, (5) listing voice profiles or personality presets, (6) producing narration where privacy, cost, or absent API keys matter, (7) non-English narration where Edge TTS/kokoro fall short, (8) batch audio for blog posts or content pipelines. Triggers: 'omnivoice', 'voice clone', 'clone this voice', 'tts', 'narrate', 'generate speech', 'voice synthesis', 'dub video', 'voice design', 'local tts', 'multilingual voice', 'narrate this post', 'elevenlabs alternative'."
---
# OmniVoice
# VoiceStudio
The canonical cross-agent package lives at `skills/omnivoice/SKILL.md`. This
Claude-specific package retains the MCP lifecycle helpers and references.
## Overview
Generate audio locally via the OmniVoiceStudio MCP server. Tools: `generate_speech`, `list_voices`, `list_personalities`, `list_languages`, `check_health`. Resources: `voice://{id}`, `history://recent`.
Generate audio locally via the VoiceStudio MCP server. Tools: `generate_speech`, `list_voices`, `list_personalities`, `list_languages`, `check_health`. Resources: `voice://{id}`, `history://recent`.
## Prerequisites — Backend Must Be Running
The MCP tools all hit `$OMNIVOICE_API_URL` (default `http://localhost:3900`). If the backend is down, every tool returns a connection error. Install + boot:
@@ -41,7 +44,7 @@ First synthesis call lazy-downloads the `k2-fsa/OmniVoice` model (~2.4 GB) from
| List personality presets | `list_personalities` | Returns narrator / casual / news-anchor / etc. with their `instruct` strings |
| List supported languages | `list_languages` | 646 total; returns 20 popular + the full count |
For non-trivial decisions (which engine to use, when to pick OmniVoice over kokoro / Edge TTS / ElevenLabs), see [references/engines-comparison.md](references/engines-comparison.md).
For non-trivial decisions (which engine to use, when to pick VoiceStudio over kokoro / Edge TTS / ElevenLabs), see [references/engines-comparison.md](references/engines-comparison.md).
For MCP wiring details, backend lifecycle, troubleshooting, and a clean teardown, see [references/mcp-setup.md](references/mcp-setup.md).
@@ -52,7 +55,7 @@ For MCP wiring details, backend lifecycle, troubleshooting, and a clean teardown
```python
# As called through the MCP client (your agent will do this for you):
result=generate_speech(
text="Hello — this is OmniVoice generating speech locally.",
text="Hello — this is VoiceStudio generating speech locally.",
@@ -148,16 +151,16 @@ Get pre-made instructs via `list_personalities` and copy the one matching the br
The MCP server does not expose the dubbing endpoint. The full transcribe → translate → re-voice → mux pipeline lives behind the desktop UI (`bun run desktop` in `$OMNIVOICE_HOME`) and the `/dub/*` REST routes. When the user asks to dub a video, point them to the UI; surface this skill only for the synthesis primitives above.
## When NOT to use OmniVoice
## When NOT to use VoiceStudio
- **Fast English-only narration on weak hardware** → `kokoro-tts` is ~10× smaller and 2× realtime on CPU (see [references/engines-comparison.md](references/engines-comparison.md))
- **Lowest-friction one-off TTS** → Edge TTS needs no install or backend
- **Highest possible quality regardless of cost** → ElevenLabs still wins on English narration polish; OmniVoice ties or wins on multilingual + cloning
- **Real-time streaming dictation** → use the OmniVoice desktop widget (`⌘+⇧+Space`), not the MCP server
- **Highest possible quality regardless of cost** → ElevenLabs still wins on English narration polish; VoiceStudio ties or wins on multilingual + cloning
- **Real-time streaming dictation** → use the VoiceStudio desktop widget (`⌘+⇧+Space`), not the MCP server
## Resources
- [references/engines-comparison.md](references/engines-comparison.md) — Decision tree across OmniVoice / kokoro / Voicebox / Edge TTS / ElevenLabs / cloud APIs
- [references/engines-comparison.md](references/engines-comparison.md) — Decision tree across VoiceStudio / kokoro / Voicebox / Edge TTS / ElevenLabs / cloud APIs
- [scripts/start-backend.sh](scripts/start-backend.sh) — Start uvicorn on 127.0.0.1:3900 with health probe
@@ -166,4 +169,4 @@ The MCP server does not expose the dubbing endpoint. The full transcribe → tra
Backend Swagger / OpenAPI: `http://127.0.0.1:3900/docs` (when backend is up).
Upstream: github.com/debpalash/OmniVoice-Studio — FSL-1.1-ALv2 (free for personal/internal/non-commercial; auto-converts to Apache-2.0 two years after each release).
Upstream: github.com/debpalash/VoiceStudio. The app uses AGPL-3.0-only; optional engines and downloaded models retain their own licenses. See `LICENSE-NOTICE.md` in the repository.
3.**Privacy / regulatory** — Nothing leaves the machine. ElevenLabs and OpenAI ship audio to their servers.
4.**No-API-key constraint** — Local-first. No accounts.
5.**Bulk generation without metered cost** — ElevenLabs bills per character. OmniVoice is free at any volume.
5.**Bulk generation without metered cost** — ElevenLabs bills per character. VoiceStudio is free at any volume.
## When OmniVoice loses
## When VoiceStudio loses
1.**Lowest-friction one-off TTS** — Backend install + ~3 GB model + uvicorn boot. Edge TTS or OpenAI TTS is one command.
2.**Fast English narration on weak hardware** — kokoro-tts is ~30 MB vs OmniVoice's 2.4 GB and runs 2× realtime on CPU. Use kokoro for blog-narration batch jobs unless you need cloning.
3.**Streaming real-time TTS** — OmniVoice is diffusion-based and not streaming. Use Edge TTS or cloud APIs for true streaming.
2.**Fast English narration on weak hardware** — kokoro-tts is ~30 MB vs VoiceStudio's 2.4 GB and runs 2× realtime on CPU. Use kokoro for blog-narration batch jobs unless you need cloning.
3.**Streaming real-time TTS** — VoiceStudio is diffusion-based and not streaming. Use Edge TTS or cloud APIs for true streaming.
4.**Apple Silicon-only specialized voices** — `mlx-audio` ships 14 engines (Kokoro, CSM, Dia, Qwen3-TTS, etc.) that may match a specific voice better.
## Composition with content pipelines
OmniVoice fits between visual asset generation and video assembly:
VoiceStudio fits between visual asset generation and video assembly:
```
research → narrative → visual assets → AUDIO (OmniVoice) → video assembly → distribution
research → narrative → visual assets → AUDIO (VoiceStudio) → video assembly → distribution
```
Default for blog-post audio narration:
- **English, no cloning needed, fast** → kokoro-tts (cheap CPU)
- **English, want a specific cloned voice** → OmniVoice with a saved profile
- **Non-English** → OmniVoice
- **English, want a specific cloned voice** → VoiceStudio with a saved profile
- **Non-English** → VoiceStudio
- **One-time, no install** → Edge TTS
For Remotion-based video pipelines that previously required ElevenLabs, OmniVoice closes the last cloud dependency — pair it with any local image/video generator for a fully self-hosted multimedia stack.
For Remotion-based video pipelines that previously required ElevenLabs, VoiceStudio closes the last cloud dependency — pair it with any local image/video generator for a fully self-hosted multimedia stack.
VIRTUAL_ENV="$(pwd)/.venv" uv pip install 'mcp[cli]'# SDK not in their lockfile yet
@@ -37,7 +37,7 @@ Drop into your MCP client config (Claude Desktop, Claude Code at `~/.claude.json
Restart the MCP client. The server only starts at client launch — in-session edits do not hot-reload.
> **Note (mcp SDK ≥ 1.10):** If you see `TypeError: FastMCP.__init__() got an unexpected keyword argument 'version'`, your `OmniVoice-Studio` checkout is older than [debpalash/OmniVoice-Studio#112](https://github.com/debpalash/OmniVoice-Studio/pull/112). Either `git pull` once that PR lands, or apply the 3-line patch manually: replace `version="…", description=(…)` with `instructions=(…)` in `backend/mcp_server.py`.
> **Note (mcp SDK ≥ 1.10):** If you see `TypeError: FastMCP.__init__() got an unexpected keyword argument 'version'`, your `VoiceStudio` checkout is older than [debpalash/VoiceStudio#112](https://github.com/debpalash/VoiceStudio/pull/112). Either `git pull` once that PR lands, or apply the 3-line patch manually: replace `version="…", description=(…)` with `instructions=(…)` in `backend/mcp_server.py`.
## Backend Lifecycle
@@ -61,7 +61,7 @@ First boot runs alembic migrations on the SQLite settings DB at `<data_dir>/omni
First synthesis call lazy-downloads the `k2-fsa/OmniVoice` model (~2.4 GB) into the HuggingFace cache. Path varies by OS:
- **macOS / Linux**: `~/.cache/huggingface/hub/`
- **Windows**: `%LOCALAPPDATA%\OmniVoice\hf_cache` (OmniVoice redirects via `backend/core/config.py` to keep the cache off the system drive root)
- **Windows**: `%LOCALAPPDATA%\OmniVoice\hf_cache` (VoiceStudio redirects via `backend/core/config.py` to keep the cache off the system drive root)
Cached on subsequent boots.
@@ -73,7 +73,7 @@ Cached on subsequent boots.
| Var | Default | Purpose |
|---|---|---|
| `OMNIVOICE_HOME` | `~/OmniVoice-Studio` | Where the OmniVoiceStudio repo is cloned (used by scripts in this skill) |
| `OMNIVOICE_HOME` | `~/VoiceStudio` | Where the VoiceStudio repo is cloned (used by scripts in this skill) |
# Remove the `omnivoice` entry from your MCP client config
```
User profiles + history live in the platform data dir (`~/Library/Application Support/OmniVoice/` on macOS; `~/.local/share/OmniVoice/` on Linux). Preserve across reinstalls if you want to keep your saved voice profiles.
User profiles + history live in the platform data dir (`~/Library/Application Support/OmniVoice/` on macOS; `~/.local/share/VoiceStudio/` on Linux). Preserve across reinstalls if you want to keep your saved voice profiles.
@@ -62,6 +85,18 @@ names: there is no `desktop=prod` (note the **hyphen** in `desktop-prod`).
Requires [Rust](https://rustup.rs/) and platform-specific Tauri dependencies — see the [Tauri prerequisites](https://v2.tauri.app/start/prerequisites/).
After installing Rust with rustup on macOS/Linux, either open a new terminal or
load Cargo into the current one before starting the desktop app:
```bash
source "$HOME/.cargo/env"
bun desktop
```
On Linux, errors such as `Package gdk-3.0 was not found`, `pango.pc` missing,
or `javascriptcoregtk-4.1` missing mean the native packages above were not
installed; changing `PKG_CONFIG_PATH` does not fix libraries that are absent.
If the app opens but stays on the **setup splash with no buttons**, the Python
backend didn't finish starting — the splash surfaces the stall reason, a log
panel, and a **Retry** button (and Settings → Logs → Backend has the full trace).
@@ -72,7 +107,7 @@ The most common from-source cause is `uv` or Python not being on your PATH.
## Project Structure
```
OmniVoice-Studio/
VoiceStudio/
├── backend/ # Python FastAPI server
│ ├── api/ # Route handlers
│ ├── core/ # Config, prefs, constants
@@ -96,7 +131,7 @@ OmniVoice-Studio/
### Bug Reports
Open an [issue](https://github.com/debpalash/OmniVoice-Studio/issues/new) with:
Open an [issue](https://github.com/debpalash/VoiceStudio/issues/new) with:
1. **What happened** vs **what you expected**
2. **Steps to reproduce**
@@ -120,7 +155,7 @@ Open an [issue](https://github.com/debpalash/OmniVoice-Studio/issues/new) with:
### Adding a New TTS Engine
OmniVoice's TTS backend is a plugin registry. Adding a new engine takes ~50 lines:
VoiceStudio's TTS backend is a plugin registry. Adding a new engine takes ~50 lines:
1. Open `backend/services/tts_backend.py`
2. Create a class extending `TTSBackend`:
@@ -168,7 +203,8 @@ class MyEngineBackend(TTSBackend):
- **Components**: Functional components with hooks
- **State**: Zustand stores in `src/stores/`, organized by slice
- **CSS**: **Utilities-first + shadcn/ui, one stylesheet.** UI is built on the shadcn/ui primitives in `src/components/ui/` (wrapped by the `src/ui/` barrel, themed to the OmniVoice palette), composed with Tailwind v4 utility classes. **All styling now lives in a single file — `src/index.css`**: the `@theme` / `[data-theme]` token foundation plus the irreducible set utilities can't express (`@keyframes`, glassmorphism/`backdrop-filter`, pseudo-elements, `:has()`, unlayered cascade overrides, and styling hooks on library-generated DOM like virtualized rows / WaveSurfer). The per-component `.css` files were eliminated in the CSS→Tailwind/shadcn migration — **do not create new ones.** Reach for shadcn primitives + utilities; if a rule is genuinely irreducible, add it to `src/index.css` with a provenance comment. (The only other `.css` is the test-only visual harness. See `docs/shadcn-migration.md`.)
- **Brand assets**: Reuse the canonical mark, palette, naming, and compatibility rules in [`docs/branding.md`](../docs/branding.md); do not redraw or rename runtime identifiers ad hoc
- **CSS**: **Utilities-first + shadcn/ui, one stylesheet.** UI is built on the shadcn/ui primitives in `src/components/ui/` (wrapped by the `src/ui/` barrel, themed to the VoiceStudio palette), composed with Tailwind v4 utility classes. **All styling now lives in a single file — `src/index.css`**: the `@theme` / `[data-theme]` token foundation plus the irreducible set utilities can't express (`@keyframes`, glassmorphism/`backdrop-filter`, pseudo-elements, `:has()`, unlayered cascade overrides, and styling hooks on library-generated DOM like virtualized rows / WaveSurfer). The per-component `.css` files were eliminated in the CSS→Tailwind/shadcn migration — **do not create new ones.** Reach for shadcn primitives + utilities; if a rule is genuinely irreducible, add it to `src/index.css` with a provenance comment. (The only other `.css` is the test-only visual harness. See `docs/shadcn-migration.md`.)
- **Naming**: `PascalCase` for components, `camelCase` for hooks and utils
### Rust (Tauri)
@@ -297,7 +333,7 @@ hard rules from the first prompt.
## Contribution licensing
OmniVoiceStudio is **AGPL-3.0-only**, and the maintainer also offers a
VoiceStudio is **AGPL-3.0-only**, and the maintainer also offers a
**commercial license** (see [LICENSE](LICENSE)). By submitting a contribution
you agree that:
@@ -317,7 +353,7 @@ appreciated but not required.
## Need Help?
- **Stuck on setup?** Ask in [Discord #help](https://discord.gg/bzQavDfVV9)
- **Not sure where to start?** Check [good first issues](https://github.com/debpalash/OmniVoice-Studio/labels/good%20first%20issue)
- **Want to discuss a big change?** Open a [discussion](https://github.com/debpalash/OmniVoice-Studio/discussions) or Discord thread before coding
- **Not sure where to start?** Check [good first issues](https://github.com/debpalash/VoiceStudio/labels/good%20first%20issue)
- **Want to discuss a big change?** Open a [discussion](https://github.com/debpalash/VoiceStudio/discussions) or Discord thread before coding
- label:I searched [existing issues](https://github.com/debpalash/OmniVoice-Studio/issues?q=is%3Aissue) and [discussions](https://github.com/debpalash/OmniVoice-Studio/discussions) for this idea.
- label:I searched [existing issues](https://github.com/debpalash/VoiceStudio/issues?q=is%3Aissue) and [discussions](https://github.com/debpalash/VoiceStudio/discussions) for this idea.
required:true
- type:textarea
id:problem
@@ -45,6 +45,6 @@ body:
- type:markdown
attributes:
value:|
> OmniVoice is **local-first** — features must work fully offline with no accounts,
> VoiceStudio is **local-first** — core features work offline without an account,
API keys, or cloud calls, and behave identically on macOS/Windows/Linux. Proposals
description:Support OmniVoice and (optionally) claim a logo slot. Not for bugs or feature requests.
description:Support VoiceStudio and (optionally) claim a logo slot. Not for bugs or feature requests.
title:"Sponsorship inquiry: "
labels:["sponsor"]
body:
- type:markdown
attributes:
value:|
Thanks for considering sponsoring **OmniVoiceStudio** 💛
Thanks for considering sponsoring **VoiceStudio** 💛
OmniVoice is free, local-first, and AGPL-3.0 — sponsorship keeps development going.
See **[SPONSORS.md](https://github.com/debpalash/OmniVoice-Studio/blob/main/SPONSORS.md)** for tiers, placements, and logo guidelines.
VoiceStudio is free, local-first, and AGPL-3.0 — sponsorship keeps development going.
See **[SPONSORS.md](https://github.com/debpalash/VoiceStudio/blob/main/SPONSORS.md)** for tiers, placements, and logo guidelines.
Prefer to just donate? [Ko-fi](https://ko-fi.com/debpalash) (recurring) or [PayPal](https://paypal.me/palashCoder) (one-time) — you don't need this form for that.
- type:input
id:name
@@ -72,7 +72,7 @@ body:
attributes:
label:Acknowledgements
options:
- label:I understand sponsorship is a thank-you, not a paywall — OmniVoice stays fully free and AGPL-3.0, and sponsors don't get gated features.
- label:I understand sponsorship is a thank-you, not a paywall — VoiceStudio stays fully free and AGPL-3.0, and sponsors don't get gated features.
required:true
- label:If I provide a logo, I have the right to use it and grant OmniVoice permission to display it in the README, the app, and the project website.
- label:If I provide a logo, I have the right to use it and grant VoiceStudio permission to display it in the README, the app, and the project website.
OmniVoice supports models from **public, verifiable sources only** (Hugging
VoiceStudio supports models from **public, verifiable sources only** (Hugging
Face repos, official project releases). Privately sold or gated model files
are not supported: an archive from a private source can carry anything
(bundled executables, modified configs), and nobody else can verify or
@@ -23,7 +23,7 @@ download, and never run executables bundled with model archives.
Instead, report them privately via one of these channels:
1. **GitHub Security Advisories** (preferred) — [Report a vulnerability](https://github.com/debpalash/OmniVoice-Studio/security/advisories/new)
1. **GitHub Security Advisories** (preferred) — [Report a vulnerability](https://github.com/debpalash/VoiceStudio/security/advisories/new)
2. **Email** — Send details to **security@palash.dev**
### What to include
@@ -44,7 +44,7 @@ Instead, report them privately via one of these channels:
### Scope
OmniVoiceStudio runs **100% locally** by default. The primary attack surface is:
VoiceStudio runs **100% locally** by default. The primary attack surface is:
- **Network exposure** — if the user binds to `0.0.0.0` without a reverse proxy
- **Model downloads** — fetched from Hugging Face Hub over HTTPS
@@ -71,6 +71,6 @@ GitHub Apps on creation.
## Security Best Practices for Users
- **Do not expose OmniVoice to the internet without authentication.** The API has no built-in auth. Use a reverse proxy (Caddy, nginx, Tailscale) if you need remote access.
- **Do not expose VoiceStudio to the internet without authentication.** The API has no built-in auth. Use a reverse proxy (Caddy, nginx, Tailscale) if you need remote access.
- **Keep your installation updated.** The desktop app auto-checks for updates via the built-in updater.
- **Review model sources.** Only download models from trusted Hugging Face repositories.
| [GitHub Issues](https://github.com/debpalash/OmniVoice-Studio/issues) | Bugs and feature requests — use the templates; attach the diagnostic bundle (Settings → About → "Save diagnostic bundle") |
| [GitHub Issues](https://github.com/debpalash/VoiceStudio/issues) | Bugs and feature requests — use the templates; attach the diagnostic bundle (Settings → About → "Save diagnostic bundle") |
SIG_FILE=$(find "$WORK" -name "*.AppImage.sig" | head -1)
[ -n "$SIG_FILE" ] || { echo "sig asset listed but download produced nothing"; exit 1; }
NEW_SIG=$(cat "$SIG_FILE")
CHANGED=$(python3 -c 'import json,sys; p,sig=sys.argv[1],sys.argv[2]; d=json.load(open(p)); n=sum(1 for k,v in d.get("platforms",{}).items() if k.startswith("linux") and v.get("signature")!=sig and not v.update({"signature":sig})); json.dump(d,open(p,"w"),indent=2); print(n)' "$WORK/latest.json" "$NEW_SIG")
echo "::warning::Could not read this run's job start times (needs actions: read) — falling back to the stricter sibling-timestamp check, which can refuse a healthy build."
cat /tmp/gh-run-err.txt
RUN_CREATED_AT=""
fi
echo "This run began executing at ${RUN_CREATED_AT:-<unknown>}"
export RUN_CREATED_AT
WORK=$(mktemp -d)
python3 - "$WORK" <<'PY'
import base64, hashlib, json, os, subprocess, sys
from urllib.parse import unquote
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
sys.path.insert(0, "scripts")
from build_preview_manifest import ManifestRefused, build_manifest, required_assets
Binding for every AI agent (Claude, Codex, Cursor, review bots, …). CLAUDE.md is the full constitution; this is the operating contract. When they conflict, CLAUDE.md wins.
- **Default to the shortest response that fully answers.** Outlines and tables over prose; no preamble, no recap of what you just did, no re-explaining a fix the diff already shows. Applies to every response, not just status updates.
- Lead with the outcome. No narration, no restating diffs, no filler praise, no plans you're about to execute anyway.
- Status updates: one line. Final reports: only what changes the reader's next action.
- Don't re-derive what CI, linters, or review bots already computed — read their output first (`gh pr checks`, bot comments via `gh api .../pulls/N/comments`).
@@ -10,6 +11,11 @@ Binding for every AI agent (Claude, Codex, Cursor, review bots, …). CLAUDE.md
- Run targeted tests while iterating; full suites only before landing.
- Tests and CI simulate CI honestly: `HF_HUB_OFFLINE=1` + empty `HF_HUB_CACHE` — a populated dev cache masks real failures.
## Cross-platform parity: behaviour, not performance
- The parity rule covers user-visible BEHAVIOUR. Hardware acceleration varies by host by design (CUDA/MPS/DirectML, Triton availability, `torch.compile`); skipping an optimization where it physically cannot work is not a parity violation.
- Do not "fix" a parity finding by disabling a working optimization everywhere. That trades a real regression for a semantic one.
- A feature the user can see and use on one OS but not another IS a violation. Judge by what the user can do, not by how fast it runs.
## Merge protocol (hard rules)
1. Never merge without review. Harvest CodeRabbit + Greptile comments first; never merge with an unread Critical/P1.
2. Never accept a PR as-is: fix findings ON the PR branch pre-merge (maintainer commits fine; credit contributors in CHANGELOG). No merge-then-fix, no comment-and-walk-away.
@@ -26,3 +32,21 @@ Binding for every AI agent (Claude, Codex, Cursor, review bots, …). CLAUDE.md
- Versioning: `frontend/package.json` is the single source of truth; never bump without the owner asking.
-`frontend/package.json` dep changes require regenerating root `bun.lock` (Docker runs `--frozen-lockfile`).
- Issues: absorb or decline — never defer to a future version. Check the open-PR queue before implementing community-reported fixes.
## Agent skills
Project development skills are pinned in `skills-lock.json` and installed under
`.agents/skills/`: Vite and FastAPI.
Repository rules and tracker mappings override generic skill guidance.
### Issue tracker
GitHub Issues on `debpalash/VoiceStudio`, via the `gh` CLI. See `docs/agents/issue-tracker.md`.
### Triage labels
The five canonical roles, each label string equal to its name. See `docs/agents/triage-labels.md`.
### Domain docs
Single-context: `CONTEXT.md` + `docs/adr/` at the repo root. See `docs/agents/domain.md`.
The format is loosely based on [Keep a Changelog](https://keepachangelog.com/).
Versions track the desktop app (`tauri.conf.json` + `frontend/src-tauri/Cargo.toml`).
The bundled TTS model package (`pyproject.toml`) is versioned independently.
`frontend/package.json` is the app-version source of truth; Cargo, Python, and
the frozen-backend fallback mirror it for their toolchains.
## [Unreleased]
**Highlights**
- Show estimated and measured model, dependency, cache, and temporary disk costs in the engine catalogue (#1718)
- CosyVoice setup guidance now separates downloaded model files from the runtime that makes the engine available.
### Changed
### Added
- Windows releases now include an independently updatable per-user MSI that installs and uninstalls without elevation (#1713)
- Engine status and diagnostic bundles now record loaded execution provider, device, precision, fallback stage, accelerator identity, runtime versions, and parent-process memory visibility (#1717)
### Docs
- The CosyVoice guide now states that packaged builds have no one-click runtime installer and records the exact readiness checks exposed by [Discussion 1631](https://github.com/debpalash/VoiceStudio/discussions/1631).
- A production private-API guide now covers pinned containers, root credentials, network isolation, streaming proxies, health checks, upgrades, and benchmark evidence (#1720)
- RX 6700 XT/gfx1031 over WSL2 ROCDXG is now explicitly unverified until a published end-to-end GPU workload proves the mapped path (#1716)
### Fixed
- Dubbing jobs can now reuse every source-language code produced by automatic ASR detection without a 400 error on the next upload (#1737)
- Incomplete Sherpa-ONNX model snapshots now self-repair before recognizer startup instead of failing on a missing ONNX file (#1733)
- OmniVoice subprocess startup now allows slow packaged Windows Python runtimes to signal readiness before termination (#1711)
- SRT files selected during source analysis now wait for speaker cloning, then replace transcript text without losing voices (#1709)
- Windows MSI deployments can now prohibit WebView2 bootstrap with `DISABLEWEBVIEW2BOOTSTRAP=1`, and `AUTOLAUNCHAPP=0` reliably suppresses first launch (#1714)
- Subtitle rows now provide 100 ms timing steppers and flag adjacent overlaps without requiring precise timeline dragging (#1710)
- Repair-sync failures now retain uv's final dependency error instead of reporting only an opaque exit status (#1705)
- YouTube ingest now retries yt-dlp's transient “page needs to be reloaded” response (#1706)
- Dictation model readiness now follows the live Hugging Face cache selected in Settings (#1707)
- Dictation capture now queues native events whenever its webview listener unmounts or reloads instead of emitting them to nobody (#1707)
- Desktop-contained backends now exit when their owning app disappears instead of surviving as stale port-3900 processes (#1707)
## [0.5.1] — 2026-08-28
**Highlights**
- OmniVoice generation on Apple Silicon now runs in a crash-isolated child, so fatal MPS memory exits no longer take down the local backend (#1697, #1698) — thanks @ndntran14!
- Model-load GPU exhaustion now returns a sanitized, actionable dubbing error, and readiness correctly attributes the shared model status to TTS (#1695)
- Source-mode development now restarts an isolated backend crash without tearing down the UI, while repeated crash loops still stop loudly with diagnostics (#1690)
- Dubbing playback now keeps an audible companion source when a WebView can render the preview picture but cannot decode its audio (#1692)
- Model Catalogue engine rows now use the available desktop width and keep identity, runtime state, and actions from crowding one another (#1689)
- VoiceStudio now acts as a local speech platform: other apps can trigger its native dictation or connect through versioned HTTP, WebSocket, JSON-RPC, CLI, and MCP transports (#1646)
- A timed-out in-process dub transcription no longer starts a second WhisperX/CTranslate2 call over the abandoned native worker, preventing the overlapping access that preceded Windows `0xC0000005` exits (#1669)
- Windows debugger termination code `0x40010004` is no longer misreported as a backend crash or charged against automatic restart recovery (#1663)
- Studio now keeps one generation reservation across page changes, preventing a remount from stacking native jobs until the backend reports capacity busy or is killed under memory pressure (#1670)
- Uploaded dubbing videos are normalized to browser-safe H.264/AAC before preview, preventing valid VP9, AV1, or Opus media from failing with “no supported sources” (#1644)
- Dubbing now separates spoken and target languages, preserves translations through segment cleanup, and lets failed translations be retried or skipped without restarting the batch (#1654) — thanks @Number16BusShelter!
- Importing replacement SRT subtitles now keeps each cue bound to the best-overlapping source speaker and clone instead of resetting every line to a random default voice (#1660) — thanks @invio-a11y!
- Uploading a Dub preview no longer blocks every backend request while ffmpeg extracts its audio (#1667) — thanks @tfreyd!
- Docker quick starts now require the administrator key needed through container NAT instead of starting a UI whose protected actions return 403 (#1651) — thanks @wd357dui!
- WSL2 AMD containers now use the `/dev/dxg` ROCDXG bridge with actionable GPU diagnostics instead of silently falling back to CPU (#1655) — thanks @wd357dui!
- Ad-hoc voice-clone references now stay alive until cancelled or timed-out GPU work actually stops reading them, so prompt caching can finish instead of failing on a deleted temp file (#1668) — thanks @tfreyd!
- Dictation now stays bound to the app where it started and recovers locally from silent recognizer output (#1175)
- The backend now answers within a second of launch and narrates its startup step by step (#1550)
- Reporting a bug from an outdated build now offers the latest release first (#1547)
- The backend is only announced ready once it can actually serve, and crash-loop restarts now pace themselves (#1548)
- Invisible watermarking no longer stalls — or silently skips — the first take of a session (#1615)
- Dub subtitles can be retimed, inserted, and merged in either direction from the segment table (#1612) — thanks @invio-a11y!
### Changed
- Model Catalogue now uses one breathable workspace canvas with simpler pane and engine-family navigation instead of nested cards and scroll regions (#1685)
- Linux source launchers now catch missing libxdo and GStreamer audio plugins before they can cause a linker error or an aborted, blank WebKit renderer (#1680, #1682)
- Dictation now carries one native output session from shortcut-down through final delivery, restores text, HTML, image, or file-list clipboards only when untouched, keeps Wayland copy-safe unless current-focus insertion is explicitly enabled, and retries silent Sherpa speech only through an already-installed local ASR model (#1175)
- The backend binds its port immediately and reports startup progress live — `/health` answers 503-with-step and a new `/startup/progress` endpoint lists every step while PyTorch, API routes, and database migrations load in the background, so "starting at step X" is never mistakable for "dead"; the desktop splash narrates each step (#1550)
### Added
- A bundled Rust loopback sidecar exposes dictation start/stop/toggle, focused-output sessions, discovery, and JSON-RPC; the backend adds versioned streaming events and a dependency-free CLI bridge for Herdr, coding agents, editors, desktop apps, and TUIs (#1646)
- Headless NVIDIA and ROCm machines can now join as worker-only Docker Compose services with no published UI and durable protocol-v2 enrollment; update both machines together before reconnecting (#1638) — thanks @jkrogers9862!
- Linux ARM64 (Asahi Apple Silicon) support for the OmniVoice GGUF engine — a `linux-aarch64` binary built with GGML Vulkan where the toolchain allows it, so Apple GPUs accelerate generation through the open-source Honeykrisp driver instead of falling back to CPU-only (#1641)
- One-command install on every desktop OS: `curl -fsSL https://voicestudio.sh/install | sh` (macOS/Linux/WSL) or `irm https://voicestudio.sh/install | iex` (Windows) — the URL serves the right script per platform, and Windows gains a source installer (`scripts/install.ps1`) with a 3-OS CI smoke (#1626)
- Per-line subtitle management in the dub table: a line's end time is editable alongside its start (typing a time and dragging its timeline edge now take the same path), lines merge with the previous row as well as the next (`Ctrl/Cmd+Shift+M`), and a new line can be inserted into the gap after any row (#1612) — thanks @invio-a11y!
- CI now enforces performance regression budgets on the hot paths — operation-count tests pin streaming TTS to one synthesis per sentence and cached dub re-mixes to zero re-synthesis; fast-path guards cover zero re-decoding and ⌈N/W⌉ native batch calls when enabled (#1594)
- Default-engine dubbing now synthesizes several segments per forward pass instead of one call per line — the width follows the host's device headroom (1 on CPU and low-VRAM cards, up to 8), `OMNIVOICE_DUB_BATCH_WIDTH` overrides it, and engines without native batching keep the single-segment path (#1594)
-`/ws/tts` now reports real time-to-first-audio, and its RTF measures synthesis alone so a slow client can't inflate it (#1594)
- The locally cached AudioSeal watermark generator warms on a background thread ~35s after boot (`OMNIVOICE_PRELOAD_WATERMARK=0` opts out; explicitly setting `=1` may download it), so the first synthesis no longer serializes the audioseal import + model load inline — measured at ~42s on a cold filesystem, 3s short of a 90s client timeout (#1576) — thanks @paoloantinori!
- Voices you've cloned stay "warm" across restarts — encoded references now persist to disk (~10 KB each), so the first generation of a session skips the re-encode and any transcription pass; `OMNIVOICE_PROMPT_DISK_CACHE=0` opts out (#1565)
- Optional FlashInfer acceleration for the default engine on CUDA (`OMNIVOICE_FLASHINFER=1`, ~2.2x measured) — needs the optional `flashinfer-python` package; missing package or kernel failure logs why and falls back to the standard path (#1565)
- The bug reporter notices when you're on an outdated build and offers the latest release before filing — with a "File anyway" escape hatch — and stamps a `Build status` line into every report so up-to-date reports are tellable from stale ones (#1547)
- Settings → Performance & Device gains a compute-device override (Auto / CUDA / ROCm / XPU / MPS / CPU, or `OMNIVOICE_DEVICE`) — pin the device when auto-detect picks wrong; only devices your machine actually has are offered (#1557)
- Opt-in 24-layer PocketTTS checkpoints via `OMNIVOICE_POCKETTTS_24L` — better prosody for it/de/es/pt at roughly 2x render time (still faster than real-time); the fast 6-layer model stays the default (#1613) — thanks @paoloantinori!
### Docs
- Supported-version and install guidance now identifies 0.5.1 as the stable desktop and container release (#1687)
- The Docker Hub overview now shows the current engine-switching demo, Model Catalogue, and gallery voice workflow (#1593)
- The Docker Hub overview and install guide now show the v0.5 tags and the built-in API-key/share-PIN security model instead of obsolete v0.4 and no-authentication guidance (#1592)
- The READMEs now lead with download buttons and a three-step first-clone walkthrough, and a new benchmarks page anchors measured per-engine/per-device numbers on the in-repo harness (#1555)
- Every engine now has its own guide — 21 new pages under docs/engines plus an index covering all 16 TTS and 11 ASR engines, linked from both READMEs (#1556)
- The OmniVoice guide now covers combining style attributes with a reference clip (consistent instruct stabilizes cloning; the reference wins conflicts), inline pronunciation control (pinyin / CMU phonemes), and corrects the claim that the default engine can't do voice design — it can, from attributes (#1565)
### Fixed
- Workspaces now measure their responsive width when the post-bootstrap shell actually mounts, so native UI scaling reflows Projects and History instead of crushing the Dubbing demo into unreadable columns (#1683)
- Dubbing keeps the source-language selector visible after a local file is chosen, so ASR can be pinned before transcription starts (#1678) — thanks @Lonki-lomki-cloud!
- First-run media-engine downloads become available to TTS immediately without a restart, and missing media-process failures now point to repair controls (#1677) — thanks @farhataligpt-dev!
- Source installs on AMD GPUs honour `OMNIVOICE_TORCH_VARIANT=rocm`: `bun run desktop` now swaps in the ROCm torch wheel after `uv sync` and launches the backend without re-syncing, instead of silently reverting to the CPU-only CUDA build on every start (#1665) — thanks @uberclokr!
-`bun run desktop` on a fresh clone no longer fails with "resource path `../../frontend/dist` doesn't exist" — the dev launcher creates the placeholder Tauri resource directory before compiling (#1664) — thanks @uberclokr!
- macOS no longer loses TTS after the first request when Python lacks `os.waitid`; subprocess ownership now uses a safe `waitpid` fallback without risking reused process groups (#1656) — thanks @paoloantinori!
- Desktop startup, Retry, reset, uninstall, shutdown, and crash recovery now share one backend lifecycle owner; quitting interrupts first-run installers and gracefully drains then force-cleans the full backend process tree, so overlaps cannot duplicate or orphan it (#1635) — thanks @Xohaibxobi!
- Large Stories and Audiobook projects now persist in IndexedDB instead of overflowing the `omnivoice.app` localStorage envelope, with quota-safe migration and orderly exit/reload flushing (#1636) — thanks @leodzai!
- OmniVoice and its crash-isolated subprocess now route to AMD ROCm GPUs instead of warning and falling back to CPU (#1629) — thanks @j4r3kb!
- Dictation now cancels pending startup work, capture resources, sockets, and timers when the capture widget closes, preventing late work against a destroyed webview (#1645)
- Streaming generation failures now show recognized recovery guidance and appear in Diagnostics instead of only returning a generic error (#1607)
- The worker-capacity transport test no longer races its own setup: the 1-slot limit now goes through the enrollment handshake instead of mutating client config after connect, where the server's stream-open ConfigUpdate (carrying the registered capacity of 2) could overwrite it and fake an over-accept; failed CI twice on 2026-08-21 (#1630)
- Moving words across a speaker boundary in a dub — merging two lines and splitting them again — no longer dubs the second half in the first speaker's voice; each half now keeps the speaker, voice, direction, gain, and language of whoever actually says it (#1612) — thanks @invio-a11y!
- Dictation on a WebView that refuses a 16 kHz audio context (WKWebView) now low-passes before downsampling, so frequencies above 8 kHz stop folding into the speech the recognizer is fed (#1610)
- A microphone context that cannot be resumed now reports a mic error instead of leaving the dictation pill on "Listening" while capturing nothing (#1610)
- Dictation no longer retains a whole session's audio for silent-model recovery — an open mic grew that buffer by ~115 MB an hour; the recent two minutes are kept instead (#1610)
- The clipboard-delivery status is now translated in all 21 languages, so Wayland users — where clipboard delivery is the default — no longer see an English string (#1610)
- A native sherpa-onnx load failure of any exception type now degrades to "engine unavailable" instead of taking the dictation WebSocket down (#1610)
- Dictation now ships Whisper Tiny as its one cross-platform default, avoiding Parakeet's measured empty decoding on Windows while keeping Parakeet selectable behind runtime fallback (#1175)
- Re-mixing a dub no longer decodes, rewrites, and re-reads every cached segment — same-rate cached audio is reused directly (and rejected if truncated), switching timing modes can't reuse slot-truncated audio as natural-rate, and RVC respects natural-rate modes (#1594)
- PocketTTS French works again — pocket-tts only ships a 24-layer French model and rejected the name the sidecar asked for, so every French request failed at model load; French now always loads `french_24l` (#1613) — thanks @paoloantinori!
- Installing IndexTTS 2.5 no longer fails claiming an interrupted download — the weights repo ships `config.yaml` and VoiceStudio demanded a `config_v2_5.yaml` that exists in no upstream release; both names are accepted, so a hand-renamed checkout keeps working (#1611) — thanks @zuiaiyutu!
- IndexTTS 2.5 no longer has long-text generation killed at 60 seconds — the sidecar now proves it is alive every 5 seconds while `infer()` runs, and its deadline rises to 900s (`OMNIVOICE_INDEXTTS_RECV_TIMEOUT_S`) (#1611) — thanks @zuiaiyutu!
- The OpenAI-compatible `/v1/audio/speech` route now reuses the shared cached engine for explicit `model` ids instead of constructing a fresh engine — and its sidecar/model load, a ~28s floor per call for subprocess engines — on every request, with the same single-engine-resident discipline `/generate` applies (#1614) — thanks @paoloantinori!
- The setup wizard's RAM check no longer blocks 8 GB machines whose OS reports ~7.8 GB usable — the thresholds now tolerate reserved memory, and `OMNIVOICE_RAM_PREFLIGHT=0` turns a genuine block into a warning for those who accept the OOM risk (#1618)
- Invisible watermarking now runs eagerly instead of through `torch.compile` — AudioSeal's lazy compile sent the first embed of every session into Inductor's C++ codegen, which failed outright on macOS hosts whose toolchain couldn't serve it and shipped the audio unmarked after a 30-40s wait; first embed drops from 9.70s to 0.26s (#1615) — thanks @paoloantinori!
- The macOS Accessibility blocker now rechecks while visible and closes as soon as the grant is enabled instead of keeping a stale permission prompt on screen (#1609)
- The dubbing editor's video and transcript columns can now be resized by pointer or keyboard, and the chosen split persists across launches (#1571) — thanks @invio-a11y!
- CPU-only synthesis now gets a bounded ten-minute execution budget, and a render that exhausts it is reported as a compute timeout instead of misleading "generation capacity is busy" queue pressure (#1588) — thanks @ChienNguyen1111!
- Rapid Launchpad ↔ Dub navigation now replaces the workspace DOM owner cleanly, so late media/waveform cleanup cannot trigger React's `insertBefore` crash (#1590) — thanks @nicolas-jacques!
- Watermark embedding failures now log the full traceback instead of just the exception message, so a silently-unmarked-audio incident (audio passes through unmarked by design) is diagnosable from the log alone (#1576) — thanks @paoloantinori!
- Dubbing now recovers rapid two-speaker exchanges when diarization collapses them, defaults new projects to lip sync without overwriting saved timing choices, and keeps the editor usable on narrow screens (#1584) — thanks @victordonat0!
-`OMNIVOICE_ASR_BACKEND=omnivoice` now selects the PyTorch-native Whisper path, so the documented ROCm escape hatch no longer fails as an unknown engine (#1582) — thanks @patmansk!
- Network Sharing from Windows MSI/portable installs now serves the bundled web interface to LAN devices instead of redirecting them to their own `localhost` (#1589) — thanks @TWIISTED-STUDIOS!
- Exported dubbed videos now mark the dubbed language as the default audio stream while keeping Original available as an explicit choice (#1575) — thanks @invio-a11y!
- Cloning references can no longer exhaust system memory: transcript-free clips up to 75 seconds are searched in five bounded passages, longer clips ask to be trimmed, and supplied transcripts remain capped at 20 seconds to preserve alignment (#1578) — thanks @ACKAPOB!
- Stored artifact subpaths now resolve after moving a data directory between Windows, macOS, Linux, and Docker, while traversal and symlink escapes remain blocked (#1559) — thanks @Eman-Yousaf!
- A remote browser hitting an API-key-configured server's admin 403 now gets the API-key login form instead of endless console 403s, while desktop and PIN-only/no-key servers keep the plain loopback error so guests are never offered a login no key can satisfy (#1568) — thanks @paoloantinori!
- The crash-isolated ASR sidecar and its download preflight now agree on which model to load — setting the shared faster-whisper model variable applies to both variants instead of the sidecar quietly using a different one (#1556)
- "Ready" now requires the deep health probe (a working database-backed route), not just the identity probe — a backend whose install broke underneath can no longer be announced up while every real request fails (#1548)
- Supervisor restarts after repeat crashes now back off (immediate, then 5s, then 15s) instead of respawning back-to-back, so a tight crash loop can't burn the whole restart budget in seconds (#1548)
- The Linux desktop cleanup regression test now isolates build artifacts, so an existing developer build can no longer change its result (#1566)
- Renaming, deleting, or revoking consent on a voice (and starring/clearing history, recording exports) now live-updates every open tab again — the sync routes' WebSocket events were silently dropped, which could look like "all my voices are gone" (#1561) — thanks @paoloantinori!
### CI
- Project agents now share pinned Vite and FastAPI skills from skills.sh (#1594)
- Weekly full-history secret scans no longer mistake the Ed25519 private-key type name for committed key material (#1591)
## [0.5.0] — 2026-08-13
**Highlights**
- The app is now **VoiceStudio** (previously OmniVoice-Studio) — one waveform-and-spark identity across the app, docs and installers. Your data folder, settings and Docker image paths stay put.
- **Model Catalogue** — engines and models in one workspace: every TTS, transcription and LLM engine with its device routing and install state, defaults picked there.
- Switch TTS, ASR and LLM engines from the status bar or any workspace — ready-only choices, memory status, environment-pin protection, `Ctrl/Cmd+E`. (#1530)
- Lend another machine's GPU with a join code and a QR scan — a Compute control in the status bar picks where jobs run, and several people can share one GPU box with revocable, certificate-pinned connections. (#1516, #1496)
- Server mode is locked down: admin actions require an API key (#1525), and the remote UI exchanges it for short-lived sessions that never sit in browser storage or WebSocket URLs (#1528) — thanks @bultodepapas!
- A faster, cleaner Dub workspace for multilingual production, with a production command bar and per-language cards. (#1489)
- The demo audio and video the app always advertised now actually ship, rendered by VoiceStudio's own engine. (#1517)
- Dictation works on Wayland now — the portal shortcut actually fires (#1490, #1526) — and the recording pill is back on every desktop.
- The Launchpad wears the project's signal-field waveform artwork over a quieter, borderless layout. (#1533)
- The catalogue reads as headroom, not breakage: available engines sort first, uninstalled ones say what they need (#1531), and the LLM row names the provider that actually answers (#1538).
- Gallery voices can be saved as local profiles — audio lands in your profile store with validated, content-addressed references. (#1542)
<img src="https://raw.githubusercontent.com/debpalash/VoiceStudio/main/docs/media/0.5.0/quick-switch.gif" alt="Switching TTS engines from the status bar" width="820" />
| The Model Catalogue | The Voice Gallery |
| --- | --- |
| <img src="https://raw.githubusercontent.com/debpalash/VoiceStudio/main/docs/media/0.5.0/catalogue.png" alt="Model Catalogue — engines pane" width="420" /> | <img src="https://raw.githubusercontent.com/debpalash/VoiceStudio/main/docs/media/0.5.0/gallery-save.png" alt="Voice Gallery — save a voice as a profile" width="420" /> |
### Changed
- Gallery personas now preview through the local backend, retain their complete voice-design recipe, and open directly in Voice, Stories, or Audiobook. (#1542)
- Typing and large workspace edits no longer serialize and rewrite persisted documents on every input; writes are coalesced off the interaction path — thanks @bultodepapas! (#1541)
- Support amount choices now use every theme's shared card, accent and focus tokens. (#1530)
- Sponsoring, commercial licensing and getting in touch are one page now. They answered the same question between them and each used to live somewhere else, so they are three sections on a single scroll — the footer heart, the commercial-licence links and Contact all land on it, at the section you asked for. (#1522)
- Model Catalogue switches panes with tabs instead of a two-state toggle, and the Engine Compatibility Matrix's TTS / ASR / LLM switcher is now tabs too — arrow-key navigable, and each tab still shows the engine it would use. (#1522)
- Engines you can actually use sort to the top of the compatibility matrix, and an unavailable engine's name recedes instead of the whole row fading — the status badge and GPU chips that say *why* it is unavailable stay legible. (#1522)
- Remote workers reads as a device list: status dot, address, latency, a live task meter, resident models and last-seen per machine, with housekeeping actions revealed on hover and a three-step empty state. (#1516)
- The GPU picker and the new status-bar control paint their status dots and menu surfaces from themed tokens instead of fixed palette classes, so they stop showing Gruvbox colours on Midnight and Catppuccin. (#1516)
- Dictation shows the pill again: a capture puts a small always-on-top capsule near the bottom of the screen you are working on — listening, transcribing, the result, and any error — and takes it away when the session ends. It never takes focus, so the text still lands in the app you were typing into. On Wayland the compositor decides where it sits; everywhere else it is bottom-centred.
- Engines and models moved out of Settings into a new Model Catalogue workspace, reachable from the icon rail (or the title-bar tabs); Settings → Engines and Settings → Models now point there, and Settings keeps the models directory and Hugging Face mirror.
- The Settings sidebar is keyboard-navigable: ⌘K / Ctrl+K jumps to the filter, ↑/↓ and Home/End move between categories, and Enter or ↓ from the filter drops into the list. Matching text in a filtered category name is highlighted, and group headers stay pinned while the list scrolls.
- The Launchpad has a quieter, more spacious look: borderless feature tiles that light up on hover or keyboard focus, plain-numeral counts, hairline section rules, and one shared page column for the hero, tiles, recent files and project lists.
- Linux release smoke now validates linuxdeploy's wrapped custom launcher instead of rejecting a healthy AppImage. (#1506)
- Remote GPU workers render audiobooks chapter by chapter, with automatic per-chapter local fallback and one combined notice if the worker drops out. (#1478)
- Remote GPU workers can now run a job to completion: long renders no longer die at two minutes, a worker that drops and reconnects mid-render keeps its work, and a timed-out job no longer takes the worker offline for good. Placing a job still needs the development-only `POST /workers/tasks`; wiring the app's own Synthesize button to it comes next.
- Voice, Stories, Audiobook, Gallery, Settings, profiles, and Launchpad now use compact, responsive layouts with accessible controls. (#1491)
- Dubbing's Generate Dub, Verify, and Export actions now use a compact hierarchy with visible labels, responsive reflow, and motion-safe feedback. (#1493)
- The Dub workspace now has a compact production command bar, responsive flag-based language cards, media previews in Dub History, and a narrower Projects rail. (#1489)
- VoiceStudio now uses one waveform-and-spark mark across the title bar, About screen, README, browser favicon, and every desktop/platform icon. (#1487)
- PocketTTS now asks you to review its code license, model license and gated-access conditions before first use, and explains how to unlock the model instead of showing a raw download failure — thanks @paoloantinori! (#1442)
- The repository moved to github.com/debpalash/VoiceStudio. Every link in the app, docs and scripts now points there; GitHub redirects the old URLs, and the Docker image paths, the app bundle identifier and your data folder are all deliberately unchanged. (#1394)
- The app is now **VoiceStudio** (previously OmniVoice-Studio). Only the name you see changes — your data folder, settings and the Docker image paths stay put, so upgrading needs nothing from you. On Linux the .deb is now `voicestudio`; remove the old `omnivoice-studio` package once.
- macOS floor raised to 13.3 (Ventura) — the frontend has required Safari 16.4 for some time, so macOS 12 was a promise the stack could not keep (#1268)
- The first-run setup screen no longer overpromises. It claimed "no account, no cloud, no telemetry" without qualification — untrue for anyone who opts into analytics — and now says what actually holds either way: your voices, recordings and projects never leave the machine, and no processing happens in the cloud.
- Dictation no longer shows a floating pill. The hotkey records, transcribes and pastes with nothing on screen; the tray icon still marks recording, and anything needing your attention (Accessibility, microphone, a failed transcription) now arrives as a notification in the main window.
### Added
- Gallery personas preview through the local backend, keep their full voice-design recipe, and open directly in Voice, Stories, or Audiobook — and can be saved as local profiles with validated audio references. (#1542)
- The demo audio the app has always advertised now actually ships: previews for all seven voice-design presets, the three dictation replay clips, and the dubbing demo's source video plus four dubbed languages with subtitles. Every one of those was a dead link before — the tooling that renders them required macOS, so on Windows and Linux the files were never built. (#1517)
- Demo assets are rendered by VoiceStudio's own engine, so the tooling runs wherever the app does, and the demos are made by the thing they demonstrate. (#1517)
- A machine can now join a control plane from the app: Settings → System → Remote workers → **Lend this machine's GPU**, paste the join code, done — no environment variables and no restart. The address travels with the code, so the machine reconnects on its own afterwards. (#1516)
- Join codes and connection strings are shown as a **QR code** alongside the text, with a live expiry countdown — scan it from the other machine instead of retyping forty characters. (#1516)
- A **Compute** control in the status bar: pick local or a remote machine, turn remote workers on or off, and mint a join code without opening Settings. It appears only once you have opted in or enrolled a machine. (#1516)
- A worker waiting for approval can be approved from its row. The panel labelled that state before but offered no way out of it. (#1516)
- **Model Catalogue** — a workspace of its own for engines and models: browse every TTS, transcription and LLM engine with its device routing and install state, pick the default for each, and install or remove model weights, all from one screen instead of two Settings categories.
- Remote GPU machines can now accept connections instead of dialling out, so several people can use the same box at once — each gets their own revocable connection string, with certificate-pinned TLS, a live list of who is connected, and a disconnect button. (#1496)
- Remote GPU model downloads now use the normal Models install flow and show per-worker progress. (#1478)
- Settings → System → **Remote workers** sends individual jobs to GPUs on your other machines while everything else stays here. Off by default; each machine is added with a single-use token and approved before any audio reaches it. See [docs/remote-workers.md](docs/remote-workers.md).
- First-run setup now recommends a screen-aware interface scale, with compact controls available throughout setup. (#1502)
- OrcaRouter is now available as a named OpenAI-compatible LLM provider — thanks @Marc-oss-hub! (#1499)
- IndexTTS 2.5 is available as a pinned one-click sidecar with five-language dubbing, expressive cloning, and backward-compatible IndexTTS-2 support. (#1482) — thanks @marwanlhabti5-coder!
- Voice recording now offers microphone and channel selection with a live input-level meter on every desktop platform. (#1481)
- Settings → Appearance → **Navigation style** switches the workspace switcher between the icon rail down the window edge and browser-style tabs across the title bar. Both offer the same workspaces; the choice sticks across launches, and the rail stays the default. Tab labels fold down to icons when the title bar runs out of room — the workspace you're in keeps its name. (#1412)
- Portable mode lets you choose the folder — press **Change…** on the first-run setup screen and put the whole install on an external drive. It also stops being greyed out after a default Program Files install. (#766)
- Settings → Privacy now has an **Invisible watermark** toggle. On by default, available to everyone, and it only affects audio generated after the change. (#1308)
- A new opt-in crash-isolated TTS engine, so a native crash takes down the sidecar instead of the whole backend — thanks @paoloantinori! (#1292, #1298, #1304)
- **PocketTTS** (Kyutai), an opt-in CPU-only engine for fast, low-latency renders in six languages (en/fr/de/pt/it/es) with zero-shot cloning from a reference clip. Enable in Settings → Engines — thanks @paoloantinori! (#1306, #1328)
- A warning before a slow generation, rather than after a five-minute wait. (#1280)
### Docs
- Engine acceptance: new `docs/engine-acceptance.md` documents the job map, the bar a new engine must clear, and the out-of-tree path (#1306)
- macOS install notes and the README support table now state the real floor (#1268)
- Contact: the project X account is listed alongside Discord (#1313)
-`OMNIVOICE_ALLOWED_ORIGINS` is finally documented: a browser loading the UI from another machine's origin needs the backend's CORS allow-list, which neither server mode nor trusted networks touches — thanks @vanderlpp! (#1348)
### Fixed
- AMD/ROCm hosts no longer crash ASR with "CUDA driver version is insufficient": ROCm torch reports itself as CUDA, but whisperx/faster-whisper run on CTranslate2, which is NVIDIA-only — they now take the CPU path there, and auto-detect prefers pytorch-whisper, which genuinely uses the HIP GPU. (#1529)
- Crash reports now carry the crashed run's own stderr: the shared error log is append-only with per-run offsets, so a restart can no longer overwrite the dying process's final output with the replacement's healthy startup. (#1510)
- Wayland: a stale portal identity no longer kills the dictation shortcut for the whole session. The desktop entry the app writes for the GlobalShortcuts portal could point at a binary that has since moved (a `cargo clean`, a relocated AppImage) — GNOME then refuses the bind with "App info not found" and the hotkey silently dies. The entry is validated and rewritten at startup now. (#1526)
- The guard that keeps transcription on the degrading ASR loader now scans the whole backend, not just the routers — a service that transcribes on a request's behalf skipped `ensure_loaded()` just as thoroughly. (#1519) — thanks @ahov520!
- The Linux app icon is no longer blank. Every AppImage since v0.4.2 shipped `.DirIcon` as an absolute symlink into the machine that built it (`/home/runner/work/…`), so the link dangled on every user's computer and file managers, app menus and desktop integration all drew nothing. The release build now verifies the icon resolves inside the bundle before publishing. (#1518)
- The Linux desktop entry no longer ships an empty `Categories=`, which `desktop-file-validate` rejects and menu builders skip. (#1518)
- Wayland: the dictation shortcut now actually starts dictation. The desktop portal registered the key correctly — GNOME and KDE even showed it back — but every press was discarded while decoding the compositor's signal, so the hotkey did nothing on any Wayland session. (#1490)
- The first-run "Choose a comfortable UI size" screen no longer stutters while you sit there. Applying a scale resizes the window's own viewport, which the screen was reading back to re-pick a size — so it flipped between two sizes forever without anyone touching it. (#1514)
- Transcription now moves to the next working engine when the auto-picked one passes its availability check but breaks on first real use, instead of returning an internal error — the recovery dubbing already had. Affected accurate-mode transcription, the OpenAI-compatible API, batch, dub verify, and voice-clone reference text. (#1512)
- A malformed request now gets a clear 422 instead of an internal error, and uploading a file to an endpoint that expects JSON no longer copies the whole upload into the app log — a 145 KB clip wrote roughly 500 KB of log, recording your audio in the file people paste into bug reports. (#1513)
- The Simplified Chinese (zh-CN) translation no longer mistranslates brand names and technical terms — Discord, Tailscale, Hugging Face, IPA, and LLM (Cinematic) were rendered as nonsensical literal translations, and ~250 more awkward machine-translation strings are now natural Chinese. (#1508) — thanks @anyingiit!
- Worker restart coverage now waits for the registration response to persist its identity instead of racing the client callback in CI. (#1505)
- Dub language and export selections now restore without false schema warnings, and remote-worker port 7443 is identified instead of reported as a generic timeout. (#1504)
- A configured remote backend now bypasses local first-run setup, verifies itself before app requests begin, and shows recovery instead of leaving the desktop stuck on Setup. (#1503)
- An idle voice model now actually hands its memory back. The unload emptied the GPU cache a moment before releasing the model, so it freed nothing while reporting success — a GPU machine lending its card sat on 3.6 GB indefinitely. (#1495)
- Unloading a model on an NVIDIA GPU now returns the last ~770 MB too. A single 8.5 MB cuBLAS workspace sat inside the model's memory block and kept the whole block reserved, so an idle machine held 1.2 GB instead of 470 MB no matter how often you pressed Flush Memory. (#1495)
- Flush Memory reports reserved GPU memory alongside allocated. Allocated alone reads near zero right after an unload while the GPU still shows gigabytes, which is exactly the case people were reporting. (#1495)
- The AudioSeal watermark models are released after the same idle period as everything else, instead of staying in memory for the life of the app once anything was watermarked. (#1495)
- Remote GPU workers now synthesize a dub's fresh segments as one coarse job with live progress and cancellation; fitting, assembly and RVC remain local. (#1478)
- Gallery voice previews now fall back to a local render when a downloaded clip cannot be decoded, instead of failing silently. (#1478)
- A second VoiceStudio instance can no longer silently share the remote-worker port; it keeps running locally and explains how to resolve the conflict. (#1478)
- Remote GPU jobs stay pinned to the selected worker across retries and restarts, stop when their caller leaves, and cannot return from cancellation as completed. (#1478)
- Remote GPU model labels now survive registration, legacy blank model IDs share one capacity slot, long jobs retain bounded leases, and idle cleanup cannot evict a live local render. (#1478)
- Remote GPU jobs now stop before dispatch when that worker lacks the model, offer the download there, and refresh scheduling as soon as it finishes. (#1478)
- Leaving a screen while its waveform is still loading no longer opens a bug-report prompt for a normal cancelled request. (#1498)
- An unreachable remote backend now opens a retryable recovery screen instead of sending the app into local model setup, with clear TLS, CORS, network, HTTP, and wrong-port guidance — thanks @debpalash! (#1501)
- Linux production test launches now stop their own extracted AppImage before resetting SQLite and logs. (#1494)
- Restored the pre-release version to 0.4.2 while the next release remains in preparation. (#1488)
- Large multi-language dubbing batches now use compact searchable language and track managers instead of overflowing the editor. (#1492)
- Dictation shortcuts now register and rebind through the desktop portal on Wayland, honor custom keys in focused app views, and show the effective platform keys. (#1490)
- Multi-language dubbing now translates, edits, generates, retains, and exports every selected language, and its language picker stays visible at viewport edges. (#1486)
- Dubbing's **From video** cast now uses available source-audio samples for every speaker and short line, including jobs without a pooled diarization clone. (#1484)
- Basic Dubbing translation remains available without an LLM; Cinematic and Autofit now degrade through the existing Fast translation path instead of blocking the quality choice. (#1481)
- Linux microphone recording now falls back to WAV when WebKit cannot encode MediaRecorder audio, and desktop scaling/titlebar controls remain responsive at every UI scale. (#1481)
- Dubbing can install a missing ASR model and retry the same job, navigate back through completed stages, and finish transcription under low GPU memory without producing an empty transcript. (#1481)
- Filenames and other outside data can no longer forge extra lines or terminal commands in backend and frontend diagnostic logs. (#1457)
- Backend journal, dictation reset, voice-catalog, and crash-notification failures are now visible and retryable instead of being silently ignored. (#1459)
- Backend failures keep raw tracebacks, local paths and credentials in the local log instead of returning them in API responses. (#1454)
- GPT-SoVITS connections now stay on loopback or explicitly trusted networks and cannot escape through redirects or DNS rebinding. (#1463)
- Engine discovery no longer exposes probe exceptions, local paths or credentials in API responses and logs. (#1460)
- Failed gallery, batch-video, and desktop-log cleanup is now reported instead of silently claiming success, and diagnostic redaction fails closed if a scrubber breaks. (#1458)
- Remote backends can no longer probe or overwrite arbitrary host files through native-only tools, and imported or persisted paths cannot escape their VoiceStudio data folders. (#1455)
- Linux releases now verify that the AppImage actually contains the compatibility launcher, instead of silently shipping Tauri's stock launcher and opening as a blank window on newer Mesa systems. (#1464)
- Patched dependency releases now cover 35 Python and Rust security advisories without weakening VoiceStudio's GPU or offline-runtime compatibility. (#1456, #1472, #1473, #1474, #1475, #1476, #1477)
- Curated models now install and repair from reviewed, immutable revisions; custom MOSS remote code requires an explicit safety opt-in. (#1453)
- YouTube imports that require a signed-in session can now use an explicitly selected `cookies.txt` export for one import; VoiceStudio never reads browser cookies silently and makes two best-effort attempts to delete its temporary copy. (#1429, #1432) — thanks @dongqing1968-sudo and @phamvandu9595-tech!
- First-run source builds no longer stop after uv was successfully downloaded just because its installer failed during a later shell-profile step; app-private uv installs no longer touch shell profiles at all. (#1438) — thanks @AdrianoCahete!
- Model files damaged by an interrupted download now repair themselves instead of failing every generation, including invalid `config.json` files and corrupt weight headers. — thanks @overrunau and @zherunh! (#1406, #1437)
- ROCm Docker now installs and starts the backend with the same Python whose AMD torch build was validated, instead of launching a second CUDA-only environment and silently running on CPU. (#1274) — thanks @simmessa and @spicchio72!
- An error whose text merely contained the digits 401 — a file path, a byte count, a job id — no longer tells you to fix your Hugging Face token. (#1427)
- Custom MLX model IDs and saved voice instructions are now validated in bounded time, so malformed input cannot stall the backend. (#1446)
- Streaming and provider failures now return stable recovery guidance without exposing exception details. (#1462)
- Server-mode settings mutations require the admin API key, while host destinations and executable paths can only be selected through the native desktop app. (#1448)
- Automatic model-mirror checks now reject untrusted URLs before opening a network connection. (#1447)
- Sidecar engines no longer break when a library they load prints to the console. Those bytes landed in the middle of the engine's data stream, failing the generation and leaving the connection scrambled for every request after it. (#1428) — thanks @1335-Group!
- A generation abandoned while stuck on an internal lock now says so, instead of blaming your hardware and suggesting shorter text. Nothing had been computed, so none of that advice applied. (#1416, #1419)
- A machine with a GPU that ends up on CPU now says why — a missing device node, a permissions problem, a card newer than the installed ROCm, an `HSA_OVERRIDE_GFX_VERSION` that is doing more harm than good, or an NVIDIA driver the container can't reach each read differently. Before, all of them looked identical to having no GPU at all. (#1274, #1228)
- The first generation on an engine that still has to install itself no longer gives up part-way. The install reports progress now, so the generation waits for it instead of hitting its own five-minute limit. (#1414)
- A slow machine is no longer told its IndexTTS-2 install isn't there. The check that confirms an engine's virtualenv gave up after 10 seconds and counted that as a broken install, so a cold first run 500'd; it now waits longer and treats slow as unproven, not broken. (#1414) — thanks @OracleNightmare!
- A broken Python environment now says so, instead of blaming the app's own install. A missing or mismatched torch/transformers surfaced as "omnivoice not importable" and sent people reinstalling the wrong thing. (#1415)
- A model that fails to load at startup no longer leaves the app looking healthy while producing nothing — the failure and its remedy now show up in the model status. (#1415)
- Generating with the default engine works again on everything built from `main` since the rename — source checkouts, preview builds and Docker `:latest` all run the same backend, whose model import had been rewritten to a class name the library doesn't export, failing every generation with "cannot import name 'VoiceStudio'". The class keeps its library name, and a guard test now pins it. (#1420)
- Running from source no longer dies at startup when a database migration is pending. Alembic resolved the migrations folder relative to wherever the app was launched from — fine from the repo root, fatal from the desktop shell (`tauri dev`), which reported "Path doesn't exist: backend/migrations" and stopped. The path is now anchored to the repo, wherever you start it. (#1420)
- The first generation after startup no longer stalls or 500s while the model is still loading. A cold load reached from a worker thread waited on a lock owned by a different event loop, which either errored outright or deadlocked until the job was abandoned. (#1417)
- The voice-design model on Apple Silicon works again. Its description was being dropped before it reached the engine, so every generation failed with a raw 400 no matter what you typed. (#1405)
- The first-run setup screen no longer times out while it waits for you. Taking more than two minutes to choose an install location, region or mirror made the app declare "Setup failed", and Retry landed back on the same screen with the same clock — so a first install could never be completed. (#1376)
- Transcription on an NVIDIA machine whose cuDNN 8 libraries are missing no longer kills the backend outright. The app checks the library before picking a transcription engine and falls back to PyTorch Whisper, instead of handing off to a component that aborts the process with no error and restarts into the same crash. (#1371)
- The dictation model picker now tells the truth about download size. Every one of the seven models was wrong: Parakeet TDT v3, the recommended default, said 180 MB and actually downloads 670 MB, while the small low-RAM fallbacks were advertised as three times bigger than they are. (#1398)
- Dictation with the 0.6B Parakeet models is steadier under load — they now decode on more threads (still capped by your CPU, still overridable with `OMNIVOICE_SHERPA_ASR_THREADS`). The small models are unchanged. (#1398)
- The dictation hotkey no longer leaves a blank dark square stuck on your desktop. A press that arrived while the pill was re-arming was dropped, and the window it had already opened had nothing in it and no way to close it. (#1398)
- The blank dark square is gone for good: the dictation window could mistake itself for the main window when its shell wasn't ready yet, and once it did, nothing in the app could close it again. It now learns which window it is before any of its code runs. (#1398)
- Dictation is more reliable to trigger: the hotkey listener no longer briefly detaches every time the pill changes state, so a press is never silently lost. (#1398)
- An auto-captured crash report now keeps the error that actually caused the crash. Python prints a chained traceback oldest-first, so trimming the log to its newest end kept the generic wrapper and cut the real cause — the reports that needed the detail most were the ones that arrived without it. (#1376)
- Text ending in punctuation no longer wastes a whole synthesis pass on it. A chunk boundary could leave a trailing fragment with nothing speakable in it, which the engine renders as nothing at all. (#1330)
- A take that is missing part of your text now says so instead of coming back quietly short. When the engine renders a sentence to nothing, the app names the missing text and suggests re-generating — until now the only way to notice was to read along. (#1330)
- A long render on modest hardware is no longer abandoned as "too heavy for the available compute" while it is visibly working. A generate that keeps finishing chunks now extends its own deadline (bounded), the way a model download already could; one that stops producing anything still fails on time. (#1338, #1348, #1391)
- A backend that dies while loading its own Python dependencies is no longer reported as a memory problem. The crash notice now says the environment is incomplete and points at "Clean & Retry", instead of sending users to flush a model that had nothing to do with it. (#1282, #1376)
- A UI whose API requests land on the wrong host — a rehosted frontend, or a reverse proxy with no API route — no longer echoes that host's raw 404 page as the error. It now says the responding server is not a VoiceStudio backend and points at the Backend URL setting and the proxy route. (#1385)
- Building the GGUF engine from source produced a binary that died on its very first spawn ("libggml.so.0: cannot open shared object file") — the build script deleted the shared libraries it had just linked against. It now ships them next to the binary on every platform, and the backend puts that folder on the loader path — thanks @vanderlpp! (#1348)
- The GGUF engine's hard 120-second per-render kill switch — which was reaping legitimate CPU-only renders mid-synthesis — is now 600s, tunable via `OMNIVOICE_GGUF_GENERATE_TIMEOUT_S`, and the timeout error names that setting — thanks @vanderlpp! (#1348)
- Every subprocess TTS engine would have turned a stereo render into noise: the mono downmix always averaged axis 0, which is time rather than channels for channels-last audio. Unreachable today since every engine returns mono, fixed in all five before it isn't. (#1328)
- First-run wizard: the Continue button and the Hugging Face token box were pushed below the window with no way to scroll to them — a layout container grew to the full model list's height, defeating every scroll clamp inside it. The pinned row now stays on screen at every UI scale, with the model list scrolling under it. (#1382, #1383)
- Dubbing the same video twice no longer ties the second job's cloned voices to the first job's files — deleting the older dub from history was silently turning the newer one's single-segment regens into a default voice. (#1331)
- ...and deleting a dub whose files an existing saved dub still renders from now keeps those files on disk (the history entry still disappears) — protecting dubs created before this fix, whose references already cross directories. (#1331)
- An unclean previous shutdown is no longer announced as a crash: the notice says what it actually knows, names the benign causes (sleep, force-quit, a stopped VM), and the one-click bug report is only offered when there is evidence to put in it — an empty report helps nobody. (#1375)
- A first-use generate no longer fails at 300s while its model is still downloading: the download's own progress heartbeats now extend the generation budget (bounded), so a slow connection isn't reported as too-slow hardware. A job that goes silent still dies at the original deadline. (#1367)
- A generation that hits its time limit now says so, instead of "an error VoiceStudio doesn't recognize" followed by an empty `TimeoutError:`. It names the likely causes and the setting that raises the limit. (#1368)
- The "transformers install is incomplete" advice now names torchvision — the package whose version mismatch actually produces that error — and points at the pinned reinstall that repairs it, instead of a reinstall that left the broken package untouched. (#1376, #1357)
- A model download cut off mid-request is no longer reported as a broken transformers install — reinstalling could never have fixed a dropped connection. (#1347)
- A TLS connection cut during generation is explained as the dropped download it is, instead of falling through as an unrecognized error carrying `_ssl.c:1016`. (#1335)
- Windows "paging file is too small" no longer suggests the Flush button, which cannot help. It now names the virtual-memory setting to change, and says plainly that it is not a network problem. (#1334)
- A port conflict that resolves itself while the backend is dying no longer reports a bare "Backend died (exit code 1)" — the conflict is named even when the other process has already let the port go. (#1364, #1223)
- Fresh installs failing to import `transformers.HiggsAudioV2TokenizerModel` with "RuntimeError: operator torchvision::nms does not exist" are fixed by pinning `torchvision==0.23.0` to match `torch 2.8.0` — thanks @HanzlahCh! (#1358, #1357)
- ...and that pin now actually reaches Colab and Docker: both install with `uv pip install`, which ignores the pyproject setting the pin lived in, so the torch trio could still drift apart. It is passed explicitly now. (#1357)
- Every RTX 40-series card (4060–4090) was declared unsupported and silently run on the CPU. The compatibility gate demanded an exact `sm_89` match, but PyTorch ships `sm_86` kernels that already cover Ada. (#1285)
- Under-provisioned hardware is now flagged **before** a synthesis starts instead of after the full compute budget expires. (#1240, #1246, #1248, #1277, #1283, #1284)
- Long text on a CPU-only machine gets the same warning up front. (#1260, #1299)
- A crash inside the compute stack no longer blames VRAM: a segfault or Windows access violation now points at the GPU driver or an incomplete model download. (#1275, #1293)
- ffmpeg failures report the failure instead of ffmpeg's build configuration. (#1309)
- A cut TLS connection is explained in words rather than as `_ssl.c:1016`. (#1301)
-`torch.compile` is skipped when the torch library path contains a space, instead of failing in the linker on every load. (#1266)
- macOS Preview updates work again — the updater bundle had been colliding with itself since early July. (#1281)
- macOS Preview updates no longer fail signature verification — the preview manifest is rebuilt from the published assets and every signature in it is verified against the file it points at — thanks @Pinkers01! (#1327)
- A dub whose transcription stream is cut by a reverse proxy now says so, instead of blaming the ASR model. (#1317)
- The dev backend going quiet under `--reload` is named as auto-reload rather than reported as a crash. (#1261)
- Building from source: `bun run desktop-prod:run`, documented as a re-launch, wiped the app's data every time — voice profiles, projects and outputs included. It now keeps them — thanks @Kakuzen93! (#1333)
- Audiobook: a chapter that fails to render now shows the reason in the chapter list and in the final error, instead of a red row whose cause existed only in the backend log — thanks @Reaksa-Cambodia! (#1321)
- Audiobook: an engine that stops without producing audio no longer stalls the render forever with no error and no timeout. (#1321)
- Linux AppImage: a permanently blank window on Mesa 26.1+ hosts (Arch/CachyOS and other rolling distros) — the bundled WebKit ran against a newer system Mesa than it was built for, and no environment variable could help because the failure precedes every rendering flag; the launcher now lets a newer system WebKitGTK take precedence — thanks @rvasilev and @HannaLovvold! (#1258, #1244)
- Linux AppImage: `OMNIVOICE_PREFER_SYSTEM_WEBKIT=1` forces your own WebKitGTK for hosts where its version can't be read automatically (no `pkg-config`), and `=0` forces the bundled one (#1258)
- Dubbing: the transcription overlay said "Transcribing with Whisper…" whatever ASR engine was actually running — it now names the stage, in all 21 languages — thanks @paoloantinori! (#1352)
- Error messages no longer arrive with terminal colour codes spliced into the sentence (`download: ^[[0;31mERROR:^[[0m …`) — every surfaced failure is cleaned now, whichever tool produced it. (#1344)
- Linux AppImage: recording failed with "No microphone found" on hosts whose GStreamer is newer than the build runner's, even with a verified-healthy audio stack — your own GStreamer now takes precedence, and the plugin cache is app-private so it can neither be confused by nor corrupt the one other apps use — thanks @Kakuzen93! (#1333)
- Linux AppImage: that GStreamer preference actually takes effect — the check guarding it could never pass, so it had been silently doing nothing. (#1333)
- A TTS job abandoned for exceeding its compute budget now records where it was actually stuck, so a hang stops being reported as a machine that is merely too slow. (#1338, #1329, #1348)
- Translation through LM Studio works. The built-in model name was the placeholder `local-model`, which LM Studio rejects because it serves whatever you have loaded — VoiceStudio now asks it, and a 404 from a local server names the models that ARE loaded instead of telling you to check a URL that was fine — thanks @biga73! (#1332)
- Generation that silently dropped the end of the input now says so. When an engine returns no audio for part of the text the result sounds clean and is simply short, so the only way to notice was to read along; the backend log now names the sentences that produced nothing. (#1330)
- Dubbing: a re-rendered line that quietly came back in a default voice instead of the cloned one now says why in the backend log — the clone clips are extracted per job and a saved dub outlives them, so regenerating after cleanup loses the reference with no error. (#1331)
- RTX 40-series GPUs are used again instead of being sent to the CPU. (#1289)
- Apple Silicon: transcription no longer needs a system ffmpeg, as the docs always said — thanks @gambletan! (#1436)
- A failed audiobook chapter says why, instead of turning red and saying nothing. (#1325)
### CI
- Windows CI falls back to a static ffmpeg build when the Chocolatey feed is down, instead of failing the run. (#1542)
- The stdio wire protocol every engine sidecar speaks is now tested once across all nine of them, instead of against a single engine — a bug in any one sidecar's copy gets caught — thanks @paoloantinori! (#1408)
- Windows smoke tests stopped silently passing a broken ffmpeg install, and every smoke leg is now budgeted for a cold dependency install. (#1290)
- Test suites no longer leak config paths or model-manager shutdown state into one another, which had been failing unrelated pull requests. (#1269)
- The nightly preview build stopped refusing to publish its own healthy updater manifest when the macOS legs finished a few minutes ahead of the slowest one — Preview-channel users were silently left without new builds.
## [0.4.2] — 2026-07-28
**Highlights**
- The update prompt is a small toast with buttons, not a screenful of release notes
- Installing an update no longer throws away work that is still running
- Quitting the app mid-generate stops reporting itself as a crash
- A half-downloaded model repairs itself instead of dead-ending
- "Dismiss" no longer reads as "terminate an employee" in five languages
### Changed
- An available update now announces itself as a toast with **Install and restart**, **What's new** and **Later**, instead of only a dot beside the version number. The release notes stay in Settings → Updates, where there is room for them — a version's notes are the whole changelog section, and rendering them inline is what made the old prompt fill the screen (#1272)
### Fixed
- Installing an update no longer relaunches the app while work is running. The check only knew about dub synthesis, so a restart could silently discard an upload, a transcription, a translation, an export or a standalone synth — and two overlapping synths used to cancel each other's protection. Install is now greyed out while anything is in flight (#1272)
- A half-downloaded model now repairs itself instead of failing with a raw 500. The automatic repair recognised only one of the two ways the loader reports missing weights, so an interrupted download whose subfolder failed to load got neither the repair nor a hint about what to do (#1273)
- Quitting the app with a generate queued reported "500 Internal Server Error: model load skipped: backend shutting down" and offered to file a bug for it. A shutdown is not a fault: the backend now answers 503 with what to do, and no bug report is offered for it (#1276)
- Dub history: clearing a large history while a render was running could still resurrect the deleted job — which markers survived depended on the process hash seed, and an oversized purge could discard a live one (#1252)
- German, Japanese, Russian and both Chinese locales rendered "Dismiss" as the employment sense — "terminate an employee" — on close buttons (#1272)
- The "wait for the current job to finish" message named dubbing specifically, though it now covers uploads, transcription, translation, exports and synthesis; reworded across all 21 languages (#1272)
## [0.4.1] — 2026-07-27
**Highlights**
- AMD GPUs are used again — every ROCm host was silently running on the CPU
- Two synth failures that used to say "an error VoiceStudio doesn't recognize" now say what actually went wrong
- A dub URL ingest that fails on a disk problem now says which folder and why
- A broken audio dependency no longer takes the whole backend down at startup
- A GPU too small for the chosen engine now says so up front, not after a five-minute wait
- A port conflict now says so, instead of "Backend died (exit code 1)"
- A model download that dies at 90% now resumes instead of failing the install
- First run: Continue and the Hugging Face token box no longer sit under the status bar
- macOS 12 (Monterey): the app launches again instead of dying on startup
- Exporting a voice or a dub no longer fails when the name isn't spelled in Latin letters
- Two more failures that used to arrive as raw OS text now say what to do about them
- Unload works on every model the panel offers it for, and a language the active engine can't speak says so
- Deleting a dub no longer un-deletes itself when the job it belonged to finishes
### Changed
- First run: the status bar (Logs, version, Sponsors) appears once you reach the studio, instead of overlaying the setup steps (#1241)
### Added
-`OMNIVOICE_MCP_ALLOWED_HOSTS` — comma-separated host patterns (e.g. `host.containers.internal:*,192.168.1.5:*`) that extend the MCP SDK's DNS-rebinding allowlist, so AI agents running in Docker containers or on other machines can reach the `/mcp` endpoint. The SDK default is localhost-only; this env var is opt-in (#1249)
### Docs
- Linux install: a new section for the Mesa 26.1+ blank window, stating plainly that no environment variable works and why (#1258)
- Docker: ROCm section explains that `torch.cuda.is_available() == True` isn't proof the app is on the GPU, and notes the `--group-add` needed for `/dev/kfd` on rootless hosts (#1228)
### Fixed
- Deleting a dub while it was still importing crashed the import with the toast `ingest: 'mgw39lx3'` — a dict key and nothing else — and the delete could then be undone by the job's own pending write, in history or mid-render; both are fixed, and no failure can present itself as a bare value again — thanks @dustmaker124-ui! (#1252, #1253)
- macOS 12 (Monterey): the app threw on startup and never started the backend — it called a Safari 16 method on the WebView that macOS ships. It launches and works now; some styling still needs a newer WebView (tracked in #1268) — thanks @singhrahat! (#1245)
- Settings → Engines: Unload failed with `400 Unknown model id: engine:kittentts` on any in-process engine — the panel offered the button for ids the backend never accepted; the warm dictation model had the same gap — thanks @JavaxmI! (#1247)
- Picking a language the active engine can't speak recited 23 codes without saying which engine refused or that switching engine was the fix — thanks @pulananave! (#1257)
- A YouTube import that failed as "DRM protected" and then worked on a manual retry now escalates the player client automatically, and a genuinely undownloadable video says so — thanks @gysahlgreene! (#1254)
- Exporting a voice profile, persona, dub, subtitle or stem whose name is Chinese, Japanese, Korean, Cyrillic, Greek, Hebrew or emoji failed with a `'latin-1' codec` 500 — every download endpoint now sends the name correctly, and browsers get the real one back — thanks @zvxzdx! (#1262)
- A synth that failed because ffmpeg/ffprobe wasn't on the system path said "an error VoiceStudio doesn't recognize"; it now names the media engine and points at Settings → Audio tools, and the app's own copy is published on PATH so dependencies find it in the first place — thanks @Heuvelsma! (#1256)
- Windows "The paging file is too small" arrived as a bare 500; it now explains that this is a virtual-memory setting, not full RAM, and gives the steps to raise it — thanks @trankeny545-sudo! (#1251)
- AMD/ROCm: every ROCm host was silently force-routed to the CPU — the compatibility gate compared a CUDA `sm_` tag against a ROCm build's `gfx` list, which can never match — thanks @simmessa! (#1228)
- AMD/ROCm: `torch.compile` was disabled on all AMD hosts by the same mismatched comparison (#1228)
- AMD/ROCm: `HSA_OVERRIDE_GFX_VERSION` is auto-set only when your card genuinely needs it and the remap target exists in your build; gfx1150/gfx1151 (Strix Point/Halo) added to the map (#1228)
- Windows blocking an engine file (Smart App Control, WDAC, or AppLocker) is now named, with the fix for personal and managed PCs — thanks @AdityaHemantBhat! (#1227)
- A failed audio write (`LibsndfileError: System error.`) now names the target file, its folder's writability and the drive's free space — thanks @morozov28061995-boop! (#1221)
- Dub URL ingest: a disk error now names the job folder, its writability and the drive's free space, instead of pointing at the system TEMP folder it never used — thanks @dustmaker124-ui! (#1225)
- Dub URL ingest fails immediately when the job folder is missing or unwritable, instead of starting a download that can only fail (#1225)
- The backend no longer dies at startup when transformers can't resolve its audio tokenizer (a missing or mismatched torchaudio, common on Google Colab) — it starts, and the error arrives with a repair hint — thanks @Navdeep-Chauhan-777! (#1229)
- Importing `omnivoice.utils.*` no longer drags in torch, torchaudio, transformers and the full model definition — thanks @Navdeep-Chauhan-777! (#1229)
- Colab notebook: the install cell now catches a broken environment with the real error, instead of a 5-minute health timeout two cells later — thanks @Navdeep-Chauhan-777! (#1229)
- A GPU with less VRAM than the chosen engine needs is flagged in Settings → Engines before you generate, instead of showing a clean green "accelerated" until the job times out — thanks @AdityaHemantBhat and @beingavais! (#1226, #1222)
- A generation timeout now names your actual card and its VRAM and recommends a lighter engine (#1226, #1222)
- First run: Continue and the Hugging Face token box rendered underneath the status bar, off the bottom of the window — the wizard laid itself out against the viewport instead of its own frame (#1241)
- A busy port 3900 now reports a port conflict instead of "Backend died (exit code 1)", in every language — thanks @xipb14! (#1223)
- The app verifies it actually freed the port before starting the backend, rather than assuming the kill worked (#1223)
- A model download truncated near the end is now retried and resumed instead of aborting the whole install — thanks @Reaksa-Cambodia! (#1224)
- Engine first-use downloads (VoxCPM2, MOSS-TTS-Nano) retry transient network failures instead of failing the load outright (#1224)
- A backend killed by the OS mid-stream now leaves a low-memory trail in the crash report (#1224)
### CI
- The AppImage launcher's unit tests now run in CI — they existed but nothing executed them (#1258)
## [0.4.0] — 2026-07-21
@@ -42,7 +483,7 @@ The bundled TTS model package (`pyproject.toml`) is versioned independently.
- First-run consent question for the existing opt-in analytics (two equal buttons, skip = no)
- First run: when the app auto-opens in a non-English system language, a one-time, dismissible banner offers to switch the UI to English — shown only until you pick a language, never for English systems (#1215)
- Source builds carry the publishable analytics token and get the same first-run consent ask as installers; opt-in events now note the install channel (installer / docker / source) — thanks @agudmund! (#1193)
- Official Google Colab notebook (`notebooks/OmniVoice_Studio_Colab.ipynb`) — full app + API feature tour on a free T4
- Official Google Colab notebook (`notebooks/VoiceStudio_Studio_Colab.ipynb`) — full app + API feature tour on a free T4
-`OMNIVOICE_TRUSTED_NETWORKS` — comma-separated CIDRs exempted from the consumption auth gates (share PIN / API key / dictation WS); admin routes stay loopback-only (#1170)
- Info/warn system notifications are dismissible and stay dismissed across restarts; error-level notices can't be dismissed, and the unclean-shutdown notice is now acknowledged server-side — thanks @agudmund! (#1192)
@@ -72,7 +513,7 @@ The bundled TTS model package (`pyproject.toml`) is versioned independently.
- Voice-clone trimmer: the preview now plays exactly the selected region on variable-bitrate clips (it had drifted off on VBR/mis-reported-duration files by playing the original file on a different timeline) (#1210)
- Screen readers now announce the hidden file-picker buttons (batch add, gallery import, stories import) (#1211)
- Audiobook language selection now reaches the backend — the client had dropped the `language` field, and the tab's Markup reference now lists the reaction tags (`[laughter]`, `[sigh]`, …) that already work there (#1208)
- A backend that fails to start now says why — exit code and error output, with actionable hints and a one-click report — instead of the evidence-free "Can't reach the local OmniVoice backend" (#1177)
- A backend that fails to start now says why — exit code and error output, with actionable hints and a one-click report — instead of the evidence-free "Can't reach the local VoiceStudio backend" (#1177)
- Generation no longer crawls on CPU after a cancelled or failed dub: the TTS model is moved back to the GPU on every exit path, and each generation now verifies its own placement (#1191)
- A generation queued behind a busy one no longer spends its timeout waiting: the budget starts when a GPU worker picks the job up, so a queued request can't be failed as "too heavy for the available compute" without having run (#1190)
- One request's timeout no longer cancels unrelated jobs already waiting in the GPU queue (#1190)
@@ -136,11 +577,11 @@ The dubbing release. Dubbed videos stop sounding like a compromise: the music ke
- **In-app analytics is now wired end to end — and still off until you say yes.** The frontend analytics SDK is only ever started *after* you opt in (Settings → Privacy), never at app launch, so a default install still transmits nothing. Two of the SDK's defaults are explicitly disabled because they would be actively harmful here: **autocapture**, which sends the text content of whatever you click — in this app, the script you are about to synthesise, your voice names, your file names — and **session recording**, which records the screen. Events carry metadata only, filtered through the same allowlist as the backend, so no future change can leak your content by adding a field.
- **Opt-in analytics — off by default, and it can't lie to you.** OmniVoice still sends **nothing** out of the box: no accounts, no telemetry, no phone-home, and your text, audio, voices, and projects never leave your machine regardless of what you choose. There is now one toggle in **Settings → Privacy → "Help improve OmniVoice"**, **off unless you turn it on**. If you do, it sends anonymous usage stats — which engine and language you used, how long a generation took, how many *characters* the text had (a number, not the text), and the *type* of any error. It never sends the text you type, your audio, your file names, your voice names, or anything identifying you. That isn't a promise in a policy: an **allowlist in the code** drops any property that isn't on it, so a future change can't leak content by accident, and crash tracebacks are deliberately **not** auto-captured (they can carry file paths and tokens). Turning it off stops everything immediately. Builds from source have no analytics destination at all and don't even show the toggle.
- **Opt-in analytics — off by default, and it can't lie to you.** VoiceStudio still sends **nothing** out of the box: no accounts, no telemetry, no phone-home, and your text, audio, voices, and projects never leave your machine regardless of what you choose. There is now one toggle in **Settings → Privacy → "Help improve VoiceStudio"**, **off unless you turn it on**. If you do, it sends anonymous usage stats — which engine and language you used, how long a generation took, how many *characters* the text had (a number, not the text), and the *type* of any error. It never sends the text you type, your audio, your file names, your voice names, or anything identifying you. That isn't a promise in a policy: an **allowlist in the code** drops any property that isn't on it, so a future change can't leak content by accident, and crash tracebacks are deliberately **not** auto-captured (they can carry file paths and tokens). Turning it off stops everything immediately. Builds from source have no analytics destination at all and don't even show the toggle.
- **Settings → Usage: see what you've made, counted entirely on your own machine.** Takes generated, audio produced, voices, days used, and a breakdown by mode and language — all computed from the history already in your own database. It collects nothing new, stores nothing new, and transmits nothing anywhere, no matter what you've chosen under Settings → Privacy: this panel is *yours*, it works with analytics switched off, and it never phones home. If you want to know what you've been making, the answer shouldn't require sending it to anyone.
- **The memory panel now tells the whole truth.** `Settings → Models` (and `GET /model/loaded`) used to report only the OmniVoice core model — a resident second engine like MLX-Audio, or the warm dictation model, was invisible, so the memory picture looked ~2 GB lighter than reality. It now lists every resident model (in-process engines and the dictation ASR included) and adds a system block with free/total RAM (and free VRAM on a dedicated GPU) plus a low-memory warning. On top of that, a load that starts while memory is already low leaves a breadcrumb in the backend log, so a subsequent out-of-memory kill points at the load that tipped it instead of dying silently. Advisory only — nothing is blocked (the OS can reclaim memory, and refusing a load on an estimate would brick machines that would actually cope). Tune the threshold with `OMNIVOICE_LOW_MEMORY_HEADROOM_GB` (default 2).
- **The memory panel now tells the whole truth.** `Settings → Models` (and `GET /model/loaded`) used to report only the VoiceStudio core model — a resident second engine like MLX-Audio, or the warm dictation model, was invisible, so the memory picture looked ~2 GB lighter than reality. It now lists every resident model (in-process engines and the dictation ASR included) and adds a system block with free/total RAM (and free VRAM on a dedicated GPU) plus a low-memory warning. On top of that, a load that starts while memory is already low leaves a breadcrumb in the backend log, so a subsequent out-of-memory kill points at the load that tipped it instead of dying silently. Advisory only — nothing is blocked (the OS can reclaim memory, and refusing a load on an estimate would brick machines that would actually cope). Tune the threshold with `OMNIVOICE_LOW_MEMORY_HEADROOM_GB` (default 2).
### Fixed
@@ -176,13 +617,13 @@ The dubbing release. Dubbed videos stop sounding like a compromise: the music ke
- **Clicking "Install" on an engine right after opening Settings could silently do nothing.** When the Engines page opens, it quietly checks each installable engine for an in-flight install to re-attach to. If you clicked Install while that check was still running, your click's status update was thrown away to keep requests orderly — so no progress panel, no error, no retry, just nothing (the install itself *did* start in the background; the UI simply never showed it). Fast machines usually won the race, which is why this mostly showed up as a once-in-a-while CI test failure. The Install click's update can no longer be dropped — it politely waits out the startup check instead. (#1131)
- **Cloning re-listened to your reference clip for every chunk of text — now it listens once.** Before OmniVoice can speak in a cloned voice it has to *encode* the reference clip you gave it. That encode was being redone on **every single piece of the job**: long text is split into chunks, and each chunk re-encoded the same reference from scratch; so did each `[pause]` span, and each chapter segment of an audiobook. A cache to prevent exactly this was written a while back — and then quietly bypassed on the path the Generate button actually takes, so for several releases it only ever helped the API. It's now wired into every path. Measured on an M2, one encode costs **0.4 seconds**, so this gives back roughly **3–4 seconds on a long paragraph** and **about a minute on a 166-segment audiobook** — the same voice, the same audio out, just without listening to your reference clip 166 times. As a bonus, `preprocess_prompt` on the OpenAI-compatible endpoint now actually does something; it was being accepted and silently discarded. (#1130)
- **Cloning re-listened to your reference clip for every chunk of text — now it listens once.** Before VoiceStudio can speak in a cloned voice it has to *encode* the reference clip you gave it. That encode was being redone on **every single piece of the job**: long text is split into chunks, and each chunk re-encoded the same reference from scratch; so did each `[pause]` span, and each chapter segment of an audiobook. A cache to prevent exactly this was written a while back — and then quietly bypassed on the path the Generate button actually takes, so for several releases it only ever helped the API. It's now wired into every path. Measured on an M2, one encode costs **0.4 seconds**, so this gives back roughly **3–4 seconds on a long paragraph** and **about a minute on a 166-segment audiobook** — the same voice, the same audio out, just without listening to your reference clip 166 times. As a bonus, `preprocess_prompt` on the OpenAI-compatible endpoint now actually does something; it was being accepted and silently discarded. (#1130)
- **Dubbing loaded the 3 GB voice model, threw it away, and loaded it again.** Before transcribing, a dub pulled the entire voice model into memory to read a single setting off it — one that is empty unless you've turned on an off-by-default flag. So it loaded ~3 GB, found nothing, released it a moment later (on Apple Silicon that's a *full* unload), and then had to load the very same model again from cold when it was time to actually speak. Every dub paid for that round trip — roughly **8 seconds**, plus the memory churn on exactly the 16 GB machines where memory pressure is the problem. It now only loads the model when there's genuinely something to read. (#1130)
- **The backend stopped holding the voice model hostage while it loads the transcription model — the 16 GB dub crash.** Before transcribing a dub, OmniVoice makes room by setting the TTS model aside. On an NVIDIA GPU it did. On **Apple Silicon it did nothing at all** — the code bailed out with "unified memory doesn't benefit from offloading". That was half right and wholly wrong: on unified memory, *moving* a model to "CPU" frees nothing (it's the same RAM), but the answer is to **release** it, not to skip the step. So a 16 GB Mac went into a dub holding the ~3 GB voice model, then loaded a ~3 GB transcription model on top of it — measured here: 4.1 GB free before, and large-v3 needs 3 — and the operating system killed the backend mid-transcription. That's the dub that "dropped before emitting any segments". The voice model is now genuinely released when memory is tight (and left alone when it isn't, so a roomy machine pays nothing); it reloads by itself on your next generation. (#1119)
- **The backend stopped holding the voice model hostage while it loads the transcription model — the 16 GB dub crash.** Before transcribing a dub, VoiceStudio makes room by setting the TTS model aside. On an NVIDIA GPU it did. On **Apple Silicon it did nothing at all** — the code bailed out with "unified memory doesn't benefit from offloading". That was half right and wholly wrong: on unified memory, *moving* a model to "CPU" frees nothing (it's the same RAM), but the answer is to **release** it, not to skip the step. So a 16 GB Mac went into a dub holding the ~3 GB voice model, then loaded a ~3 GB transcription model on top of it — measured here: 4.1 GB free before, and large-v3 needs 3 — and the operating system killed the backend mid-transcription. That's the dub that "dropped before emitting any segments". The voice model is now genuinely released when memory is tight (and left alone when it isn't, so a roomy machine pays nothing); it reloads by itself on your next generation. (#1119)
- **Dubbing on a Mac was transcribing on the CPU — with the GPU sitting idle.** OmniVoice picked its transcription engine without ever looking at your hardware: WhisperX won every time, and WhisperX (like faster-whisper) is built on CTranslate2, which **has no Metal backend at all**. So on Apple Silicon it ran whisper-large-v3 on the *processor*. Measured on an M2, one 30-second chunk: **90 seconds on the CPU versus 20 on the GPU** — slower than realtime, which turned a 16-minute video into a ~48-minute transcribe that looked exactly like a hang. Worse, the slowest chunks blew past the 2-minute per-chunk timeout and were **abandoned entirely**, so the transcript came back with pieces missing and the app blamed a "VRAM-starved GPU" — on a machine that has no VRAM. Apple Silicon now uses MLX, which runs the **same** whisper-large-v3 on the GPU, roughly **4x faster**. Word timing is unchanged: the wav2vec2 forced alignment that lip-sync depends on (±10-30 ms, versus Whisper's own ±100-300 ms) is layered on top exactly as before. Same model, same alignment, four times the speed. Nothing changes on NVIDIA or Linux, where WhisperX already used the GPU. (#1127)
- **Dubbing on a Mac was transcribing on the CPU — with the GPU sitting idle.** VoiceStudio picked its transcription engine without ever looking at your hardware: WhisperX won every time, and WhisperX (like faster-whisper) is built on CTranslate2, which **has no Metal backend at all**. So on Apple Silicon it ran whisper-large-v3 on the *processor*. Measured on an M2, one 30-second chunk: **90 seconds on the CPU versus 20 on the GPU** — slower than realtime, which turned a 16-minute video into a ~48-minute transcribe that looked exactly like a hang. Worse, the slowest chunks blew past the 2-minute per-chunk timeout and were **abandoned entirely**, so the transcript came back with pieces missing and the app blamed a "VRAM-starved GPU" — on a machine that has no VRAM. Apple Silicon now uses MLX, which runs the **same** whisper-large-v3 on the GPU, roughly **4x faster**. Word timing is unchanged: the wav2vec2 forced alignment that lip-sync depends on (±10-30 ms, versus Whisper's own ±100-300 ms) is layered on top exactly as before. Same model, same alignment, four times the speed. Nothing changes on NVIDIA or Linux, where WhisperX already used the GPU. (#1127)
- **The transcribe screen invented its ETA, and the number was a fiction.** It assumed transcription runs at ~20x realtime — true on a fast GPU — and predicted from the video's length alone. For a 16-minute video it promised **56 seconds**. Once reality overran the guess it pinned itself at "~0s remaining" with the bar frozen at 95%, and sat there for the next three quarters of an hour. It now reports the *real* fraction of the audio transcribed and extrapolates the time left from the speed it can actually observe — so it is right on a fast machine and a slow one, and says nothing at all until it has something true to say. (#1127)
@@ -196,59 +637,59 @@ The memory release. The reason the app kept saying "Can't reach the local backen
### Added
- **Factory reset grew up: Settings → Storage → "Reset & remove".** It used to do exactly one thing — clear your UI preferences — while the only other option was deleting everything and starting over. Between "forget my theme" and "wipe the machine" sat every reset people actually needed. Now there are four one-click tiers — **UI preferences**, **all settings**, **downloaded assets & models**, and **everything OmniVoice did** — plus a per-item checklist if you want to drop just the model weights, just a wedged sidecar engine, or just the history. Every option shows its **real size on disk before you commit**, and the number on the button is exactly what gets freed. Deleting voices, projects or audio asks you to type `DELETE`; nothing irreversible happens on a single click. "Everything" deliberately stops short of the Python environment, so you land on a working first-run screen rather than a rebuild — the app stops its engine, deletes, and starts it again for you. On macOS and Linux the model cache is the **shared** Hugging Face cache, so it's its own checkbox and says so; on Windows and portable installs it's OmniVoice's own, and the app doesn't pretend otherwise.
- **Factory reset grew up: Settings → Storage → "Reset & remove".** It used to do exactly one thing — clear your UI preferences — while the only other option was deleting everything and starting over. Between "forget my theme" and "wipe the machine" sat every reset people actually needed. Now there are four one-click tiers — **UI preferences**, **all settings**, **downloaded assets & models**, and **everything VoiceStudio did** — plus a per-item checklist if you want to drop just the model weights, just a wedged sidecar engine, or just the history. Every option shows its **real size on disk before you commit**, and the number on the button is exactly what gets freed. Deleting voices, projects or audio asks you to type `DELETE`; nothing irreversible happens on a single click. "Everything" deliberately stops short of the Python environment, so you land on a working first-run screen rather than a rebuild — the app stops its engine, deletes, and starts it again for you. On macOS and Linux the model cache is the **shared** Hugging Face cache, so it's its own checkbox and says so; on Windows and portable installs it's VoiceStudio's own, and the app doesn't pretend otherwise.
- **The Storage panels got a design.** "Remove all data" and "Reset & remove" listed folders as a flat run of text, so a 7.5 GB model cache and a 391-byte config file carried exactly the same visual weight — the one thing you actually wanted to see (where the space went) was the one thing you couldn't. Every row now has an icon, a dimmed path, and a **proportional bar showing its share of what will be freed**, so the big one looks big. The shared Hugging Face cache is promoted out of the confirm dialog into its own "Optional" row with a checkbox, so ticking it moves the running total **in front of you** instead of springing a different number on you at the point of no return, and the dialog now lists exactly what is about to go.
### Fixed
- **Switching TTS engines no longer stacks their models in memory.** Using a second engine in a session (or a per-request engine override) loaded its model *on top of* the first one's, because the OmniVoice core model and the other engines live in two separate caches that never coordinated — measured on a 16 GB M2, an `omnivoice` → `mlx-audio` switch left the machine holding both (footprint 3.9 GB → 4.3 GB, the ~2.8 GB core never freed). That accumulation is a direct contributor to the memory pressure behind the "Can't reach the local backend" OOM deaths. Now only one TTS engine's model stays resident: resolving an engine hands back every *other* resident engine first (the same `omnivoice → mlx-audio` switch now drops to ~1.5 GB). Steady-state single-engine use is unaffected; an A/B switch pays a re-load on the way back (~8 s for the OmniVoice core, ~1–2 s for the lighter engines). Opt out with `OMNIVOICE_SINGLE_ENGINE_RESIDENT=0` if you have RAM to keep several warm. Two underlying leaks are fixed as part of this: every in-process TTS engine's `unload()` now actually frees its model and empties the device cache (previously all but OmniVoice were silent no-ops), and `faster-whisper`'s `unload()` cleared the wrong attribute so its model was never released.
- **Switching TTS engines no longer stacks their models in memory.** Using a second engine in a session (or a per-request engine override) loaded its model *on top of* the first one's, because the VoiceStudio core model and the other engines live in two separate caches that never coordinated — measured on a 16 GB M2, an `omnivoice` → `mlx-audio` switch left the machine holding both (footprint 3.9 GB → 4.3 GB, the ~2.8 GB core never freed). That accumulation is a direct contributor to the memory pressure behind the "Can't reach the local backend" OOM deaths. Now only one TTS engine's model stays resident: resolving an engine hands back every *other* resident engine first (the same `omnivoice → mlx-audio` switch now drops to ~1.5 GB). Steady-state single-engine use is unaffected; an A/B switch pays a re-load on the way back (~8 s for the VoiceStudio core, ~1–2 s for the lighter engines). Opt out with `OMNIVOICE_SINGLE_ENGINE_RESIDENT=0` if you have RAM to keep several warm. Two underlying leaks are fixed as part of this: every in-process TTS engine's `unload()` now actually frees its model and empties the device cache (previously all but VoiceStudio were silent no-ops), and `faster-whisper`'s `unload()` cleared the wrong attribute so its model was never released.
- **The backend no longer sits on ~2 GB of idle dictation model — the real reason it was being killed on 16 GB Macs.** Four reports of *"Can't reach the local OmniVoice backend"* (#1076, #1092, #1093, #1101) all died at the same moment: during a generate, on a 16 GB machine. Measuring it showed the generate was never the problem — it costs about 116 MB. The problem was the **baseline**: the backend sat at **~6.2 GB even while idle**. The TTS model has always been unloaded after an idle timeout, but the speech-recognition model used for dictation never was — so once you dictated a single time, ~2 GB stayed resident for as long as the app ran. On a 16 GB Mac, that plus the app, macOS, and your other programs is enough for the system to run out of memory and kill the backend, which surfaced as the "can't reach the backend" error. Dictation's model now gets the same idle release the TTS model already had, handing that memory back. The only cost is a ~1.4-second re-warm on your next dictation after a long pause, and a live dictation session is pinned so nothing is ever unloaded mid-sentence.
- **The backend no longer sits on ~2 GB of idle dictation model — the real reason it was being killed on 16 GB Macs.** Four reports of *"Can't reach the local VoiceStudio backend"* (#1076, #1092, #1093, #1101) all died at the same moment: during a generate, on a 16 GB machine. Measuring it showed the generate was never the problem — it costs about 116 MB. The problem was the **baseline**: the backend sat at **~6.2 GB even while idle**. The TTS model has always been unloaded after an idle timeout, but the speech-recognition model used for dictation never was — so once you dictated a single time, ~2 GB stayed resident for as long as the app ran. On a 16 GB Mac, that plus the app, macOS, and your other programs is enough for the system to run out of memory and kill the backend, which surfaced as the "can't reach the backend" error. Dictation's model now gets the same idle release the TTS model already had, handing that memory back. The only cost is a ~1.4-second re-warm on your next dictation after a long pause, and a live dictation session is pinned so nothing is ever unloaded mid-sentence.
- **Folder sizes under 1 KB displayed as "0 KB".** The uninstall panel's `391 B` config folder rendered as `0 KB` — which reads as "nothing here" for a folder that very much exists. The Storage panels now share one byte formatter that can say `391 B`.
- **Some styling silently did nothing.** A handful of components referenced CSS custom properties that were never defined (`--chrome-fg-subtle`, `--chrome-bg-raised`, `--color-warning`). An undefined `var()` makes the whole declaration invalid, so the browser drops it and the element quietly inherits — the dimmed folder paths in the Storage panels weren't dimmed at all. Fixed in those panels, and a new guard (`frontend/src/test/cssTokens.test.js`) fails on any bare `var(--token)` in JSX that isn't defined in a stylesheet or documented as runtime-injected, so a typo can't ship as invisible styling again.
- **Uninstalling now removes the saved-environment file it used to leave behind.** OmniVoice keeps a small `~/.config/omnivoice/env` file (the model-cache location you chose, and any saved Hugging Face token). Every uninstall path — the in-app "Remove all data", `scripts/uninstall.sh`, and `scripts/uninstall.ps1` — walked right past it, so a later reinstall silently picked the *old* file back up and redirected its downloads to a location you may have long since deleted. All three now list and remove it (it's the same `~/.config/omnivoice` path on every OS, Windows included), and the per-platform tables in `docs/install/uninstall.md` document it.
- **Uninstalling now removes the saved-environment file it used to leave behind.** VoiceStudio keeps a small `~/.config/omnivoice/env` file (the model-cache location you chose, and any saved Hugging Face token). Every uninstall path — the in-app "Remove all data", `scripts/uninstall.sh`, and `scripts/uninstall.ps1` — walked right past it, so a later reinstall silently picked the *old* file back up and redirected its downloads to a location you may have long since deleted. All three now list and remove it (it's the same `~/.config/omnivoice` path on every OS, Windows included), and the per-platform tables in `docs/install/uninstall.md` document it.
- **Disk usage now counts installed sidecar engines instead of hiding them.** Settings → Storage measured engine venvs in `backend/engines` — the built-in engine *code*, which has no venvs — so a multi-GB IndexTTS-2 install (which actually lives in `DATA_DIR/engines/<id>`) was invisible in the engine row and quietly rolled into the data dir's "other" subtotal. The report now points at the real install location and sizes the **whole** install (venv + checkout + weights), counted once, so "IndexTTS-2 — 6.2 GB" shows up where you'd look for it.
## [0.3.20] — 2026-07-12
The follow-through release. v0.3.19 promised that "Can't reach the local OmniVoice backend" would stop firing while the backend was merely restarting — and then a user hit it anyway, on 0.3.19, because the fix had a race in it. That's closed properly here. Uninstalling also stopped being a thing only maintainers could do: it's now a button in the app, where the person who asked for it can actually reach it.
The follow-through release. v0.3.19 promised that "Can't reach the local VoiceStudio backend" would stop firing while the backend was merely restarting — and then a user hit it anyway, on 0.3.19, because the fix had a race in it. That's closed properly here. Uninstalling also stopped being a thing only maintainers could do: it's now a button in the app, where the person who asked for it can actually reach it.
### Added
- **Uninstall is now in the app: Settings → Storage → "Remove all data".** The v0.3.19 uninstaller was a *script* — which never reached the people who needed it, since anyone who installed the .dmg / .msi / AppImage has no repo to run it from (exactly the case in #1089). The app now lists every folder this install owns with its real size, deletes them behind a typed confirmation, and quits. The **downloaded model weights are a separate, opt-in checkbox**, because that's the standard Hugging Face cache shared with other AI tools on your machine — removing it can delete models OmniVoice never downloaded. Custom and portable install locations are honored, and nothing outside OmniVoice's own folders can be touched. The scripts now also ship as **release assets**, so you can clean up without launching the app at all. (#1089)
- **Uninstall is now in the app: Settings → Storage → "Remove all data".** The v0.3.19 uninstaller was a *script* — which never reached the people who needed it, since anyone who installed the .dmg / .msi / AppImage has no repo to run it from (exactly the case in #1089). The app now lists every folder this install owns with its real size, deletes them behind a typed confirmation, and quits. The **downloaded model weights are a separate, opt-in checkbox**, because that's the standard Hugging Face cache shared with other AI tools on your machine — removing it can delete models VoiceStudio never downloaded. Custom and portable install locations are honored, and nothing outside VoiceStudio's own folders can be touched. The scripts now also ship as **release assets**, so you can clean up without launching the app at all. (#1089)
### Fixed
- **"Can't reach the local OmniVoice backend" could still fire on 0.3.19 — the fix had a hole.** The app asks the desktop shell whether a start/restart is in progress before showing that error, but the shell learns of a dead backend from a **2-second poll**: when the backend dies mid-generation, the supervisor needs a moment to notice it, record the crash, and flip its state to "restarting". The app was asking **once**, ~3 seconds in — often still hearing "everything's fine" — and dead-ending on the generic toast anyway. A failed connection *contradicts* "everything's fine", so that answer is now treated as stale rather than authoritative: the app keeps retrying briefly, letting the shell catch up, which turns the failure into the "backend is restarting — hang tight" banner (and gives the crash report time to be written, so you get the real cause instead of a guess). A shell that has genuinely given up, or no shell at all, still errors immediately. (#1101)
- **"Can't reach the local VoiceStudio backend" could still fire on 0.3.19 — the fix had a hole.** The app asks the desktop shell whether a start/restart is in progress before showing that error, but the shell learns of a dead backend from a **2-second poll**: when the backend dies mid-generation, the supervisor needs a moment to notice it, record the crash, and flip its state to "restarting". The app was asking **once**, ~3 seconds in — often still hearing "everything's fine" — and dead-ending on the generic toast anyway. A failed connection *contradicts* "everything's fine", so that answer is now treated as stale rather than authoritative: the app keeps retrying briefly, letting the shell catch up, which turns the failure into the "backend is restarting — hang tight" banner (and gives the crash report time to be written, so you get the real cause instead of a guess). A shell that has genuinely given up, or no shell at all, still errors immediately. (#1101)
- **The uninstaller was leaving the backend's log folder behind on Linux and Windows.** It cleaned the app-data, config, and Python-env folders but missed where the backend actually writes `backend.log` / `backend_err.log` — `~/.local/state/OmniVoice` on Linux and `%LOCALAPPDATA%\OmniVoice\Logs` on Windows. Both the scripts and the documented path lists now cover them. (#1089)
## [0.3.19] — 2026-07-12
The honesty release. Every error in here was already *technically* true and practically useless — so this round went after the lies the app tells when something goes wrong. "Can't reach the local OmniVoice backend" no longer fires while the backend is simply still starting; a dead Hugging Face mirror no longer strands the setup wizard with advice it can't follow; and a dub that dies mid-transcription now names the actual cause instead of guessing at it. Alongside that: generated speech starts playing on the *first* chunk instead of the last, and there's finally a real uninstaller.
The honesty release. Every error in here was already *technically* true and practically useless — so this round went after the lies the app tells when something goes wrong. "Can't reach the local VoiceStudio backend" no longer fires while the backend is simply still starting; a dead Hugging Face mirror no longer strands the setup wizard with advice it can't follow; and a dub that dies mid-transcription now names the actual cause instead of guessing at it. Alongside that: generated speech starts playing on the *first* chunk instead of the last, and there's finally a real uninstaller.
### Added
- **Generated speech starts playing on the first chunk, instead of after the last one.** Long text is synthesized in chunks, but you used to sit through the entire render before hearing anything. The Studio now streams the preview: audio begins the moment the first chunk is ready and the rest arrives as it renders, so a long passage is audible in about the time the first sentence takes. The take saved to your history is **byte-identical** to the non-streaming render — streaming is a delivery channel, not a different synthesis path — and if a stream fails mid-flight the app falls back to the classic whole-file flow with nothing half-written to disk. (#1088)
- **A clean uninstaller + a straight answer to "where's my data?"** OmniVoice is fully local, so removing it is just deleting the folders it wrote — but until now users had to guess which ones. New `scripts/uninstall.sh` (macOS/Linux) and `scripts/uninstall.ps1` (Windows) find every OmniVoice folder — app data, the multi-GB managed Python env, config, logs, and (separately, because it's shared) the Hugging Face model cache — print each with its size as a **dry-run first**, and delete only on `--yes`. They honor your custom locations (`OMNIVOICE_DATA_DIR`, `HF_HOME`, portable mode) and never touch the app binary. The complete per-platform path list lives in the new `docs/install/uninstall.md`, linked from the README FAQ, SUPPORT, and troubleshooting. (#1089)
- **A clean uninstaller + a straight answer to "where's my data?"** VoiceStudio is fully local, so removing it is just deleting the folders it wrote — but until now users had to guess which ones. New `scripts/uninstall.sh` (macOS/Linux) and `scripts/uninstall.ps1` (Windows) find every VoiceStudio folder — app data, the multi-GB managed Python env, config, logs, and (separately, because it's shared) the Hugging Face model cache — print each with its size as a **dry-run first**, and delete only on `--yes`. They honor your custom locations (`OMNIVOICE_DATA_DIR`, `HF_HOME`, portable mode) and never touch the app binary. The complete per-platform path list lives in the new `docs/install/uninstall.md`, linked from the README FAQ, SUPPORT, and troubleshooting. (#1089)
### Fixed
- **A dub that dies mid-transcription now says what actually happened instead of guessing.** "Transcribe stream dropped before emitting any segments. Likely ASR backend failed to load" was a *guess* — and usually the wrong one. The backend is contract-bound to emit a terminal event on every stream even when it fails, so a stream that simply goes silent means the backend **process died underneath it** — on smaller GPUs, almost always a native out-of-memory abort while loading the ASR model on top of a still-resident TTS model. The app now consults the desktop shell's crash forensics and tells you that: the exit code, when it happened, a one-click "View crash details" with the captured error output, and the actual next step (free VRAM / pick a smaller ASR model) rather than "check the backend log". With no crash recorded, the original message still stands. (#1062)
- **"Can't reach the local OmniVoice backend" stopped crying wolf during startups and restarts.** A real backend start or auto-restart takes 10–20+ seconds (Python spawn plus the PyTorch import), but the app's transport retry only bridged ~3 seconds — every click inside that window dead-ended with the scary toast, over and over, even though the backend healed itself moments later. The app now asks the desktop shell whether a start/restart is actually in progress and simply waits for it (up to the shell's own 2-minute restart budget), and shows a single "backend is restarting — hang tight" banner with a "back — carrying on" confirmation — the reconnecting affordance the supervisor has promised since #567. A truly dead backend (or a non-desktop deployment) still errors promptly, and the crash notice keeps telling the honest story.
- **"Can't reach the local VoiceStudio backend" stopped crying wolf during startups and restarts.** A real backend start or auto-restart takes 10–20+ seconds (Python spawn plus the PyTorch import), but the app's transport retry only bridged ~3 seconds — every click inside that window dead-ended with the scary toast, over and over, even though the backend healed itself moments later. The app now asks the desktop shell whether a start/restart is actually in progress and simply waits for it (up to the shell's own 2-minute restart budget), and shows a single "backend is restarting — hang tight" banner with a "back — carrying on" confirmation — the reconnecting affordance the supervisor has promised since #567. A truly dead backend (or a non-desktop deployment) still errors promptly, and the crash notice keeps telling the honest story.
- **A dead Hugging Face mirror can no longer strand the first-run wizard.** When a model download failed because the *configured* mirror was unreachable, the error pointed at Settings — which first-run users can't open (the wizard gates the studio) — and falsely claimed the mirror setting only applies after a restart (downloads actually pick it up per call, immediately). Now the wizard shows the mirror quick-pick (including "Hugging Face (official)") right next to the failed download and retries it the moment you switch; the corrected hint says retry-first, restart only if it still fails. Two backend holes in the same flow are closed too: switching endpoints clears the "failed recently" retry cooldown (no more 429 on the immediate retry), and clearing to official also removes the legacy `hf_endpoint` pref, which used to silently keep the dead mirror in effect.
### Changed
- **The first-run wizard shows the app version in its masthead**, next to the OmniVoiceStudio title — so setup-time screenshots and bug reports identify the build at a glance (the install splash already did).
- **The first-run wizard shows the app version in its masthead**, next to the VoiceStudio title — so setup-time screenshots and bug reports identify the build at a glance (the install splash already did).
- **Repo root decluttered.** Retired the finished planning archives (`.planning/`, `specs/`), the pre-React design mockups (`design/`), the legacy research dir (`research/`), and stale third-party agent rules (`.agents/`) — ~110 files of process noise gone; everything stays in git history, and the four load-bearing engine decision docs moved to `docs/adr/`. Contributor-facing only; the app is unchanged.
@@ -326,7 +767,6 @@ The quality release. Three long-standing frictions got structural fixes: **regen
The cold-start release. Three "why is this broken on my machine" mysteries got solved at their roots: **first generations stop dying at 300 seconds** (the timeout was counting the model download as generation time — @moduvoice measured it on a Tesla T4: 0% GPU for the full window), **updates stop deleting engines you installed yourself** (the updater's dependency sync removed anything not in the app's lockfile — including things our own UI told you to install), and **the "slower than v0.3.5" regression is found and fixed** (clone profiles without a transcript were silently re-running a full Whisper transcription on every single generate). Also: Clear History is back, auto-played audio is finally stoppable, @stronghamjji hardened the dub pipeline against wedged transcribes, and @shakib30's community Colab notebook is now the linked no-GPU path. Thank you all.
### Added
- **Agent Skills: `npx skills add debpalash/omnivoice-studio`.** Two installable [skills](https://skills.sh) now ship in the repo — `omnivoice` teaches any AI agent (Claude Code, Cursor, Codex, …) to speak and transcribe through your local install via the OpenAI-compatible API, including your cloned voices; `oss-maintainer` packages the maintainer methodology this project is run with.
@@ -362,7 +802,7 @@ The community-fixes release. Two contributors didn't just report bugs — they d
### Added
- **A path to Qwen3-ASR today: generic OpenAI-compatible transcription.** The direct integration is still blocked on `transformers>=5.13` stabilizing upstream, but a community member proposed splitting the work — add a backend that talks to any OpenAI-compatible transcription server right now. Point OmniVoice at a self-hosted Qwen3-ASR/FunASR/SenseVoice server, or OpenAI's own API, configured in Settings → Models. No install; audio does leave your machine to whichever server you configure, unlike every other ASR engine. (#877)
- **A path to Qwen3-ASR today: generic OpenAI-compatible transcription.** The direct integration is still blocked on `transformers>=5.13` stabilizing upstream, but a community member proposed splitting the work — add a backend that talks to any OpenAI-compatible transcription server right now. Point VoiceStudio at a self-hosted Qwen3-ASR/FunASR/SenseVoice server, or OpenAI's own API, configured in Settings → Models. No install; audio does leave your machine to whichever server you configure, unlike every other ASR engine. (#877)
### Fixed
@@ -378,7 +818,7 @@ The community-fixes release. Two contributors didn't just report bugs — they d
### Changed
- **Removed the donate heart from the nav rail.** Support OmniVoice is still one click away from Settings and the Contact page.
- **Removed the donate heart from the nav rail.** Support VoiceStudio is still one click away from Settings and the Contact page.
### CI
@@ -395,14 +835,14 @@ A community-issue sweep — nineteen open reports triaged in one pass, most fixe
### Fixed
- **First-run no longer dead-ends behind restricted networks (e.g. China).** The system check probed hardcoded huggingface.co, and any failure locked the Continue button — users behind the Great Firewall were stuck on the very first screen, even when they had already configured a working mirror. The check now probes the Hugging Face endpoint actually in effect, an unreachable endpoint is a warning instead of a blocker (models already on disk keep working offline), and when huggingface.co is blocked but the hf-mirror.com community mirror answers, the wizard says so and offers a one-click mirror switch right on the check screen — no restart needed. (#984)
- **Installs behind a corporate or antivirus TLS-inspecting proxy no longer fail with a raw SSL error.** `SSLV3_ALERT_HANDSHAKE_FAILURE` happens when a proxy re-signs HTTPS traffic with a root CA your OS trusts but Python's bundled certificate list doesn't — a different failure mode from the network-blocking case above. OmniVoice now trusts your OS's certificate store directly, which should resolve the handshake outright rather than just explain it better. (#976)
- **The loaded-models panel now says when a resident model is not your active engine.** Switching TTS engines keeps the previous model in VRAM (so switching back is instant) — but the panel showed it with no context, so "OmniVoice TTS — 1.9 GB" after selecting VoxCPM2 looked like the selection was ignored. A field report confirmed the confusion. Resident-but-inactive models are now tagged "not active — safe to unload", and the API self-describes each entry's engine. (#985)
- **Installs behind a corporate or antivirus TLS-inspecting proxy no longer fail with a raw SSL error.** `SSLV3_ALERT_HANDSHAKE_FAILURE` happens when a proxy re-signs HTTPS traffic with a root CA your OS trusts but Python's bundled certificate list doesn't — a different failure mode from the network-blocking case above. VoiceStudio now trusts your OS's certificate store directly, which should resolve the handshake outright rather than just explain it better. (#976)
- **The loaded-models panel now says when a resident model is not your active engine.** Switching TTS engines keeps the previous model in VRAM (so switching back is instant) — but the panel showed it with no context, so "VoiceStudio TTS — 1.9 GB" after selecting VoxCPM2 looked like the selection was ignored. A field report confirmed the confusion. Resident-but-inactive models are now tagged "not active — safe to unload", and the API self-describes each entry's engine. (#985)
- **Voices no longer ship with a hidden echo.** Every non-raw synthesis was getting a small room reverb baked in by the mastering pre-stage — on top of whatever effect preset you chose, so even "Podcast" (which promises *no reverb*) had some, and Cinematic/Warm got it twice. A field report ("a lot of echo/reverb on some of the voices") led straight to it. The mastering stage is now highpass + compressor only; reverb happens only when a preset explicitly declares it. Also documented: cloned voices reproduce the reference clip's room acoustics — dry, close-mic references clone cleanest. (#986)
- **Your engine selection now actually applies to Dubbing and Batch TTS.** Both hardcoded OmniVoice regardless of what was picked in Settings → Engines — pick VoxCPM2, dub anyway with OmniVoice, no error. Both now resolve the active engine up front; an engine that can't clone from reference audio (KittenTTS, Sherpa-ONNX, Supertonic 3 — fixed preset voices only) fails the job immediately with a clear message naming which engines do support it, instead of silently substituting OmniVoice or mis-cloning every speaker into one voice. Batch only requires cloning when a specific voice is pinned — an unpinned batch job runs on any engine. (#987)
- **Your engine selection now actually applies to Dubbing and Batch TTS.** Both hardcoded VoiceStudio regardless of what was picked in Settings → Engines — pick VoxCPM2, dub anyway with VoiceStudio, no error. Both now resolve the active engine up front; an engine that can't clone from reference audio (KittenTTS, Sherpa-ONNX, Supertonic 3 — fixed preset voices only) fails the job immediately with a clear message naming which engines do support it, instead of silently substituting VoiceStudio or mis-cloning every speaker into one voice. Batch only requires cloning when a specific voice is pinned — an unpinned batch job runs on any engine. (#987)
- **AMD ROCm torch install no longer silently falls back to CPU.** A community member (Kaihui-AMD) diagnosed it precisely: the ROCm wheel index we pointed at tops out at PyTorch 2.5.1, but the app pins `torch==2.8.0` — the reinstall was unsatisfiable and silently kept the default CUDA build, which runs on CPU on an AMD GPU. Bumped the default index to one that actually carries the pinned version. (#972)
- **mlx-audio no longer crashes on unsupported languages.** Selecting a language like Dutch, Spanish, or Portuguese with mlx-audio's Kokoro model crashed with a raw, unreadable internal-details dump instead of a real error — the code was guessing an ISO language code by truncating the language name, which only worked by coincidence for a few languages. Unsupported languages now fail cleanly with a message naming what's actually supported, and no engine can leak a raw crash-internals dump into an error message again. (#977)
- **The voice-design panel no longer crashes on certain saved voice profiles.** A genuine regression: an earlier translation fix accidentally introduced a crash when a saved design profile's data was incomplete (possible from an older app version or a partial save). Fixed at every layer — the render no longer crashes, both places that restore saved data complete it first, and profiles can no longer be *saved* with incomplete data in the first place. (#983)
- **Windows: the dictation pill no longer steals focus.** Pressing the dictation shortcut activated the pill window, which meant the auto-paste landed back in OmniVoice instead of whatever app you were dictating into, and the pill would get stuck on screen. Precisely diagnosed by a community reporter; fixed to match how this already worked on macOS. (#982)
- **Windows: the dictation pill no longer steals focus.** Pressing the dictation shortcut activated the pill window, which meant the auto-paste landed back in VoiceStudio instead of whatever app you were dictating into, and the pill would get stuck on screen. Precisely diagnosed by a community reporter; fixed to match how this already worked on macOS. (#982)
- **The nemo-parakeet ASR engine's install hint no longer breaks your backend.** Following the in-app "pip install nemo_toolkit[asr]" instruction silently downgraded core packages your backend needs to start — the install reported success, and the breakage only showed up on the next restart. The hint now says plainly that this isn't safe to install into the shared environment. (#974)
- **A stuck generate now tells you the actual fix.** When a job times out from GPU/VRAM contention, the error explained why but never mentioned Flush/Unload — the one action that actually resolves it, and one the sibling ASR-timeout error already recommended. (#939)
@@ -452,15 +892,15 @@ The dictation release — and a deep reliability pass driven by live-testing the
### Added
- **Sponsor OmniVoice.** A new `SPONSORS.md` (tiers, logo guidelines, how to sponsor), a README Sponsors section, and an in-app Sponsors area (Support page + a footer link) let people back the project — with a one-click "Become a sponsor" that opens a structured GitHub issue form, no account or token needed. Sponsorship is a thank-you, not a paywall: OmniVoice stays free and AGPL-3.0. (#923, #924)
- **OpenAPI reference in Settings.** A new Settings → OpenAPI page embeds an interactive Scalar reference for OmniVoice's local backend API, with a one-click footer button. Fully local — Scalar is bundled, not loaded from a CDN, and phones home to nothing. (#928)
- **Sponsor VoiceStudio.** A new `SPONSORS.md` (tiers, logo guidelines, how to sponsor), a README Sponsors section, and an in-app Sponsors area (Support page + a footer link) let people back the project — with a one-click "Become a sponsor" that opens a structured GitHub issue form, no account or token needed. Sponsorship is a thank-you, not a paywall: VoiceStudio stays free and AGPL-3.0. (#923, #924)
- **OpenAPI reference in Settings.** A new Settings → OpenAPI page embeds an interactive Scalar reference for VoiceStudio's local backend API, with a one-click footer button. Fully local — Scalar is bundled, not loaded from a CDN, and phones home to nothing. (#928)
- **Engine Self-test.** The Engines matrix gains a "Self-test" button for in-process TTS engines that runs a tiny real synthesis and reports duration + sample rate — proving an engine actually makes audio, not just imports — plus a copy-paste `export OMNIVOICE_*_DIR=…` setup line for opt-in engines right in the "Why unavailable?" panel. (#930)
- **One canonical HuggingFace-token store + incomplete-download visibility.** The Model Store token field now saves to and is cleared from the same encrypted store as Settings → Credentials (no more two-stores split), and a truncated model cache shows an "incomplete · N MB" state with one-click Repair and Delete instead of masquerading as "not installed". (#927)
- **Launchpad, reimagined as a deck of cards.** The seven feature cards now fan out with animated waveform faces in each card's accent color; hover or keyboard-focus any card and it comes forward while the rest tuck underneath, and the layout stays usable down to the minimum window size. (#904)
- **See exactly what OmniVoice keeps on disk — and get warned before space runs out.** Settings → Storage shows real usage for the model cache (with your largest models), app data, engine environments and temp files, plus a free-space gauge and low-disk / near-full-volume warnings with one-click paths to open folders or reclaim space. (#906)
- **See exactly what VoiceStudio keeps on disk — and get warned before space runs out.** Settings → Storage shows real usage for the model cache (with your largest models), app data, engine environments and temp files, plus a free-space gauge and low-disk / near-full-volume warnings with one-click paths to open folders or reclaim space. (#906)
- **A "What's new" changelog reader in Settings → Updates.** The available update's real release notes now render in-app, alongside an offline changelog viewer and a one-time "what's new" note after each update. (#909)
- **Route each AI feature to its own LLM — or switch it off.** A new Settings → LLM Skills panel lists every LLM-powered capability (Cinematic/Autofit translation, slot fitting, glossary auto-extract, direction parsing, dictation cleanup) with a per-skill toggle and provider picker, so sensitive work can stay on a local model while heavier jobs use a remote one. Disabled skills fall back to the exact non-LLM behavior. (#912)
- **A small thank-you moment, done right.** After a successful export, dub, audiobook, or batch run, OmniVoice may — rarely — show a friendly, dismissible note by the footer heart about supporting development: never more than once a session, at most every 7 days, never for brand-new users, with a permanent "don't ask again". The logs bar also gained an icon and the footer icons now share one size. (#898)
- **A small thank-you moment, done right.** After a successful export, dub, audiobook, or batch run, VoiceStudio may — rarely — show a friendly, dismissible note by the footer heart about supporting development: never more than once a session, at most every 7 days, never for brand-new users, with a permanent "don't ask again". The logs bar also gained an icon and the footer icons now share one size. (#898)
- **Dictation, rebuilt.** The dictation pill now shows a live waveform the moment the mic opens, streams words as you speak with real download/loading progress on first use, and finishes what you say in about half a second of silence instead of two-and-a-half. Transcripts come out properly capitalized and punctuated. Text insertion is now honest and safe: your clipboard is preserved and restored, failures show what to do (including a one-click jump to macOS Accessibility settings when permission is missing) instead of a false "Pasted", and Esc cancels cleanly at any point. The dictation model also pre-warms in the background after launch, so the first press of the hotkey no longer sits on a cold model load.
@@ -469,7 +909,7 @@ The dictation release — and a deep reliability pass driven by live-testing the
### Changed
- **A "Get in touch" page that actually guides you.** The Contact page is now clearly-labelled cards (report a bug, request a feature, get community help, support the project, report a security issue) with a sentence each on when to use them, instead of a flat link list. (#925)
- **Release titles are version-first.** GitHub's release-list sidebar truncates the title, so "OmniVoiceStudio v0.3.8" hid the version; releases are now named "vX.Y.Z — OmniVoiceStudio" so the version is always visible. (#922)
- **Release titles are version-first.** GitHub's release-list sidebar truncates the title, so "VoiceStudio v0.3.8" hid the version; releases are now named "vX.Y.Z — VoiceStudio" so the version is always visible. (#922)
- **Launchpad feature cards now fill the window.** The seven cards (Voice Clone, Voice Design, Video Dubbing, Stories, Audiobook, Voice Gallery, Transcripts) span the full content width on a maximized display instead of a fixed ~780px fan, and reflow responsively (7→3→1 columns) down to the 900×600 minimum — driven by the shell's own width, keeping the animated card faces, hover/keyboard-focus raise, and reduced-motion fallback. (#915)
- **LLM Providers settings, de-confused.** The old inline "LLM endpoint" box in Translation is gone — LLM Providers is now the one place that owns it. Fields pinned by an environment variable are shown disabled with an explainer instead of silently reverting, the make-active button explains when a provider is env-pinned, and the Cloudflare Account ID is remembered and editable. (#907)
- **Intel Macs: honestly unsupported for the local backend.** PyTorch no longer ships Intel-Mac builds, so the backend cannot run there; instead of a cryptic dependency error, Intel users now get a clear explanation up front (with the remote-backend option), and the README/docs say so plainly. (#889, #891)
@@ -499,7 +939,7 @@ The dictation release — and a deep reliability pass driven by live-testing the
- **Parakeet TDT transcription now works without an NVIDIA GPU.** The `nemo-parakeet` ASR engine (parakeet-tdt-0.6b-v3, 25 languages, word timestamps) was hard-gated behind CUDA — but a live measurement on an Apple Silicon M2 shows it transcribing at ~10× realtime *on CPU*, roughly 20× faster than the default whisper-large-v3 on the same machine at equal accuracy. The false GPU gate is removed, so Mac and CPU-only users can now pick the dramatically faster engine in Settings → Engines.
- **8 GB GPUs: voice-clone/dub transcription no longer kills the backend.** On cards where the TTS model already held most of the VRAM (e.g. RTX 4060 Ti 8 GB), loading whisper `large-v3` in float16 for a reference-clip or dub transcription died as a *native* CUDA out-of-memory abort — the whole backend process vanished with no error logged, and the app showed "Can't reach the local OmniVoice backend." A new VRAM preflight re-checks free GPU memory right before the ASR load and steps down float16 → int8 → CPU instead of attempting a load that can't fit (opt-out: `OMNIVOICE_ASR_VRAM_PREFLIGHT=0`). (#723)
- **8 GB GPUs: voice-clone/dub transcription no longer kills the backend.** On cards where the TTS model already held most of the VRAM (e.g. RTX 4060 Ti 8 GB), loading whisper `large-v3` in float16 for a reference-clip or dub transcription died as a *native* CUDA out-of-memory abort — the whole backend process vanished with no error logged, and the app showed "Can't reach the local VoiceStudio backend." A new VRAM preflight re-checks free GPU memory right before the ASR load and steps down float16 → int8 → CPU instead of attempting a load that can't fit (opt-out: `OMNIVOICE_ASR_VRAM_PREFLIGHT=0`). (#723)
### CI
@@ -675,6 +1115,7 @@ across dub, generate, and design (a corrupt-binary failure no longer poses as
above Continue, framed around what it actually buys you — authenticated, faster,
more reliable downloads (higher rate limits, fewer stalls) — with a one-click
"get a free token" link. (#657, #669)
### Fixed
- **Bug reports redact more secrets and every Windows username casing.** The
@@ -721,9 +1162,9 @@ across dub, generate, and design (a corrupt-binary failure no longer poses as
- **Dubbing a video URL no longer fails with "ffmpeg is not installed."** yt-dlp
downloads video and audio as separate streams and muxes them with ffmpeg, but
it only looked on PATH — so on Windows (where OmniVoice's ffmpeg is a bundled
it only looked on PATH — so on Windows (where VoiceStudio's ffmpeg is a bundled
sidecar / `imageio-ffmpeg` binary off PATH) the merge aborted before the dub
could start. yt-dlp is now pointed at the same ffmpeg OmniVoice resolves. (#712)
could start. yt-dlp is now pointed at the same ffmpeg VoiceStudio resolves. (#712)
- **A synth that succeeded no longer 500s because of a history-logging hiccup.**
If the local database somehow missed schema init, recording the clip to
generation history failed with *"no such table: generation_history"* and
@@ -821,7 +1262,7 @@ across dub, generate, and design (a corrupt-binary failure no longer poses as
- **Dubbing a URL no longer fails with `[Errno 22] Invalid argument` on Windows.**
yt-dlp stamps the downloaded file's modified-time with the video's upload
date; an out-of-range/invalid timestamp makes the `os.utime` call raise
`[Errno 22]` and aborts the whole URL ingest. OmniVoice downloads to a throwaway
`[Errno 22]` and aborts the whole URL ingest. VoiceStudio downloads to a throwaway
file and never uses its mtime, so it now skips the stamp entirely
(`updatetime=False`). (#642)
@@ -918,7 +1359,7 @@ across dub, generate, and design (a corrupt-binary failure no longer poses as
field now owns its own height (starts taller, and the corner grip grows it
reliably on every platform). (#595)
- **An interrupted model download now self-repairs instead of dead-ending.**
When the OmniVoice TTS cache was missing weight shards (the usual aftermath of
When the VoiceStudio TTS cache was missing weight shards (the usual aftermath of
an interrupted first download), the next synthesize failed with a 500 and a
"delete the model and install it again" instruction — a manual dead-end. The
backend now detects the truncated-cache error on load, re-fetches just the
@@ -1196,7 +1637,7 @@ first-run, and install reliability all get a pass too.
- **Portable personas (`.ovsvoice`).** Export any voice as a self-contained,
fully-local persona bundle — identity, optional reference clip, consent
attestation, SPDX license, and a watermarked preview — and import it back into
another OmniVoice install. A privacy toggle ships a **preview-only** bundle so
another VoiceStudio install. A privacy toggle ships a **preview-only** bundle so
no raw recording of your voice has to travel. Verified-own-voice status can't
be forged by hand-editing a bundle (real recording + consent text + attestation
required). Legacy `.omnivoice` files still import. See
@@ -1208,7 +1649,7 @@ first-run, and install reliability all get a pass too.
active engine's GPU verdict (accelerated / caveat / CPU-fallback /
unavailable). At synth time every TTS entry point (`/generate`,
`/v1/audio/speech`) enforces the same routing — an engine that can't use this
host's GPU returns an explicit error or an `X-OmniVoice-Routing` header instead
host's GPU returns an explicit error or an `X-VoiceStudio-Routing` header instead
of silently dropping to CPU or dying mid-synth. (#21)
- **Diagnostics suite.** New self-check tooling for when something's wrong: a
`/system/diagnose` report (and matching backend `--diagnose`), a persistent
@@ -1250,7 +1691,7 @@ first-run, and install reliability all get a pass too.
crossfade removes the per-generation length cap, and a new sentence-by-sentence
`/ws/tts` streams audio as it's produced. An inline `[pause Nms]` marker
inserts measured silence in generated speech. (#276, #357, #358)
- **MCP server v1.** OmniVoice mounts an MCP server on `/mcp` (with a stdio shim
- **MCP server v1.** VoiceStudio mounts an MCP server on `/mcp` (with a stdio shim
and per-agent voice binding) so it can act as a local TTS/STT provider for
agentic pipelines. (#368)
- **Remote-backend access.** Point the desktop UI at a remote backend URL with a
@@ -1266,7 +1707,7 @@ first-run, and install reliability all get a pass too.
### Fixed
- **Transcription/dubbing failed when ffmpeg wasn't on `PATH`** (notably on
Windows). WhisperX now decodes audio through OmniVoice's own validated ffmpeg
Windows). WhisperX now decodes audio through VoiceStudio's own validated ffmpeg
binary instead of a bare `PATH` lookup, so ASR works without a system ffmpeg
install. (#479)
- **Translation defaulted the source language to English.** Dubbing/translation
@@ -1515,4 +1956,4 @@ Region selector, realtime download speed, retry buttons, recheck top-right, HF m
## Earlier releases
See [GitHub Releases](https://github.com/debpalash/OmniVoice-Studio/releases) for prior versions.
See [GitHub Releases](https://github.com/debpalash/VoiceStudio/releases) for prior versions.
OmniVoiceStudio is an open-source, fully-local ElevenLabs alternative — a desktop app for voice cloning, voice design, video dubbing, and real-time dictation across 646 languages. It runs entirely on the user's machine (CUDA/MPS/ROCm/CPU auto-detect), with no API keys, no accounts, and no cloud dependencies. It's an active beta with a growing user base who hit it with real workloads (50-video batches, multi-engine setups, edge-OS platforms) and report friction in GitHub Issues and Discord. The current version lives in `frontend/package.json` (the single source of truth — see Versioning); the latest stable tag is on the [Releases page](https://github.com/debpalash/OmniVoice-Studio/releases/latest). With `AUTO_VERSION_BUMP` off (the current owner setting), `main` holds at the released version between releases.
VoiceStudio is an open-source, fully-local ElevenLabs alternative — a desktop app for voice cloning, voice design, video dubbing, and real-time dictation across 646 languages. It runs entirely on the user's machine (CUDA/MPS/ROCm/CPU auto-detect), with no API keys, no accounts, and no cloud dependencies. It's an active beta with a growing user base who hit it with real workloads (50-video batches, multi-engine setups, edge-OS platforms) and report friction in GitHub Issues and Discord. The current version lives in `frontend/package.json` (the single source of truth — see Versioning); the latest stable tag is on the [Releases page](https://github.com/debpalash/VoiceStudio/releases/latest). With `AUTO_VERSION_BUMP` off (the current owner setting), `main` holds at the released version between releases.
**Core Value:** **A first-run that actually works.** A user who downloads the installer (or clones the repo) should reach a working voice-cloning or dubbing output without hitting a wall — and when something does go wrong, the error or docs should tell them exactly what to do.
@@ -13,7 +13,7 @@ Everything else (new engines, fancy features) is downstream of "the thing instal
- **Existing engine compatibility**: Users with already-installed engines (IndexTTS, CosyVoice, etc.) must not have to reinstall. Fixes touching engine code must be backward-compatible with on-disk model state.
- **Cross-platform parity**: Every fix must work on macOS (Apple Silicon + Intel), Windows (x64), and Linux (AppImage + deb). No platform-only regressions; the cross-platform bug bash (PR #51) is the baseline.
- **Default features must work on every platform (strict rule, 2026-05-20):** A feature that ships in default mode — out-of-the-box, no user customization, no opt-in toggle — must behave identically on macOS, Windows, and Linux. Platform-specific *implementation code* is allowed for OS APIs / shells / packaging, but the user-visible *default behavior* cannot diverge. Platform-only features (e.g., a macOS-only global shortcut, a Windows-only path picker) must go behind explicit user opt-in: Settings toggle, env var, or CLI flag. When a default doesn't work on a platform, that's a P0 bug — either fix it on the missing platform or move it behind opt-in. No third option.
- **Default features must work on every platform (strict rule, 2026-05-20):** A feature that ships in default mode — out-of-the-box, no user customization, no opt-in toggle — must behave identically on macOS, Windows, and Linux. Platform-specific *implementation code* is allowed for OS APIs / shells / packaging, but the user-visible *default behavior* cannot diverge. Platform-only features (e.g., a macOS-only global shortcut, a Windows-only path picker) must go behind explicit user opt-in: Settings toggle, env var, or CLI flag. When a default doesn't work on a platform, that's a P0 bug — either fix it on the missing platform or move it behind opt-in. No third option.**This rule governs BEHAVIOUR, not PERFORMANCE** (clarified 2026-07-30, council): hardware acceleration is expected to vary by host — CUDA, MPS, DirectML, Triton availability and `torch.compile` are all host-dependent by design, and reading the rule to forbid that would forbid GPU support itself. An optimization that is skipped where it cannot work (missing Triton, an arch the wheel lacks, a path its toolchain cannot link) is NOT a parity violation; a *feature* the user can see and use on one OS but not another is.
- **Backward-compatible project data**: Existing `omnivoice_data/` (user voices, projects, settings) must keep working without manual migration. Any DB schema change goes through alembic with a tested upgrade path.
- **Local-first guarantee preserved**: nothing leaves the machine without the user's **explicit yes**, and the app must remain fully functional with everything declined. Auto bug reporting is opt-in and submits only to GitHub Issues (prefilled-URL, from the user's own browser). Product analytics (owner-sanctioned 2026-07-16) is opt-in PostHog EU with a **first-run consent prompt** — two equal-weight Yes/No buttons, never default-on, skipping = off; consent-gated, allowlisted content-free metadata only (`backend/core/analytics.py`); every build — installer, Docker, and source alike (owner reversal 2026-07-20, #1193) — carries the in-repo publishable write-only token and shows the same consent ask, with env/baked token overriding it. No required cloud calls, accounts, or API keys.
- **Beta release cadence (no RC, no ceremony — strict rule, 2026-05-20):** the v0.3.x line has **no release candidates, no 48h soak, no formal release ceremony**. Every fix goes continuous-to-main; the owner tags a patch (`v0.3.Z`) from main whenever the current state is worth cutting. No `-rc` tags. No phased release. No `v0.4` deferrals while the v0.3.x line is open — every open issue and every open community PR gets absorbed into the v0.3.x line or explicitly declined. Users follow `main` for previews; users wanting stable stay on the latest tagged release. ROADMAP.md's Phase 6 "Release/Verify/Retro" entries are obsolete unless the user revives them.
@@ -42,7 +42,7 @@ For anything new: prefer what's already pinned in `pyproject.toml` / `frontend/p
- Docker: `ghcr.io/debpalash/omnivoice-studio:latest` = **main** (rolling preview); `:X.Y.Z` + `:X.Y` + `:stable` = tagged releases. `:latest` is the preview channel by design — stable users pin `:stable` or a version tag.
- Do not bump minor/major or invent RCs/codenames without the owner asking. No "defer to next version" labels — scope is absorbed or declined, never re-versioned.
**Docs-sync (hard rule, owner-set 2026-06-11):** any change that alters something these docs describe — README.md, CONTRIBUTING.md, SECURITY.md, SUPPORT.md, LICENSE, or `docs/**` (install flows, Docker tag semantics, platform support, versioning/release behavior, review process, supported versions) — must update those docs **in the same PR** as the change. If a doc impact is discovered after merge, the docs fix is the immediate next commit, not backlog. Stale docs are treated as bugs.
**Docs-sync (hard rule, owner-set 2026-06-11):** any change that alters something these docs describe — README.md, `.github/CONTRIBUTING.md`, `.github/SECURITY.md`, `.github/SUPPORT.md`, LICENSE, or `docs/**` (install flows, Docker tag semantics, platform support, versioning/release behavior, review process, supported versions) — must update those docs **in the same PR** as the change. If a doc impact is discovered after merge, the docs fix is the immediate next commit, not backlog. Stale docs are treated as bugs.
**Release notes / changelog (hard rule, owner-set 2026-06-16):** every tagged release gets a **high-quality, user-facing `## [X.Y.Z] — DATE` section in `CHANGELOG.md`** before (or in the same hour as) the tag — never the "Auto-generated release for vX.Y.Z…" fallback. `release.yml` extracts that section verbatim as the GitHub Release body (the `Extract CHANGELOG section for tag` step), so a missing/empty section ships a bare release. Quality bar (owner-restyled 2026-07-17, replaces the old bold-lead paragraphs): **quiet and scannable** — a short `**Highlights**` bullet list first (plain words, one line each), then `### Changed` / `### Added` / `### Docs` / `### Fixed` / `### License` / `### CI` subsections where each entry is a **single one-liner** with the `(#NNN)` issue/PR ref and contributor credit (`— thanks @user!`) where applicable. Written for users, grouped by theme, no multi-line paragraphs, **not** raw commit dumps. This applies to **preview builds too**: preview release notes summarize what's new on `main` since the last stable, in the same style. Workflow: as features merge, keep `## [Unreleased]` current; at release time rename it to the version + date. If a release was already cut with the fallback body, the next action is to backfill `CHANGELOG.md`**and**`gh release edit <tag>` the live body — not backlog.
@@ -66,7 +66,10 @@ Architecture not yet mapped. Follow existing patterns found in the codebase.
<!-- GSD:skills-start source:skills/ -->
## Project 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.
- `vite` — Vite configuration, assets, HMR, builds, and Vitest guidance.
- `fastapi-python` — FastAPI and Pydantic implementation patterns.
Canonical copies live under `.agents/skills/`; `skills-lock.json` pins their sources and hashes. Claude should follow these paths directly, avoiding cross-platform symlinks.
<!-- GSD:skills-end -->
<!-- GSD:workflow-start source:GSD defaults -->
@@ -76,7 +79,7 @@ Direct repo edits are authorized (owner decision, 2026-07-08). The GSD command g
**Harvest bot reviews before merging (rule, 2026-07-20):** CodeRabbit and Greptile auto-review every PR (tuned via `.coderabbit.yaml` / `greptile.json`, both fed CLAUDE.md as context). Before merging ANY PR — including your own — read their inline comments (`gh api repos/<owner>/<repo>/pulls/<N>/comments` filtered by bot login) and triage: fix real findings, ignore noise, never merge with an unread Critical/P1. They are the free first review pass; reserve deep agent-driven review for what they can't judge (architecture, cross-file semantics, product intent). Mechanical rules belong in deterministic CI tests, not in any AI reviewer.
**Token economy (owner directive, 2026-07-20):** lead with the outcome; one-line statuses; no narration, filler, or diff-restating. Read what CI/linters/review bots already computed instead of re-deriving it. Mechanical rules belong in deterministic tests (changelog style, locale parity, version lockstep, CJK — all in `tests/`), never in agent effort. Targeted tests while iterating; full suites only before landing. `AGENTS.md` carries this contract for all agents — keep the two in sync.
**Token economy (owner directive, 2026-07-20; tightened 2026-07-28):** default to the shortest response that fully answers — outlines and tables over prose, no preamble, no recap of work just done, no re-explaining what the diff shows; applies to every response, not just status updates. Lead with the outcome; one-line statuses; no narration, filler, or diff-restating. Read what CI/linters/review bots already computed instead of re-deriving it. Mechanical rules belong in deterministic tests (changelog style, locale parity, version lockstep, CJK — all in `tests/`), never in agent effort. Targeted tests while iterating; full suites only before landing. `AGENTS.md` carries this contract for all agents — keep the two in sync.
**Never accept a PR as-is (owner directive, 2026-07-20):** review findings — bot, agent, or human — get FIXED on the PR branch before merge (maintainer commits are fine and credit the contributor in the changelog); do not merge with known issues, do not merge-then-fix, do not leave findings as comments for someone else. Also merge current `main` into stale community branches before judging their CI, so the PR runs today's workflow gates (PR-green under an old workflow ≠ main-green).
<!-- GSD:workflow-end -->
@@ -89,3 +92,17 @@ Direct repo edits are authorized (owner decision, 2026-07-08). The GSD command g
> Profile not yet configured. Run `/gsd-profile-user` to generate your developer profile.
> This section is managed by `generate-claude-profile` -- do not edit manually.
<!-- GSD:profile-end -->
## Agent skills
### Issue tracker
GitHub Issues on `debpalash/VoiceStudio`, via the `gh` CLI. See `docs/agents/issue-tracker.md`.
### Triage labels
The five canonical roles, each label string equal to its name. See `docs/agents/triage-labels.md`.
### Domain docs
Single-context: `CONTEXT.md` + `docs/adr/` at the repo root. See `docs/agents/domain.md`.
<p>Real-time dictation, zero-shot voice cloning, and cinematic video dubbing — all on your desktop.<br/><b>No accounts. No API keys. No cloud.</b> Everything runs on your machine. Open-source, <b>646 languages.</b></p>
<img src="docs/media/0.5.0/quick-switch.gif" alt="Switching TTS engines from the VoiceStudio status bar" width="100%"/>
</div>
> **Your voice is the most personal data you have. So why rent it back from a cloud?** Every mainstream voice tool ships your audio to someone else's server and bills you monthly for the privilege. OmniVoice Studio flips that: clone, design, dub, and dictate on your own hardware — 646 languages, no meter running, nothing leaving your machine.
> [!WARNING]
> **Active beta.**Things may break between releases — for the newest fixes, run from source. Bug reports and PRs are very welcome: [open an issue](https://github.com/debpalash/OmniVoice-Studio/issues) or [join Discord](https://discord.gg/bzQavDfVV9).
> **Active beta.**Use the [latest release](https://github.com/debpalash/VoiceStudio/releases/latest) for stable work or `main` for current fixes. Report problems through [GitHub Issues](https://github.com/debpalash/VoiceStudio/issues).
<a id="screenshots"></a>
## At a glance
## 📸 See it in action
| | VoiceStudio |
|---|---|
| **Workflows** | Voice cloning and design, video dubbing, dictation, stories, audiobooks, batch generation |
| **Language catalogue** | 646 TTS languages; actual coverage and quality depend on the selected engine |
| **Engines** | 16 TTS · 11 ASR · switch in Model Catalogue or with <kbd>Ctrl</kbd>/<kbd>Cmd</kbd>+<kbd>E</kbd> |
| **Platforms** | macOS 13.3+ on Apple Silicon · Windows 10/11 x64 · Linux x86_64 with glibc 2.39+ |
| **Compute** | CUDA · Apple Silicon MPS/MLX · ROCm on Linux · CPU · optional remote workers |
| **Interfaces** | Desktop app · local REST/SSE/WebSocket API · OpenAI-compatible audio API · MCP Server |
| **Storage** | Voices, projects, settings, and outputs stay on the machine by default |
| **License** | AGPL-3.0; optional engines keep their own model licenses |
<sub>One-click model store — auto-detects your platform (CUDA / MPS / CPU) and recommends the right models.</sub>
</td>
</tr>
</table>
<a id="install"></a>
---
## Install
| Platform | Package | Guide |
|---|---|---|
| macOS 13.3+ | DMG, Apple Silicon | [Install on macOS](docs/install/macos.md) |
| Windows 10/11 | MSI, x64 | [Install on Windows](docs/install/windows.md) |
| Linux | AppImage, x86_64 with glibc 2.39+ | [Install on Linux](docs/install/linux.md) |
| Docker | CUDA, ROCm, or CPU; worker-only GPU profiles | [Run with Docker](docs/install/docker.md) |
Download packages from the [latest release](https://github.com/debpalash/VoiceStudio/releases/latest). First launch creates a managed Python environment and downloads the default model. Later launches reuse both.
> [!NOTE]
> On macOS, first launch needs a one-time right-click → **Open** approval. Intel Macs cannot run the local Python backend; use a [remote backend](docs/install/macos.md) instead.
### First voice
1. Launch VoiceStudio and open **Voice Cloning**.
2. Add a clean voice sample. Three seconds works; 5–15 seconds usually gives a better prompt.
3. Enter text, choose a language, then select **Generate**.
### Run from source
Install the [development prerequisites](.github/CONTRIBUTING.md#development-setup), then:
Feels slow? [docs/performance.md](docs/performance.md) covers where generation time actually goes, the tuning knobs, and the three classic causes of "it got slow".
Want breaths, laughter, pauses, whispering, or emotion in the output? [docs/expressive-speech.md](docs/expressive-speech.md) covers exactly what each engine can do today — and what's spec'd but not shipped yet.
> Coming from **[CorentinJ/Real-Time-Voice-Cloning](https://github.com/CorentinJ/Real-Time-Voice-Cloning)** (now archived)? There's a dedicated migration guide: [docs/migration/real-time-voice-cloning.md](docs/migration/real-time-voice-cloning.md).
<details>
<summary><b>🧰 Stuck? Self-checks, tokens & restricted networks</b></summary>
<br/>
Run the built-in self-check first — **Settings → About → "Run
self-check"** in the app, or `uv run python backend/main.py --diagnose` from
a checkout (`--deep` also test-loads the active engine). Then see
[docs/install/troubleshooting.md](docs/install/troubleshooting.md) for the
top 10 install errors. The in-app error UI deeplinks to those entries when
something breaks at runtime, and **Settings → About → "Save diagnostic
bundle"** packages scrubbed logs + the self-check report for bug reports.
For Hugging Face token setup, see
[docs/setup/huggingface-token.md](docs/setup/huggingface-token.md). For
diarization-specific gating, see
[docs/features/diarization.md](docs/features/diarization.md). For download
speed, the ⚡ fast-download (Xet) status, and restricted-network / mirror
options, see [docs/downloading-models.md](docs/downloading-models.md).
</details>
---
<a id="why-ovs"></a>
## 💡 Why OmniVoice?
ElevenLabs charges **$5–$330/mo** and processes your audio on their servers. OmniVoice Studio runs **on your hardware, with no usage limits.**
| **Python** | 3.10+ (managed by `uv`) | 3.11–3.12 |
| **GPU** | Optional — CPU works | NVIDIA CUDA · Apple Silicon MPS · AMD ROCm (Linux only) |
| **Disk** | 10 GB free | 20 GB+ SSD |
| **GPU** | Optional; CPU mode is supported | NVIDIA CUDA or Apple Silicon |
| **VRAM** | 4 GB when using a GPU | 8 GB+; large optional engines need more |
| **Python from source** | 3.11+ | 3.11–3.12 |
> [!TIP]
> On GPUs with **≤8 GB VRAM**, OmniVoice automatically offloads TTS to CPU during transcription — no config needed. A dedicated GPU is not required; the entire pipeline runs on CPU (just slower).
ROCm is Linux-only and opt-in. Windows AMD/Ryzen AI uses CPU. Systems with limited VRAM offload work to CPU when required. See [performance](docs/performance.md), [benchmarks](docs/benchmarks.md), and [engine disk usage](docs/engines/disk-usage.md).
> [!NOTE]
> **AMD GPUs:** ROCm acceleration is **Linux-only and opt-in** — pick **"AMD GPU (ROCm)"** on the first-run setup screen or set `OMNIVOICE_TORCH_VARIANT=rocm` ([docs/install/linux.md](docs/install/linux.md#amd-gpu-rocm)). In **Docker/Podman**, pull the dedicated ROCm image instead: `ghcr.io/debpalash/omnivoice-studio:rocm` ([docs/install/docker.md](docs/install/docker.md#pull-and-run-amd-gpu--rocm)). **On Windows, AMD GPUs (incl. Ryzen AI iGPUs) run CPU-only**: PyTorch has no Windows ROCm wheels, so Windows GPU acceleration is NVIDIA/CUDA-only ([docs/install/windows.md](docs/install/windows.md#gpu-support)).
<a id="engines"></a>
> [!IMPORTANT]
> **macOS Intel (x86_64) is unsupported for the local backend:** the app UI installs, but the Python backend cannot run because PyTorch no longer ships Intel-Mac wheels ([#889](https://github.com/debpalash/OmniVoice-Studio/issues/889)). Intel-Mac users can still point the UI at a remote backend on another machine — see [docs/install/macos.md](docs/install/macos.md).
## Engines
Engine support is capability-specific. Check cloning, language, platform, memory, and license before choosing one. Full setup guides: [docs/engines](docs/engines/README.md).
<a id="tts-engines"></a>
### 🗣️ TTS Engines
**14 engines, one picker.** OmniVoice (default, 600+ languages) is always available; seven more are opt-in and auto-detected (CosyVoice 3, GPT-SoVITS, VoxCPM2, MOSS-TTS-Nano, KittenTTS, MLX-Audio, Sherpa-ONNX), plus six lazy-installed heavyweights (IndexTTS 2, OmniVoice GGUF, Supertonic 3, MOSS-TTS-v1.5, dots.tts, Confucius4-TTS). Switch in **Settings → TTS Engine**; the choice applies everywhere synthesis happens.
<details>
<summary><b>📊 The full matrix</b> — 14 engines × platform × clone/instruct × license</summary>
<br/>
### Text to speech
| Engine | Languages | Clone | Instruct | Linux | macOS ARM | Windows | License |
> **CUDA** = GPU-accelerated · **MPS** = Apple Silicon Metal · **CPU** = runs everywhere, slower for large models · KittenTTS and MOSS-TTS-Nano run realtime on CPU · MLX-Audio is Apple Silicon only · ⚡ = lazy-registered (installed on first use)
>
> **Clone** matters beyond single-clip generation: Video Dubbing (and any Batch job with a pinned voice) needs reference-audio cloning to preserve speaker identity, so picking a Clone-less engine (KittenTTS, Sherpa-ONNX, Supertonic 3) as the active engine fails those jobs up front with an actionable message instead of silently falling back to OmniVoice.
>
> **MOSS-TTS-v1.5** (8B, ~16 GB), **dots.tts** (2B, ~9 GB), and **Confucius4-TTS** are heavyweight opt-ins that run in their own isolated venv from a local clone. None claims Apple-Silicon MPS (CPU on Macs); dots.tts has no Windows path; Confucius4 wants CUDA (CPU works, ~17× realtime). Details: [MOSS-TTS-v1.5](docs/engines/moss-tts-v15.md) · [dots.tts](docs/engines/dots-tts.md) · [Confucius4-TTS](docs/engines/confucius4-tts.md).
⚡ Installed or registered on demand.
</details>
¹ IndexTTS 2.5 requires a separate written Bilibili license above 100 million monthly active users or RMB 1 billion annual revenue. Review the [model license](https://huggingface.co/IndexTeam/IndexTTS-2.5/blob/main/LICENSE).
² PocketTTS shows its gated-access and CC-BY-4.0 terms before first use.
Clone-less engines cannot preserve a reference speaker in dubbing or pinned-voice batch jobs. VoiceStudio rejects those jobs instead of silently changing engines. Heavy engines have separate memory and platform limits; check their engine guide first.
<a id="asr-engines"></a>
### 🎧 ASR Engines
### Speech to text
**11 engines** — they power dictation, video dubbing, and subtitles. **WhisperX** is the cross-platform default (~100 languages, word-level timing); the rest are opt-in and auto-detected. Switch in **Settings → Engines**. Ten run fully on-device; the eleventh (OpenAI-compatible) is an optional remote client for Qwen3-ASR or any compatible server.
| **OpenAI-compatible** ⚠️ remote | `openai-compat-asr` | Server-dependent | Qwen3-ASR or another compatible endpoint; audio leaves the machine |
<details>
<summary><b>📊 The full lineup</b> — 11 engines, what each is best at, and compute-type notes</summary>
WhisperX and Faster-Whisper retry with `int8` when efficient `float16` is unavailable. Pin `ASR_COMPUTE_TYPE=int8` or `float32` only if automatic selection still fails.
<br/>
<a id="architecture"></a>
| Engine | `OMNIVOICE_ASR_BACKEND` | Languages | Best for |
| **Faster-Whisper** | `faster-whisper` | ~100 | Fast transcription on Linux / macOS / Windows (CTranslate2) |
| **Faster-Whisper (isolated)** | `faster-whisper-isolated` | ~100 | Same as Faster-Whisper but crash-isolated in a subprocess — an ASR crash won't take down the app |
| **PyTorch Whisper** | `pytorch-whisper` | ~100 | CUDA / CPU fallback via 🤗 Transformers (no cuDNN 8 needed) |
| **Parakeet TDT** | `nemo-parakeet` | English + 25 EU | SOTA accuracy at ~10× realtime even on CPU, auto language detection (NVIDIA NeMo, CUDA/CPU) |
| **Parakeet TDT v3 (MLX)** | `parakeet-mlx` | 25 EU | The Parakeet tier for Apple Silicon — TDT word timestamps, ~2 GB unified memory, dictation-grade speed on the GPU via MLX. Install the model from **Settings → Models** and dictation prefers it automatically when your system language is one of its 25 (European) languages; other languages (CJK, Arabic, …) keep the multilingual Whisper engine so dictation coverage never regresses. |
| **sherpa-onnx** (live dictation) | `sherpa-onnx-asr` | 25 EU + 90+ | Live, faster-than-real-time dictation — small streaming/offline ONNX models (Parakeet TDT v3/v2, streaming Zipformer & Paraformer, Whisper Tiny), CPU, identical on macOS / Windows / Linux. Picked per-model in **Settings → Voice**. |
| **OpenAI-compatible** ⚠️ remote | `openai-compat-asr` | Server-dependent | A path to **Qwen3-ASR** today (self-hosted server, no transformers wait), any OpenAI-compatible transcription endpoint, or OpenAI's own API — no install, configure + test the connection in **Settings → Engines** (ASR tab). Audio leaves your machine to whatever server you point it at; see [docs/engines/openai-compatible-asr.md](docs/engines/openai-compatible-asr.md). |
## Architecture
> Whisper-family engines cover ~100 languages; **FunASR / SenseVoice** adds an all-in-one multilingual path with built-in voice-activity detection and inline speaker diarization. **sherpa-onnx** powers the live dictation model picker — you talk and text appears as you speak. Every engine runs on-device — no API keys, no cloud.
> **GPU without efficient float16?** On older NVIDIA GPUs (Maxwell/Pascal, GTX 16xx) or after a CTranslate2/cuDNN mismatch, the CTranslate2 ASR engines (WhisperX, Faster-Whisper) can't run `float16` and OmniVoice automatically retries on `int8` — no config needed. If transcription still fails, pin the compute type with the `ASR_COMPUTE_TYPE` env var (escape hatch): `ASR_COMPUTE_TYPE=int8` (or `float32` for CPU). Set it to `int8` and restart the backend.
Already have a script, agent, or tool that speaks OpenAI's audio API? Point it at `http://localhost:3900/v1` — no key needed, no code changes. The backend ships a drop-in surface for the audio endpoints, wired to whichever TTS/ASR engine you have active (and yes, `voice` accepts your cloned voice-profile IDs).
- The desktop talks to a loopback-only backend on `localhost:3900`.
- Loopback API calls need no server key. Remote access requires a share PIN or API key.
- Remote workers and OpenAI-compatible ASR are opt-in. The UI identifies when audio leaves the machine.
- Analytics is off until consent. If enabled, it sends allowlisted, content-free usage metadata—not text, audio, file names, or projects.
| Endpoint | What it does |
<a id="api"></a>
## Local speech platform and OpenAI-compatible API
Point an OpenAI-compatible audio client at the local backend:
```diff
- base_url="https://api.openai.com/v1"
+ base_url="http://localhost:3900/v1"
```
| Endpoint | Purpose |
|---|---|
| `POST /v1/audio/speech` | TTS — text in; `mp3` / `wav` /`flac` /`opus` / `pcm` out. `tts-1` / `tts-1-hd` map to your active engine; OpenAI voice names (`alloy`, …) are accepted. |
| `POST /v1/audio/transcriptions` | STT — audio file in;`json`, `text`, `verbose_json`, `srt`, or `vtt` out. `whisper-1` maps to your active ASR engine. |
| `GET /v1/audio/voices` | OmniVoice extension — lists every voice profile and engine, so clients can discover your clones. |
```sh
curl http://localhost:3900/v1/audio/speech \
-H "Content-Type: application/json" \
-d '{"model": "tts-1", "voice": "alloy", "input": "Generated on my own hardware.", "response_format": "wav"}' \
--output speech.wav
```
| `POST /v1/audio/speech` | TTS to `mp3`, `opus`, `aac`,`flac`,`wav`, or `pcm`; select a profile with `voice` and an engine with `model` |
with client.audio.speech.with_streaming_response.create(
model="tts-1",
voice="<profile-id>",
input="Made on my own hardware.",
response_format="wav",
) as response:
response.stream_to_file("speech.wav")
```
Want the whole surface (100+ endpoints)? The full REST API reference is embedded in the app — **Settings → OpenAPI Reference** (Scalar-powered), or the `{}` button in the footer.
The bundled Rust control sidecar also lets Herdr, coding agents, VS Code,
desktop apps, and TUIs trigger the existing system-wide dictation flow or reuse
its safe native insertion. See the [speech platform guide](docs/speech-platform.md).
The full API reference is in **Settings → OpenAPI Reference**. For LAN,
Tailscale, or proxy access, read [API authentication](docs/api-auth.md) before
exposing the backend.
Calling the backend from **another machine** (LAN, Tailscale, behind a proxy)? It's loopback-only and unauthenticated by default; to reach it remotely you set a share PIN or an API key. [docs/api-auth.md](docs/api-auth.md) covers the exact headers, query params, `401`/`403`/`429` meanings, and the `OMNIVOICE_TRUSTED_NETWORKS` exemption.
### Agent skills
### 📓 Run on Google Colab
Install the VoiceStudio skills for Claude Code, Codex, Cursor, and other [skills.sh](https://skills.sh)-compatible agents:
[](https://colab.research.google.com/github/debpalash/OmniVoice-Studio/blob/main/notebooks/OmniVoice_Studio_Colab.ipynb)
No local GPU? The official notebook ([notebooks/OmniVoice_Studio_Colab.ipynb](notebooks/OmniVoice_Studio_Colab.ipynb)) boots the full app — web UI included — on a free Colab T4: it builds the frontend in-notebook, installs the backend with uv (reusing Colab's preinstalled CUDA PyTorch), and opens the UI through Colab's built-in port proxy. No third-party tunnels, no API keys. It then walks the whole feature surface as a guided API tour with inline playback: multilingual TTS, voice cloning and design, saved voice profiles, transcription, AI-watermark detection, the OpenAI-compatible API, a multi-voice story, a chaptered m4b audiobook, and a miniature video dub with vocal-isolation stems.
### 🤝 Agent Skills
Teach your AI agent (Claude Code, Cursor, Codex, …) to use OmniVoice with one command:
```sh
npx skills add debpalash/omnivoice-studio
```bash
npx skills add debpalash/VoiceStudio
```
Ships two [skills](https://skills.sh): **`omnivoice`** — speak and transcribe through your local install (including your cloned voices) from any agent, free and offline; and **`oss-maintainer`** — the maintainer methodology this project is run with, for anyone running their own OSS project with an agent.
- `omnivoice`: synthesize speech and transcribe audio through local VoiceStudio.
- `oss-maintainer`: the repository's open-source maintenance workflow.
---
### Google Colab
## 🗺️ Roadmap
[](https://colab.research.google.com/github/debpalash/VoiceStudio/blob/main/notebooks/OmniVoice_Studio_Colab.ipynb)
### 🔜 Up Next
The [notebook](notebooks/OmniVoice_Studio_Colab.ipynb) runs the app and web UI on a Colab GPU. Colab is remote compute, so uploaded audio and project data do not remain local to your machine.
- 🎬 **Lip-sync v2** — visual speech timing with wav2lip
- 🌐 **Hosted Demo** — try OmniVoice without installing anything
- 🔌 **Plugin Marketplace** — community-contributed TTS engines and effects
- 🎵 **Real-time Voice Changer** — live microphone transformation during calls
<summary><strong>Does it work on Apple Silicon and Intel Macs?</strong></summary>
Apple Silicon is supported with MPS and MLX options. Intel Macs cannot run the local backend because current PyTorch wheels are unavailable; they can connect to a remote backend. See [macOS installation](docs/install/macos.md).
</details>
---
<details>
<summary><strong>How much VRAM do I need?</strong></summary>
<a id="sponsor--donate"></a>
A GPU is optional. Use 4 GB VRAM as the minimum for accelerated work and 8 GB+ for the default multi-stage workflow. Large optional engines can require 12–16 GB or more. Check the [benchmarks](docs/benchmarks.md) and engine guide.
</details>
## 💜 Sponsor / Donate
<details>
<summary><strong>Why does a longer reference clip not always improve the clone?</strong></summary>
OmniVoice Studio is built by one developer using Claude Code and AI agents — and the agent bills are real (thousands of dollars over the last three months). If OmniVoice has created value for you, covering a slice of those bills keeps development full-time.
Cloning is zero-shot: the clip is a prompt, not training data. Use 5–15 seconds of one speaker, close to the microphone, without music, noise, or reverb. Match the tone and pace you want in the output. For training, see [data preparation](docs/data_preparation.md) and [training](docs/training.md).
</details>
<details>
<summary><strong>Can I use generated audio commercially?</strong></summary>
Yes under VoiceStudio's AGPL-3.0 terms. Optional engines and model weights may use different licenses; review the selected engine's license before commercial use.
Not unless you opt in. Analytics is off by default and skipping consent keeps it off. When enabled, the app sends allowlisted, content-free usage metadata. Text, audio, file names, voices, and projects are excluded. Change this at **Settings → Privacy**.
</details>
<details>
<summary><strong>How do I remove VoiceStudio and its data?</strong></summary>
Use `scripts/uninstall.sh` on macOS/Linux or `scripts\uninstall.ps1` on Windows. Both show a dry run before deletion. See the [uninstall guide](docs/install/uninstall.md) for every path.
</details>
## Community and contributing
- [GitHub Issues](https://github.com/debpalash/VoiceStudio/issues) for reproducible bugs and feature requests.
- [Discord](https://discord.gg/bzQavDfVV9) for setup help and project discussion.
- [Good first issues](https://github.com/debpalash/VoiceStudio/labels/good%20first%20issue) for a scoped starting point.
- [Contributing guide](.github/CONTRIBUTING.md) for setup, tests, and pull requests.
## Support development
VoiceStudio is free and has no paid tier. Donations fund development and infrastructure.
VoiceStudio is licensed under [AGPL-3.0](LICENSE). You may run it, modify it, use it internally, and sell generated audio. If you modify VoiceStudio and provide that modified version as a network service, AGPL requires you to offer the corresponding source under the same license. A commercial license is available for proprietary embedding; contact **VoiceStudio@palash.dev**. See [LICENSE-NOTICE.md](LICENSE-NOTICE.md) for the plain-language scope.
Optional engines and downloaded models retain their own licenses. The bundled `omnivoice/` model remains Apache-2.0 upstream.
## Acknowledgments
VoiceStudio builds on [OmniVoice](https://github.com/k2-fsa/OmniVoice), [WhisperX](https://github.com/m-bain/whisperX), [Demucs](https://github.com/facebookresearch/demucs), [Pyannote](https://github.com/pyannote/pyannote-audio), [CTranslate2](https://github.com/OpenNMT/CTranslate2), [AudioSeal](https://github.com/facebookresearch/audioseal), [Tauri](https://tauri.app), [Supertonic](https://huggingface.co/Supertone/supertonic-3), [Sherpa-ONNX](https://github.com/k2-fsa/sherpa-onnx), [GPT-SoVITS](https://github.com/RVC-Boss/GPT-SoVITS), and [PocketTTS](https://kyutai.org).
<sub>Every dollar goes directly to agent bills — keeping OmniVoice development continuous.</sub>
<br/><br/>
<sub><b>More apps from the creator of OmniVoice Studio</b> — same local-first philosophy:
<a href="https://github.com/debpalash/Opal"><b>Opal</b> 💠</a> (play everything — the media player for the AI era) ·
<a href="https://github.com/debpalash/memxt"><b>memxt</b> 🧠</a> (local memory for Claude Code & coding agents).
A ⭐ on those helps too → <a href="#more-from-the-maker">details below</a>.</sub>
</div>
<a id="sponsors"></a>
### 🌟 Sponsors
OmniVoice is **free** and **AGPL-3.0** — no paid tier, no SaaS revenue. Sponsors keep development going, and in return get a logo slot here, in the app, and (for top tiers) on the project website. It's a thank-you, never a paywall. **[See tiers & become a sponsor →](SPONSORS.md)**
<div align="center">
<!-- SPONSORS:START — logo slots are filled here as sponsors come aboard; see SPONSORS.md -->
**Your logo here** — [become a sponsor](SPONSORS.md)
<!-- SPONSORS:END -->
</div>
<sub>💡 GitHub also shows a **Sponsor** button at the top of this repo, wired to the same links via <a href=".github/FUNDING.yml"><code>.github/FUNDING.yml</code></a>.</sub>
<sub>We respond to setup questions within hours, not days.</sub>
</div>
<details>
<summary><b>What happens in there</b></summary>
<br/>
| Channel | What happens there |
|---------|--------------------|
| `#announcements` | Release news and the big moments — new versions land here first |
| `#releases` + `#changelog` | Every build and exactly what's inside it |
| `#issues` | Bug reports as forum posts — triaged straight into GitHub issues |
| `#ideas` | Feature requests, discussed and voted on |
| `#discuss-ideas` | Design talk before things get built |
| `#general` | Setup help, GPU troubleshooting, and showing off your dubs |
</details>
---
<a id="contributing"></a>
## 🤝 Contributing
Yes please — bug fixes, new TTS engine adapters, UI improvements, docs, translations. All of it.
- 📖 Read the **[Contributing Guide](CONTRIBUTING.md)** for setup, code style, and PR workflow
- 🐛 Browse [good first issues](https://github.com/debpalash/OmniVoice-Studio/labels/good%20first%20issue)
- 💬 Join our [Discord](https://discord.gg/bzQavDfVV9) to discuss ideas or ask for help
---
## ❓ FAQ
<details>
<summary><b>Is this really as good as ElevenLabs?</b></summary>
<br/>
Honest answer: <b>it depends on what you're doing.</b>
<b>Where OmniVoice is genuinely competitive:</b> voice cloning from a clean reference clip (state-of-the-art open diffusion TTS), language coverage (646 languages vs. their 32), and everything structural — no per-character billing, no usage caps, no audio leaving your machine, full pipeline customizability (14 TTS engines, 11 ASR engines, your choice of translation).
<b>Where ElevenLabs still wins:</b> out-of-the-box consistency and polish, especially for English TTS. Their one model is heavily tuned; our quality depends on which engine you pick, your hardware, and — for cloning — the reference audio (a dry, close-mic clip clones dramatically better than a noisy or echoey one).
<b>For dubbing specifically:</b> a dub is a chain — transcription → translation → cloning → synthesis — only as good as its weakest link on <i>your</i> source material. If parts come out incoherent, check the segment table's <i>original</i> text first: when the transcription is already wrong, switch the ASR engine or use cleaner source audio — that's usually the fix, not the voice.
Try it on your real material — it's free and takes one download. Many users replace ElevenLabs outright; some keep both. Both outcomes are fine with us.
</details>
<details>
<summary><b>Why doesn't a longer reference clip sound more like me?</b></summary>
<br/>
Because OmniVoice's cloning is <b>zero-shot</b>: your clip is a <i>prompt</i> the model conditions on at generation time — it is never trained on. Feeding it 2 hours doesn't teach it your voice; past a short window the extra audio is simply not used. The dubbing pipeline's reference builder targets ~8 s and hard-caps at 15 s (<code>backend/services/speaker_clone.py</code>), and engines cap the prompt themselves (VoxCPM2 trims references to 30 s). This is different from ElevenLabs <i>Professional</i> Voice Cloning, which fine-tunes a model on hours of your audio — that's a training job, not a bigger prompt.
<b>What actually moves clone quality is the clip, not its length.</b> Zero-shot cloning mirrors the acoustics and delivery of the prompt, so: record 5–15 seconds (~8 s is the sweet spot) of continuous natural speech, close to the mic, in a quiet room with no reverb or music — an echoey clip clones echoey. One speaker only, and read in the tone and pace you want the output to have, because the clone copies your delivery, not just your timbre. Recording a few candidate clips and comparing results beats any amount of extra footage.
<b>Want audiobook-grade, trained-on-your-voice fidelity?</b> That path exists, but it's offline fine-tuning, not an in-app button: prepare a dataset of your recordings (<a href="docs/data_preparation.md">docs/data_preparation.md</a>) and fine-tune the bundled checkpoint via <code>init_from_checkpoint</code> (<a href="docs/training.md">docs/training.md</a>). Fair warning — it's a technical, command-line workflow that needs a capable GPU and hours of transcribed audio. In-app fine-tuning / long-reference "professional" cloning is on the <a href="docs/ROADMAP.md">roadmap</a> as research only; no promised date.
</details>
<details>
<summary><b>Does it work on Apple Silicon (M1/M2/M3/M4)?</b></summary>
<br/>
Yes. MPS acceleration is auto-detected. MLX-optimized Whisper models are available for faster transcription on Apple hardware. <b>Intel Macs are not supported</b>: the app UI installs, but the local Python backend cannot run because PyTorch no longer ships Intel-Mac wheels (<a href="https://github.com/debpalash/OmniVoice-Studio/issues/889">#889</a>) — an Intel Mac can only be used with a remote backend.
</details>
<details>
<summary><b>How much VRAM do I need?</b></summary>
<br/>
<b>4 GB minimum.</b> With ≤8 GB, the TTS model is automatically offloaded to CPU during transcription. With 8+ GB, everything runs on GPU simultaneously. No GPU at all? CPU mode works — just slower (~3× for TTS).
</details>
<details>
<summary><b>Can I use this commercially?</b></summary>
<br/>
<b>Yes — commercial use is free</b> under the <a href="https://www.gnu.org/licenses/agpl-3.0.html">AGPL-3.0</a>: run it, sell the audio you make, dub client videos, deploy it across your team. One obligation: if you <b>modify</b> OmniVoice and offer the modified version to others over a network, you must share that modified source under the same terms. Embedding it in a closed-source product instead? A commercial license is available — see <a href="#license">License</a>.
</details>
<details>
<summary><b>What languages are supported?</b></summary>
<br/>
646 languages for TTS via the OmniVoice model. Transcription (WhisperX) supports 99 languages. Translation coverage depends on the target language pair.
</details>
<details>
<summary><b>Can I add my own TTS engine?</b></summary>
<br/>
Yes. Subclass <code>TTSBackend</code> in <code>backend/services/tts_backend.py</code> and add it to the <code>_REGISTRY</code> dictionary — ~50 lines. The fourteen built-in engines all work this way; see <a href="#tts-engines">TTS Engines</a>.
</details>
<details>
<summary><b>Does OmniVoice collect any data about me?</b></summary>
<br/>
<b>Not unless you explicitly say yes.</b> On first run the app <i>asks</i> — one screen, two equal-weight buttons, no pre-ticked box — and until you answer yes, OmniVoice sends nothing: no analytics, no telemetry, no accounts, no phone-home. Skipping the question means no. Your text, audio, voices, and projects never leave your machine either way.
If you do opt in (also togglable anytime under <b>Settings → Privacy → "Help improve OmniVoice"</b>), what's sent is anonymous, content-free usage stats: generations (engine, language, generation time, character <i>count</i>, error <i>type</i>), plus app lifecycle — an install ping, updates (version-to-version), crashes (error class and a <i>bucketed</i> uptime, never logs), error <i>types</i> (capped, deduplicated), and a single uninstall ping if you remove it. Never your text, audio, file names, or anything identifying — enforced in code by a property allowlist (<code>backend/core/analytics.py</code>), not just a promise. Every build — installer, Docker, or built from source — asks the same first-run question and stays off unless you say yes (the destination is PostHog's publishable write-only client key; skipping the question means off). Your own numbers live in <b>Settings → Usage</b>, computed locally, sent nowhere.
</details>
<details>
<summary><b>How do I uninstall it / remove all its data?</b></summary>
<br/>
OmniVoice is fully local — uninstalling is just deleting the app plus the folders it wrote (model cache, Python env, your voices/projects, config). Run <code>scripts/uninstall.sh</code> (macOS/Linux) or <code>scripts\uninstall.ps1</code> (Windows) — it prints every folder with its size as a dry-run first, then deletes on <code>--yes</code>. The full per-platform path list and app-removal steps are in <a href="docs/install/uninstall.md"><b>docs/install/uninstall.md</b></a>.
</details>
---
<a id="license"></a>
## 📜 License
OmniVoice Studio is free and open-source software under the [**GNU Affero General Public License v3.0 (AGPL-3.0)**](https://www.gnu.org/licenses/agpl-3.0.html).
**Free for any use — including commercial and internal business use.** Run it, sell the audio you produce with it, dub your own or clients' videos, roll it out across your team — all free, no license needed. As a **network copyleft** license, AGPL adds one obligation: if you **modify** OmniVoice Studio and offer that modified version to others over a network, you must make the complete corresponding source of your modified version available to them under the same AGPL-3.0 terms.
A **commercial license** is available for organizations that want to embed OmniVoice Studio in a **closed-source or proprietary** product or service without the AGPL-3.0 copyleft obligations. **Pricing tiers coming soon.** Inquiries: **OmniVoice@palash.dev**.
The bundled `omnivoice/` TTS model by Han Zhu remains Apache-2.0 upstream. See [`LICENSE`](LICENSE) for the full, binding terms, and [`LICENSE-NOTICE.md`](LICENSE-NOTICE.md) for the plain-language summary and scope.
---
## 🙏 Acknowledgments
OmniVoice Studio is built on the shoulders of exceptional open-source work:
| Project | Role |
|---------|------|
| [**OmniVoice (k2-fsa)**](https://github.com/k2-fsa/OmniVoice) | Zero-shot diffusion TTS engine — the core voice synthesis model |
| [**WhisperX**](https://github.com/m-bain/whisperX) | Word-level speech recognition and alignment |
| [**Demucs (Meta)**](https://github.com/facebookresearch/demucs) | Music source separation for vocal isolation |
| [**Pyannote**](https://github.com/pyannote/pyannote-audio) | Speaker diarization — who said what |
| [**CTranslate2**](https://github.com/OpenNMT/CTranslate2) | Optimized Transformer inference on CPU and GPU |
| [**AudioSeal (Meta)**](https://github.com/facebookresearch/audioseal) | Invisible neural audio watermarking for AI provenance |
<p><b>Play everything.</b> The media player for the AI era.</p>
<p><sub>Video, anime, comics, torrents, Jellyfin & Plex — one player for all of it, with local AI memory and context built in. Written in Zig, runs on macOS & Windows.</sub></p>
<p><b>The fastest benchmarked open-source AI memory system.</b></p>
<p><sub>Local long-term memory for Claude Code and coding agents — an MCP server on SQLite + embeddings, 100% on your machine. Your agent finally remembers yesterday.</sub></p>
[](https://colab.research.google.com/github/debpalash/OmniVoice-Studio/blob/main/notebooks/OmniVoice_Studio_Colab.ipynb)
[](https://colab.research.google.com/github/debpalash/VoiceStudio/blob/main/notebooks/VoiceStudio_Studio_Colab.ipynb)
没有本地 GPU?官方笔记本([notebooks/OmniVoice_Studio_Colab.ipynb](notebooks/OmniVoice_Studio_Colab.ipynb))可在免费的 Colab T4 上启动完整应用(包含 Web 界面):在笔记本内直接构建前端,用 uv 安装后端(复用 Colab 预装的 CUDA PyTorch),并通过 Colab 内置端口代理打开界面。无需第三方隧道,也无需任何 API 密钥。随后还有一套覆盖全部主要功能的 API 导览,全部可在笔记本内直接播放:多语言 TTS、声音克隆与声音设计、已保存的声音档案、语音转写、AI 水印检测、OpenAI 兼容 API、多角色故事、带章节的 m4b 有声书,以及一个附带人声分离音轨的迷你视频配音。
没有本地 GPU?官方笔记本([notebooks/VoiceStudio_Studio_Colab.ipynb](notebooks/VoiceStudio_Studio_Colab.ipynb))可在免费的 Colab T4 上启动完整应用(包含 Web 界面):在笔记本内直接构建前端,用 uv 安装后端(复用 Colab 预装的 CUDA PyTorch),并通过 Colab 内置端口代理打开界面。无需第三方隧道,也无需任何 API 密钥。随后还有一套覆盖全部主要功能的 API 导览,全部可在笔记本内直接播放:多语言 TTS、声音克隆与声音设计、已保存的声音档案、语音转写、AI 水印检测、OpenAI 兼容 API、多角色故事、带章节的 m4b 有声书,以及一个附带人声分离音轨的迷你视频配音。
### 🤝 智能体技能(Agent Skills)
用一条命令教会你的 AI 智能体(Claude Code、Cursor、Codex 等)使用 OmniVoice:
用一条命令教会你的 AI 智能体(Claude Code、Cursor、Codex 等)使用 VoiceStudio:
<p><b>Keep the open-source ElevenLabs alternative free, local, and shipping.</b></p>
</div>
@@ -8,15 +8,15 @@
## Why sponsor?
OmniVoiceStudio is built by one developer, in the open, using Claude Code and AI agents — and the agent bills are real. Over the last few months I've spent thousands of dollars on Claude subscriptions to keep features shipping, bugs fixed, and your issues answered.
VoiceStudio is built by one developer, in the open, using Claude Code and AI agents — and the agent bills are real. Over the last few months I've spent thousands of dollars on Claude subscriptions to keep features shipping, bugs fixed, and your issues answered.
OmniVoice is **free**, **fully local**, and **AGPL-3.0**. There's no paid tier, no accounts, no cloud, and no SaaS revenue — nothing runs on a server we bill you for, because nothing runs on a server at all. That's the whole point, and it's also why there's no recurring revenue to fund development. Sponsorship is what makes continued full-time work possible.
VoiceStudio is **free**, **fully local**, and **AGPL-3.0**. There's no paid tier, no accounts, no cloud, and no SaaS revenue — nothing runs on a server we bill you for, because nothing runs on a server at all. That's the whole point, and it's also why there's no recurring revenue to fund development. Sponsorship is what makes continued full-time work possible.
If OmniVoice has created value for you or your company, sponsoring means the next release keeps coming — and you get a thank-you (and, at most tiers, a logo slot) in return.
If VoiceStudio has created value for you or your company, sponsoring means the next release keeps coming — and you get a thank-you (and, at most tiers, a logo slot) in return.
### Where your money goes
Every dollar goes to the cost of building OmniVoice — chiefly the **AI agent bills that keep it shipping** (Claude subscriptions and API usage), plus the occasional signing certificate, test hardware, and model-hosting costs. It is not a salary top-up; it's what keeps the lights on for continuous development.
Every dollar goes to the cost of building VoiceStudio — chiefly the **AI agent bills that keep it shipping** (Claude subscriptions and API usage), plus the occasional signing certificate, test hardware, and model-hosting costs. It is not a salary top-up; it's what keeps the lights on for continuous development.
---
@@ -41,7 +41,7 @@ Placements marked "as that page ships" (the in-app Sponsors page and the project
**1. Open a sponsorship inquiry (recommended).** This opens a short GitHub form (name/org, logo, tier, contact) so we can get you set up:
> **[→ Open a sponsorship inquiry](https://github.com/debpalash/OmniVoice-Studio/issues/new?template=sponsor.yml)**
> **[→ Open a sponsorship inquiry](https://github.com/debpalash/VoiceStudio/issues/new?template=sponsor.yml)**
**2. Or start recurring support directly:**
@@ -70,16 +70,16 @@ To make your logo look sharp everywhere (README on GitHub, the in-app page, the
**How your logo gets added:**
- **Easiest:** attach the asset and link in your [sponsorship inquiry](https://github.com/debpalash/OmniVoice-Studio/issues/new?template=sponsor.yml) — the maintainer places it.
- **Easiest:** attach the asset and link in your [sponsorship inquiry](https://github.com/debpalash/VoiceStudio/issues/new?template=sponsor.yml) — the maintainer places it.
- **Or open a PR:** add your asset under `docs/sponsors/` and an entry to the tables in this file. Silver/Gold logos are also wired into the app's in-app Sponsors page (via the `sponsors.js` manifest) and the project website as those surfaces ship.
By sponsoring you confirm you have the right to use the submitted logo and grant OmniVoice permission to display it in the contexts above. We won't alter your logo beyond scaling, and we'll remove it promptly on request.
By sponsoring you confirm you have the right to use the submitted logo and grant VoiceStudio permission to display it in the contexts above. We won't alter your logo beyond scaling, and we'll remove it promptly on request.
---
## Current sponsors
OmniVoice doesn't have any sponsors yet — **you could be the first.** These slots fill in as sponsors come aboard.
VoiceStudio doesn't have any sponsors yet — **you could be the first.** These slots fill in as sponsors come aboard.
### 🥇 Gold
@@ -107,13 +107,13 @@ _Open — [become a Backer](#how-to-become-a-sponsor)._
Sponsorship is a **thank-you, never a paywall.**
Every feature of OmniVoiceStudio is and will remain **free** and **open-source under [AGPL-3.0](LICENSE)**. Sponsors do **not** get private builds, gated features, license exceptions, or anything that degrades the experience for people who don't (or can't) pay. What sponsors get is **visibility and our gratitude** — and the knowledge that they're directly funding the next release.
Every feature of VoiceStudio is and will remain **free** and **open-source under [AGPL-3.0](LICENSE)**. Sponsors do **not** get private builds, gated features, license exceptions, or anything that degrades the experience for people who don't (or can't) pay. What sponsors get is **visibility and our gratitude** — and the knowledge that they're directly funding the next release.
OmniVoice stays local-first and fully functional with zero dollars spent. Sponsoring just helps it keep getting better, faster.
VoiceStudio stays local-first and fully functional with zero dollars spent. Sponsoring just helps it keep getting better, faster.
---
<div align="center">
<sub>Thank you for keeping local-first voice AI alive and free. ❤️</sub><br/>
<sub>Questions? <a href="https://github.com/debpalash/OmniVoice-Studio/issues/new?template=sponsor.yml">Open an inquiry</a> · <a href="https://discord.gg/bzQavDfVV9">Discord</a></sub>
<sub>Questions? <a href="https://github.com/debpalash/VoiceStudio/issues/new?template=sponsor.yml">Open an inquiry</a> · <a href="https://discord.gg/bzQavDfVV9">Discord</a></sub>
lang:str=Query(None,description="Track language code. Emits that track's text (segments_i18n) when the job carries it; when that track was generated under Smart Fit or stretch_video, cue times come from the fitted timeline."),
):
_job_dir_or_400(job_id)
lang=_safe_lang_or_400(lang)
job=_get_job(job_id)
ifnotjob:
raiseHTTPException(status_code=404,detail="Job not found")
lang:str=Query(None,description="Track language code. Emits that track's text (segments_i18n) when the job carries it; when that track was generated under Smart Fit or stretch_video, cue times come from the fitted timeline."),
):
_job_dir_or_400(job_id)
lang=_safe_lang_or_400(lang)
job=_get_job(job_id)
ifnotjob:
raiseHTTPException(status_code=404,detail="Job not found")
VoiceStudio es una aplicación de escritorio para clonación de voz, doblaje de vídeo y diseño de voz. Funciona completamente en tu máquina. Sin cuentas, sin nube, sin claves de API. Solo abre la aplicación y comienza a crear.
VoiceStudio est une application de bureau pour le clonage de voix, le doublage vidéo et la conception vocale. Elle fonctionne entièrement sur votre machine. Pas de compte, pas de cloud, pas de clé d'API. Ouvrez l'application et commencez à créer.
"script":"VoiceStudio is a desktop app for voice cloning, video dubbing, and voice design. It runs entirely on your machine. No accounts, no cloud, no API keys. Just open the app and start creating."
},
"dubbed":[
{
"code":"es",
"label":"Español",
"video":"dubbed_es.mp4",
"srt":"dubbed_es.srt",
"dir":"ltr",
"script":"VoiceStudio es una aplicación de escritorio para clonación de voz, doblaje de vídeo y diseño de voz. Funciona completamente en tu máquina. Sin cuentas, sin nube, sin claves de API. Solo abre la aplicación y comienza a crear."
},
{
"code":"fr",
"label":"Français",
"video":"dubbed_fr.mp4",
"srt":"dubbed_fr.srt",
"dir":"ltr",
"script":"VoiceStudio est une application de bureau pour le clonage de voix, le doublage vidéo et la conception vocale. Elle fonctionne entièrement sur votre machine. Pas de compte, pas de cloud, pas de clé d'API. Ouvrez l'application et commencez à créer."
},
{
"code":"zh",
"label":"中文",
"video":"dubbed_zh.mp4",
"srt":"dubbed_zh.srt",
"dir":"ltr",
"script":"VoiceStudio 是一款桌面应用,用于语音克隆、视频配音和声音设计。它完全在你的电脑上运行。无需账户,无需云端,无需 API 密钥。打开应用即可开始创作。"
VoiceStudio is a desktop app for voice cloning, video dubbing, and voice design. It runs entirely on your machine. No accounts, no cloud, no API keys. Just open the app and start creating.
Some files were not shown because too many files have changed in this diff
Show More
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.