* 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>
Bump the single source of truth (frontend/package.json) and the three
toolchain mirrors (Cargo.toml, pyproject.toml, backend/core/version.py) plus
the lockfiles (Cargo.lock, uv.lock) from 0.3.22 → 0.4.0, and rename the
CHANGELOG's [Unreleased] section to [0.4.0] with milestone highlights.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
greptile: the fixed menu was always anchored below the trigger (top: r.bottom+4),
so a picker near the viewport bottom (e.g. the last dub segment row) opened
off-screen and scrolling just re-pinned it there. Flip to a bottom-anchored
position above the trigger when there's not enough room below, and cap the
options list to the available space so the chosen side always fits.
Migrate the Stories editor (cast + per-line) and Dub per-segment voice
pickers off native <select>s onto the shared, gallery-enabled
VoiceSelector so the designed-voice Gallery is selectable there too
(follow-up to #1219). Add opt-in body-portal support to SearchableSelect
so the dropdown escapes the react-window virtualized dub row's overflow
clip (the native select worked only because browsers render popups
outside the DOM). Drop the legacy hardcoded design PRESETS group from the
dub picker — superseded by the Gallery; stored preset: values still
expand to instruct via segmentGenInputs, so existing projects are
unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- /archetypes/{id}/use: re-check the personality row inside the write connection
right before INSERT (the pre-render SELECT could race a concurrent /use of the
same archetype). Reuse the winner's row and drop the just-rendered sample. A
UNIQUE index isn't viable — marketplace/persona imports reuse the personality
column — so this closes the realistic single-user window (greptile P2).
- VoiceSelector: log the raw materialize error to the console and show a generic,
actionable localized message instead of interpolating backend detail; drop the
{{message}} placeholder from voiceSelector.addVoiceFailed across all 21 locales
(CodeRabbit).
- mediaDownload: check res.ok before reading the body on the subtitle and
dynamic-endpoint save paths, so a 4xx/5xx doesn't get written to disk or
reported as a successful save (CodeRabbit Major; matches browserDownload).
- StoriesEditor: drop the unconditional toast.success + direct recordValueMoment
— downloadMedia owns the toasts and fires onValueMoment only on a real save,
so a cancelled dialog no longer shows 'export done' (greptile).
The restore is dispatched fire-and-forget to _cpu_pool from the stream's finally;
the test's single asyncio.sleep(0) wasn't enough for the pool thread to land it
under CI load, and asyncio.run then closed the loop before it fired — so the
assertion raced (flaked repeatedly even at a 60s Event.wait; timeout was treating
the symptom). Block within the loop until the restore Event is set (default
executor waits while _cpu_pool runs the restore), so the caller always sees a
settled state. 5/5 locally.
- Stats template: remove '~' before the exact word count and before the
already-'est.' runtime across all 21 locales (misleading/redundant).
- Changelog: neutral one-liner, no bold-lead/colloquial wording.
- Import/Load sample/Preview plan/Stop-Create now share one shadcn button layout
(icons via the leading/loading slot, text in a leading-none span). Import's
inline flex/gap override — which mismatched the others' gap and icon baseline —
is gone, so the row lines up.
- voiceCast '?? {}' minted a new object every render, defeating the useMemos
keyed on it; use a stable frozen EMPTY_CAST fallback.
Addresses the last #1216 review note (elapsed kept ticking if the panel stayed
mounted after a terminal state) with a defensive freeze; the parent also unmounts
it on cancel/fail. Adds the #1217 changelog entry.
- Abort the in-flight generation on tab unmount (useEffect cleanup): leaving
mid-render no longer leaves the stream/backend job running, and a late
done/error can't clobber a newer render's output (CodeRabbit Major).
- Record a client-disconnect Stop as job_store.mark_cancelled, not mark_failed
— a user stop is a cancellation, not a failure (CodeRabbit).
- AudiobookStopCancel test: reject immediately if the signal already aborted
before read(), instead of hanging to timeout (CodeRabbit).
The reset flow schedules setTimeout(() => window.location.reload(), 400) with no
cleanup. If the component unmounts before it fires (notably a vitest env torn
down before 400ms), the timer throws from window.location as an *unhandled*
error and reddens the whole run even though every test passed — an intermittent
flake (the earlier jsdom reload no-op only covered the in-suite case, not
post-teardown). Hold the timer id and clear it on unmount.
Group the sprawling right-hand settings column into consistent collapsible
Sections (Output open by default; Book details / Pronunciation / Markup
collapsed) built on a small reusable native-<details> Section component, so a
first-timer sees script + voice + language + Create without scrolling. Extract
the cover+metadata and lexicon blocks into BookDetails / LexiconEditor
sub-components (also relieves the max-lines lint). New i18n key audiobook.output
added to all 21 locales. Behaviour-identical; adds an AudiobookTab layout test.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per feedback: clicking the sample should drop a ready-to-run demo story straight
into the script editor so a first-timer can hit Create immediately, not download
a file to re-import. Sample content moves to src/data/sampleAudiobook.js; the
button loads it (with a confirm guard when the editor already has content).
Removed the public .md; i18n keys renamed load_sample/_hint/_confirm across all
21 locales.
Import accepts .txt/.md/.epub/.pdf; add a 'Sample script' download next to
Import that hands users a ready-to-import demo (frontend/public/sample-audiobook.md)
exercising chapters, per-character [voice:], [pause], [slow]/[fast]/[emphasis]/
[spell], and reaction tags — so a first-timer can hear the full range in one
generate. New i18n keys in all 21 locales.
A component that schedules a reload on a timer (ResetPanel: setTimeout(reload,
400)) throws 'Not implemented' from jsdom when the timer fires after its test
has moved on — an unhandled error that reddens the whole run even though every
test passed (order-dependent; it flaked #1214's CI). No-op location.reload/
assign/replace globally so a lingering navigation timer can never fail CI.
The restore is dispatched fire-and-forget to a thread pool from the transcribe
stream's finally (run_in_executor(_cpu_pool, restore_tts_after_asr)), so the
test waits on a threading.Event. The 10s timeout raced a slow/loaded CI runner
(intermittent 'left the TTS model stranded on CPU'); bump to 60s — instant on
success, only a genuine never-restored regression pays the wait.
CodeRabbit: the 6-digit share PIN is brute-forceable (10^6, no lockout), so
letting it unlock admin over the network was still weak. Admin now requires the
API key (a long operator secret) or loopback; the PIN is consumption-only and
never gates /system/* or /api/settings/*. A PIN-only deployment keeps admin
loopback-only. Docs (api-auth.md, remote-gpu.md) aligned with the conditional
gate (no credential -> admin open; API key -> admin) and the PIN exclusion.
Test inverted: presenting the PIN over the network is now 403 on admin.
- Add Field(ge/le) bounds to the expressive knobs so a loopback POST can't pin
a GPU worker with an absurd num_step or feed the sampler out-of-range values
(CodeRabbit Major). Regression test in test_audiobook_expressive.py.
- Localized markup hints had translated the bracket PARSER tokens
([voice:NAME]->[voz:NOMBRE], [slow]->[lento], mismatched [enfasi]…[/emphasis]),
so users copying the localized examples would send unsupported tags. Restore
canonical tokens in all 20 locales, keeping only the prose translated.
- Note the seed override in the changelog control list.
CodeRabbit: a plain canvas click anchors a fresh selection with start === end,
and the preview button isn't gated on that, so a loop source could start with
loopStart === loopEnd — which makes Web Audio ignore the loop points and loop
the WHOLE buffer. Extract a pure loopWindow() helper that floors the segment at
MIN_LOOP_SEC (so loopStart < loopEnd always) and clamps into the buffer; unit-
tested for empty/inverted/near-end selections. Also adds the #1210 changelog.
OMNIVOICE_SERVER_MODE=1 made require_loopback an unconditional no-op, so with
OMNIVOICE_TRUSTED_NETWORKS also set a trusted-CIDR client — a consumption-only
exemption that bypasses the PIN/API-key middleware via is_local_host — could
reach the RCE-class admin surface (/system/set-env, /api/settings/*) with no
credential. That collapsed the documented two-tier privilege model
(consumption trust != admin trust) in exactly the "lock the backend with a key,
exempt a LAN proxy for TTS" configuration.
Server mode still can't require true loopback (Docker NAT, #261), but it now
applies the credential rule to admin routes: open only when NO credential is
configured; otherwise the request must present the API key or share PIN.
Trusted-network membership alone never satisfies it. Loopback, credential
holders, and no-credential Docker deployments are unchanged; consumption
routes (require_local / middleware) keep exempting trusted networks.
Regression tests cover the server-mode x trusted-network x credential matrix
(fail-before/pass-after). Docs: new docs/api-auth.md two-tier model + quick
reference; docs/remote-gpu.md corrected (previously documented the hole as
accepted behavior).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Audiobook renders were locked to the model's most deterministic preset (32
steps / 2.0 guidance / model-default temps) with no way to change it, which is
why books sounded flatter than the same voice on the Voice page. Open that up
without changing any default byte-for-byte.
- Production Overrides in the Audiobook tab: position_temperature,
class_temperature, num_step, guidance_scale, postprocess_output (+ seed),
reusing the Voice page's panel. Unset reproduces today exactly.
- IndexTTS2 graded emotion (emo_vector / emo_text / emo_alpha) reaches the
longform path via a typed engine-options object; engines that don't
understand an option ignore it (no crash across the ~14 backends).
- Cache opt-out ("vary repeated lines") so identical lines can get distinct
takes; default off keeps the content-addressed replay.
- Markup reference now lists the reaction tags that already work in audiobooks;
docs/expressive-speech.md corrected so no recipe it names is unreachable.
- Fix AudiobookGenerateBody dropping `language`, so audiobook language
selection actually reaches the backend.
Every new param is folded into BOTH cache layers (chapter + segment) and the
per-chapter preview, so changing a knob re-renders instead of replaying stale
audio — and an all-default request keeps its old cache key, so existing books
don't re-render. Regression tests: tests/test_audiobook_expressive.py (backward
compat, cache-signature loop, preview/render parity, engine-ignores-unknown,
cache opt-out, emotion reaches engine) + audiobookOverrides.test.jsx.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The mount chain (StrictMode → QueryClientProvider → RemoteAuthGate → App) had
no error boundary above App. App's per-tab <ErrorBoundary> wraps only cover
their own subtrees, so a throw in App's own render — its top-level hooks, store
access, restoreProjectExtras, or the header/sidebar chrome that renders before
any tab — escaped every boundary and left #root empty. The shell's blank_guard
could then only reload three times and paint a dead-end failure page: exactly
the "#root children = 0 after 3 reloads" a user hit.
Wrap the mount root in <ErrorBoundary name="app-root"> so such a throw shows the
in-app, recoverable error card (Reload / Report, data untouched) instead of a
blank window. The shell guard stays as the last resort for the rarer case where
even the boundary can't render.
Recurrence-proofing (the throw only had to happen once to blank the app, and the
#1178-class variant only bites the MINIFIED bundle that dev + jsdom never run):
- main-app.test.jsx: fail-before/pass-after — a throwing app tree must leave
#root non-empty (the recovery card), not blank.
- Wire the existing production-bundle smoke (e2e-prod/prod-bundle-smoke.spec.ts)
into CI so a pre-render crash in the shipped bytes fails the pipeline, not a
user's launch. Fixed its IGNORABLE list to treat the no-backend WebSocket
handshake error as the harness artifact it is.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Preview played the original file through a separate <audio> element and
seeked/looped it with the selection's decoded-buffer seconds treated as
container seconds. For VBR / mis-reported-duration clips those two
timelines diverge, so the previewed region drifted from the region the
waveform showed and the confirmed clip was sliced from.
Preview now plays the SAME decoded AudioBuffer the waveform is drawn from
and the export slices, over the exact [start,end] selection window via a
Web Audio BufferSource. One buffer, one timeline: preview, waveform and
exported clip can no longer drift apart, and playback is identical across
platform WebViews (no per-container media-timeline variance).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A botched merge resolution can git-add a file still carrying <<<<<<< /
======= / >>>>>>> markers — git commits them as literal content and no
existing linter catches it (it happened on a fix branch 2026-07-20).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
CodeRabbit findings 2 and 3 (finding 1, the LAST_FAILURE test race, was already
fixed in 7adcc7d3 — it reviewed the commit before it).
- A Report click whose `buildBugReportUrl`/`openExternal` throws only logged to
the console, so the button read as broken. It now raises a toast naming the
fallback that still works (the diagnosis is on screen, ready to copy). Fixed
in BackendCrashNotice too — same class, same silent catch, and the crash and
start-failure notices should not disagree about what a failed Report does.
- The `bun desktop` failure hint pointed Linux users at ~/.local/share/OmniVoice.
The real path is ~/.omnivoice (backend/core/config.py::get_app_data_dir, and
its mirror resolveDataDir in dev-backend.mjs) — a wrong path in a "where to
look" message is worse than no path. Commented so the three stay in step.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
CodeRabbit (PR #1204): the self-heal moved the shared model on the CPU
pool, so a concurrent generate() could hit the instance mid-transfer.
Dispatch the move on the GPU pool instead — the hosts that can strand a
model are always 1-worker (offload only fires below 8 GB free VRAM), so
occupying a slot is real mutual exclusion.
Deadlock guard: OmniVoiceBackend._ensure_loaded() reaches get_model()
via asyncio.run() from inside generate(), already on a GPU-pool worker.
Dispatching back into that pool (or blocking on the model lock held by
the loop waiting on us) would deadlock, so that case heals inline —
it already owns the GPU slot. Pinned by a regression test.
Also resolve the test module's app import at run time (CodeRabbit).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`what` carries request-derived data (engine ids), so strip CR/LF and clamp
it before it reaches the timeout/saturation log lines; narrow the
abandoned-future cleanup handler off BaseException.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
An abandoned future is not done yet at the moment we stop awaiting it, so
calling .exception() inline was a no-op; register the consumer as a done
callback instead, and cover the client-disconnect path too.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Greptile P1: the retention test mutated the module-level `LAST_FAILURE` and
left it set on exit. `cargo test` runs a binary's tests in parallel, so any
future test asserting on `last_failure_message()` would race it.
Rather than paper over it with teardown (which doesn't fix the race, only the
leak), `set_stage` now delegates to `set_stage_into(state, slot, stage)` with
the retention slot as a parameter. The behavioural tests drive their own slot —
deterministic and parallel-safe by construction — plus one narrow wiring test
that asserts the public `set_stage` writes the same global `last_failure_message`
reads, using a thread-unique value so a parallel writer cannot make it flake.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A job queued behind a busy 1-worker pool burned its whole 300s budget
without executing an instruction, then reported "too heavy for the
available compute". The clock now starts when a worker picks the job up;
queue wait has its own generous bound and surfaces as a retryable
saturation error.
Also: reset() no longer cancels innocent queued peers; the timeout
message stops claiming capacity was restored (the abandoned job keeps the
device until it drains); every GPU dispatch uses the shared length-scaled
budget; watermark embeds move off the GPU pool; /v1/audio/speech gets
429/503 + Retry-After; a timed-out batch segment fails the job instead of
shipping a silent gap.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
CodeQL flagged the `return` inside the finally block ('break'/'return'
in finally swallows in-flight exceptions). The returns lived in a nested
def so nothing was actually swallowed, but the pattern is worth avoiding
outright: the dispatch helpers now live at endpoint scope and the finally
holds straight-line control flow only.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The reporter's string is apiFetch's LAST fallback, reached only when no crash
marker exists AND the shell's lifecycle stage is 'failed' or 'unknown'. The
'failed' half was the bug: `BootstrapStage::Failed { message }` carries the
whole diagnosis — exit code plus a ~30-line stderr tail, or the precise reason
`ensure_venv_ready` refused (Intel Mac, a failed `uv sync`, a blocked GitHub) —
and `backendLifecycleStage()` returned only the stage tag, throwing the message
away. Every backend-start failure mode collapsed into one generic, evidence-free
sentence that was also factually wrong: it is not starting, and it will not
recover on its own.
- backendLifecycleStage() returns `{ stage, message }`; a `failed` stage gets
its own branch in apiFetch that surfaces the shell's diagnosis, scrubbed.
- BackendStartFailureNotice renders it after the splash is gone, reusing the
splash's `detectHints` matcher (shared, so the two can't drift) and the
existing bug-report affordance. No Retry advice for unrecoverable failures.
- Rust retains the last `Failed { message }` past a later stage transition
(Retry sets Checking, the supervisor sets StartingBackend) so a respawn can't
erase the first diagnosis; exposed as `last_bootstrap_failure`.
- `bun desktop` prints the exit code and where to look instead of exiting
silently — the from-source twin of the same class ("builds but won't launch").
- Scrub primitives extracted to utils/scrub.js so the transport layer can scrub
without a bugReport -> client import cycle.
Non-Tauri deployments are untouched: there is no shell to fail this way, so the
stage stays 'unknown' and #1164's deployment-specific message still stands.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`offload_tts_for_asr()` moves the TTS model to CPU to make VRAM room for
WhisperX, but its partner `restore_tts_after_asr()` was only reachable on
the dub-transcribe success path. Any abort, terminal error, or client
disconnect skipped it, and `get_model()` never re-checked placement — so
EVERY subsequent /generate ran on CPU (10-50x slower, CPU pegged) until
the ~15-minute idle unload happened to fire. Reported as "speed varies by
time of day"; it is fully deterministic.
Two independent guarantees:
- Balance the pair at the call site: gen()'s `finally` now pays the
restore debt on every exit path, chained off the ASR unload so the two
never contend for VRAM (and fire-and-forget, since the finally also
runs under GeneratorExit where awaiting is illegal).
- Self-heal placement (the class fix): `get_model()` verifies the model
is on the resolved target device and moves it back if not, so a future
unbalanced offload path cannot strand it either. Cheapest-first probe —
one parameter check on the hot path; unified memory is exempt (its
offload releases the model rather than moving it).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
analytics.ts landed unformatted in e3ed9523, so CI's oxfmt gate has been
red on main since — every PR inherited the failure. Separately,
tone_instructions exceeded CodeRabbit's 250-char cap, so the whole config
failed to parse and reviews silently ran with defaults (the brevity
tuning was never active). Shortened to 204.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
CodeRabbit (PR #1203): the paste dialog's "Load .srt/.vtt file" control was a
bare <label> pointing at a `hidden` file input. A <label> is not in the tab
order and `hidden` drops the input from both the tab order and the
accessibility tree, so keyboard-only users had no way to open the picker.
Visually hidden (`sr-only`) but focusable keeps the pointer affordance and
restores the keyboard path; regression test asserts it never goes back to
`hidden`.
Also runs oxfmt over frontend/src/utils/analytics.ts, which commit e3ed9523
landed unformatted on main — CI's "Frontend format check (oxfmt)" step has
been failing on main since, and no PR can go green until it is reformatted.
Whitespace only, no behaviour change.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
After transcription the user can paste a translation produced elsewhere
(ChatGPT, DeepL, a human translator) and have it map onto the segments
that already exist — no re-transcription, no timing loss.
Three input shapes are auto-detected: a timestamped .srt/.vtt (cues matched
to segments by time overlap, greedy one-to-one so one long cue can't be
copied onto several rows), numbered lines (`1.` / `2)` / `[3]`, mapped by
number and falling back to order when a model renumbers mid-answer), and
plain lines (positional, blank lines treated as separators rather than
empty translations). Nothing is applied until the preview dialog has shown
every row as before→after with unmatched rows flagged.
Applying goes through `pasteTranslations` in useSegmentEditing, which
mirrors `segmentEditField`'s duties across rows in ONE undo step: write
`text` and `translations[dubLangCode]` in lock-step and clear the stale
machine-translation badges. It never writes `text_original` (the translate
source `handleTranslateAll` reads — overwriting it would poison every later
re-translate) and never touches a language other than the active one.
Changing `text` alone marks those rows stale via the existing per-language
fingerprints, so no new flag is needed.
The new `POST /dub/parse-subtitle-text` is a stateless wrapper over the
existing `services.srt_parser.parse_srt`, so the lenient cue parsing stays
single-sourced instead of being reimplemented in JavaScript.
Also fixes a ReDoS in that parser, reachable today via /dub/import-srt:
`_TIMING_RE` used `^\s*` under re.MULTILINE, so at every line start the
engine consumed all remaining blank lines before failing on the first
digit — quadratic. 20k blank lines already took 1.7s and a 2 MB blank-line
file never returned, pinning the request thread. Horizontal-whitespace-only
classes make the scan linear.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
.gitleaks.toml allowlists the exact phc_ literal (write-only client key,
public by PostHog's design; #1193) — regex-based so history scans pass
too — plus gitleaks:allow inline markers. phx_ personal keys stay banned;
the guard test still pins the literal to the two canonical files.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Owner-sanctioned reversal: the publishable write-only PostHog client key is
committed as the in-repo default in backend/core/analytics.py and
frontend/src/utils/analytics.ts (env / baked release token still wins), so
source builds show the same first-run consent ask as installers — skip = off,
nothing is ever sent without an explicit yes. Adds an install_channel property
(installer / docker / source) to lifecycle events, stamped by the desktop shell
via OMNIVOICE_INSTALL_CHANNEL and by the Docker image's existing
OMNIVOICE_SERVER_MODE marker. Guard tests now pin the two-canonical-files
allowlist + same-token invariant, and the uninstall-ping info file works on the
default token.
Fixes#1193
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The curated model rows made the Models & engines step taller than the
viewport; the step's list section lacked the overflow-y-auto clamp the
System step already has, so the pinned footer (Back/Continue) was pushed
offscreen. The list now scrolls; Continue stays visible, disabled until
required models install.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
CodeRabbit's finding covered both unload sites; the harvest fixed only
gen()'s finally. The success path still blocked the event loop for the
gc/CUDA-cache drop on every completed transcription.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Owner directive: no fluff on PRs. Greptile: logic-only comments at max
strictness, no diagrams/confidence sections, summary collapsed.
CodeRabbit: three-sentence findings, no sequence diagrams or ASCII
sketches, collapsed walkthrough, no per-push status comments, finishing
touches off. Both: comment only when a finding changes what merges.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- fallback preflight pins the candidate backend id (Greptile) — also the
CI empty-cache failure: deep-import fall-through tests get the
asr_model_installed fixture
- locale ratchet: improvement warns instead of failing CI (CodeRabbit)
- stream-exit ASR unload runs on the GPU pool fire-and-forget instead of
blocking the event loop (CodeRabbit)
- E741 rename; M4A ftyp sniff scope documented (CodeRabbit)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Harvested and verified every CodeRabbit/Greptile finding from PRs #1175,
#1189, #1192, #1195: 16 real ones fixed (fallback ASR preflight bypass,
VRAM release on stream exit, typed 409 parity, uv env independence,
path-privacy in errors, MCP clone_voice hardening, CaptureWidget WS
guard, test hygiene), 4 refuted with evidence, rest documented as
deliberate design or deferred.
Deterministic CI replaces hand-enforcement: tests/test_changelog_style.py
(quiet one-liner format) and tests/test_locale_parity.py (21-locale
key/placeholder lockstep with a ratchet baseline) — the latter surfaced
and fixes 151 already-broken locale strings. CodeRabbit/Greptile carry
the house rules via .coderabbit.yaml + greptile.json; CLAUDE.md gains
the harvest-before-merge and never-accept-as-is rules.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Both Greptile P2s on the PR: the OMNIVOICE_PRELOAD_TTS_ASR get_model()
wait ran between the open comment and the ASR keepalive loop, leaving a
proxy-idle-timeout hole; and a client disconnect abandoned the executor
load future, logging 'Task exception was never retrieved' into crash
forensics.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
PR #1192's branch forked before the oxfmt check landed in main's CI
workflow, so its (green) PR run never executed the format gate that
main's post-merge run enforces. PR-green != main-green under workflow
skew — the post-merge run on main is the authority.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The whole transcribe preflight (job lookup, TTS-pipe harvest, ASR-missing
check, eager ASR backend load) ran in the endpoint body, before the
StreamingResponse existed — outside the stream's terminal-event contract:
* an exception on any unguarded preflight line became an HTTP 500, whose
body EventSource cannot read, so the UI could only show the generic
'Transcribe stream dropped … likely ASR backend failed to load' guess
while a perfectly alive backend knew the real cause;
* not one byte (not even response headers) went out until the ASR load
finished — a first-run multi-GB weight download means minutes of total
silence, tripping Chrome's hard ~5 min no-response timeout and
reverse-proxy idle timeouts in front of Docker installs, severing the
stream with that same generic message.
Move the preflight inside the guarded generator: headers plus an opening
comment byte go out immediately, keepalive comments (invisible to
EventSource) flow every ASR_LOAD_KEEPALIVE_S while the backend loads, and
ANY preflight crash now lands in the #516 finalizer as a structured
error + terminal done. No client changes needed.
Regression tests: preflight crash must stream a structured error (not
raise), and bytes must flow before/during a slow ASR load.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
AI agents driving OmniVoice via MCP could use and list voices but couldn't
create one. Add a clone_voice MCP tool that takes a base64-encoded reference
audio sample (consistent with transcribe's audio_base64 pattern), decodes it,
and POSTs it as a multipart ref_audio to POST /profiles (kind=clone). Returns
the new profile_id so the agent can immediately use it with generate_speech.
Update test_mcp_mount.py to include clone_voice in the asserted tool surface.
CHANGELOG entry.
System notifications are re-generated on every /system/notifications poll,
so standing facts about a machine (gpu-unavailable on a CPU-only box,
disk-low) sit in the bell and footer forever with no way to acknowledge
them. This adds client-side dismissals:
- prefsSlice gains dismissedNotificationIds (persisted, deduped, capped
at 50) + dismissNotification(id)
- useVisibleNotifications wraps the shared poll with the filter so the
header bell badge and the footer tab always agree
- info/warn notes render a dismiss button (reuses common.dismiss, no new
strings); error notes stay undismissible -- broken things remain visible
until actually fixed
- id stability is the re-arm contract: stable ids stay dismissed across
sessions, occurrence-stamped ids (last-run-crash-<detected_at>) come
back as fresh ids exactly as their generator intended
- acting on the run-sentinel unclean-shutdown notice now POSTs
/system/last-run-crash/ack, mirroring what crash-last-session already
did -- previously nothing in the UI ever acknowledged it
Tests: store contract (dedupe/cap/level-gate) + render-level coverage of
the footer tab through the real hook chain (mocked API, real QueryClient,
real store); existing suite green (1388 passed), oxlint + tsc clean.
- validate managed binaries before exec; 0-byte GGUF placeholders fail
actionably instead of "Exec format error" (#1172)
- KittenTTS: tokenizer-measured chunking to the ONNX 512-token cap;
clear 400 for unspeakable input (#1173)
- clean SIGTERM during weight load: shutdown-aware loader, benign
cancelled-load classification, lifespan hardening, scoped log
silencers (transformers load + alembic fileConfig) (#1174)
- broken ASR deep-imports (lightning_fabric) mark the engine
unavailable with a repair hint and fall through (#1185)
- uv cache + managed Python follow the chosen install drive on
Windows (cherry-picked cross-drive class fix + spaces/D: tests) (#1186)
- adaptive silence-removal ladder for quiet clone references; localized
actionable error for truly silent clips, all 21 locales (#1188)
- CHANGELOG: consolidated Unreleased into the quiet one-liner style
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The stranded-pill fix shipped in #1175 without runtime cover; these close that.
Errors deliberately do not auto-dismiss so a failed paste keeps the transcript
on screen for the user to copy. But the mic / model-missing / server /
connection paths carry NO transcript, and those left the widget parked on top
of everything until the app restarted ("the dictation bubble is permanently
sticking when it's not used"). That distinction IS the fix, so both halves are
pinned: a no-transcript error clears itself, and an error holding text stays up
past the window — auto-dismissing there would discard the only copy of what the
user just said.
Fake timers are engaged only AFTER startSession() resolves; enabling them
earlier stalls its awaits and the test hangs rather than fails.
The silent-model fallback + auto-demotion and the stranded-pill dismissal were
committed after #1175's changelog section was written, so they merged
undocumented. Per the docs-sync rule this is the immediate next commit, not
backlog — both are user-visible and would otherwise ship unmentioned.
format:check gates CI and the two new files were not oxfmt-clean. Scoped to
just those files: running `bun run format` wholesale on Windows rewrites all
479 files' line endings, which would bury the change in CRLF churn.
The curated default `sherpa-parakeet-tdt-v3` installs cleanly, loads without
error, and returns an empty token list for clear speech — while whisper and
zipformer transcribe the same bytes. Ruled out: quantisation (fp32 fails too),
sherpa-onnx version (1.13.3 and 1.13.4), decoding method (greedy and
modified_beam), and model_type (explicit and auto-detect). The defect is inside
sherpa-onnx's NeMo-TDT decoder and cannot be fixed here by configuration.
The previous commit made that survivable — a silent model falls back to the
capture ASR engine for the session. This makes it stop recurring.
Hard-coding a different default per OS would be a guess: there is evidence for
Windows only, and blanket-changing the curated pick could regress a platform
that works. So the app observes rather than predicts. When a session hears
speech-level audio and the model returns nothing, that model is demoted ON THIS
MACHINE and is no longer auto-selected; dictation routes to the capture ASR
engine instead. Self-correcting wherever the breakage actually is, a no-op
everywhere it isn't, and no round trip repeated on every later session.
Demotion is never a dead end: explicitly choosing a model in Settings clears it,
so a sherpa upgrade that fixes the decoder is reachable and the user stays in
charge.
Verified end-to-end on a clean backend: with parakeet pinned and no demotions,
one speech session demoted it ("demoted on this machine" logged), after which
dictation_model_id() returns None (capture ASR engine) while the pref is
untouched — and re-picking the model restores it. 12 tests cover the round
trip, per-model scoping, and the user-regains-control path.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Plan B of the never-blank work. Plan A stops us SHIPPING a blank; this stops us
DISPLAYING one, whatever the cause.
OmniVoice has shown a blank window from unrelated causes: in production a
minifier temporal-dead-zone reorder threw before React mounted (#1178, assets
fine, #root empty), and in development a second `bun desktop` killed the first
instance's Vite server, leaving its window pointed at a dead URL. Both look
identical to the user — a dark rectangle, no explanation, no way forward.
Preventing each individual cause is necessary but never sufficient, because the
next cause is always a new one. So the guard enforces the invariant itself:
"did anything render?"
1. Detect — a probe injected by the SHELL (never by app code, which is what
may be broken) reports #root's child count.
2. Heal — reload with backoff; a slow bundle or a still-booting dev server
recovers on its own and the user sees nothing.
3. Guarantee — once retries are spent, show a page compiled into the binary,
so whatever broke the app cannot also break the explanation.
Two findings from verifying it against a real blank, both of which the first
version got wrong:
* Silence is the most important failure signal. When navigation fails the
webview parks on an internal error page where Tauri's IPC bridge does not
exist, so the probe CANNOT report no matter how blank the window is. v1
only reacted to a report and therefore sat mute through a genuine blank.
Anything that cannot tell us it is fine is now treated as not fine (-2).
* The fallback must be reached by NAVIGATION, not by eval'ing HTML into the
document. Injection needs scripts to run in the current page — which is
exactly what is not happening when this page is needed. It navigates to a
self-contained data: URL, with injection kept only as a backstop.
Also heartbeats rather than checking once at startup: a one-shot check would
miss every blank that happens later, including the dev-server-dies case that
was actually reproduced.
Scoped to the `main` window: the dictation pill is a separate webview that
legitimately renders nothing while hidden, and would trip this on every launch.
Verified end-to-end against the reproduced blank (vite killed, forced reload):
the guard logged 3 reloads then painted the fallback, and CDP confirms the main
window ends on the data: URL showing the failure page instead of an empty one.
9 unit tests cover the probe's independence from app globals, the fallback
being network-free and a complete document, HTML/quote escaping of the detail,
and data-URL encoding of the characters the page actually contains.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two independent causes of a blank app window.
Never SHIP a blank (the prod hole). v0.3.22 shipped a black screen: a minifier
temporal-dead-zone reorder threw before React mounted, leaving an empty #root
(#1178). It reached users because every existing check — vitest, node:test, and
the whole Playwright e2e suite — runs the UN-MINIFIED dev server, so a bug
living only in the minified bundle passes them all. playwright.prod.config.ts +
e2e-prod/ close that hole: build the real bundle, serve dist/ via vite preview,
and assert the app actually mounts (#root has children, renders visible text,
no pageerror). The core assertion is deliberately structural — "did anything
mount?" — because that is what a pre-render crash always breaks, whatever its
cause. retries: 0, so a blank screen can never be flaky-passed away.
Never DISPLAY a blank in dev (the collision). Running `bun desktop` while one is
already up does not fail politely, it cascades into a blank window. Reproduced
deterministically and measured over CDP: the healthy main window has #root
childElementCount 1; after a second launch it is 0. The new launch's port grab
makes the running instance's dev:api exit, and `concurrently --kill-others-on-fail`
then tears down that instance's whole stack including its Vite server — leaving
its window open, pointed at a dev URL that no longer answers. desktop-dev.mjs
now clears a leftover dev app first, loudly.
The safety boundary for that cleanup is `isDevAppProcess` in desktop-common.mjs:
it matches the cargo dev binary (`omnivoice-studio`) ONLY, never the installed
release app (`OmniVoice Studio`) — killing a user's real app would be far worse
than the bug being fixed. Unit-tested both ways.
Also makes the gate runnable off Linux: the dev e2e config hardcodes
/usr/bin/chromium, which doesn't exist on Windows/macOS. The new config falls
back to Playwright's own browser so a contributor can run the gate before a
release.
The ci.yml step that runs this gate is NOT in this commit — pushing workflow
changes needs a token scope this session lacks. It is provided separately for
the maintainer to apply.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two bugs that together made dictation look completely dead on Windows.
1. Silent model. The curated default `sherpa-parakeet-tdt-v3` downloads, loads
with zero errors, and is correctly detected as a TDT model — then returns an
empty token list for clear speech. Measured on one 18.9s WAV, same machine,
same sherpa-onnx:
sherpa-whisper-tiny -> "Alright, here we are. I hope that's all..."
sherpa-zipformer-en-20m -> "ANTS BOTH IN WHAT DISGUISED THIS THAT..."
parakeet-tdt-v3 int8 -> '' <-- the default
parakeet-tdt-v3 fp32 -> ''
parakeet-tdt-v2 int8 -> ''
Ruled out: quantisation (fp32 fails too), sherpa-onnx version (1.13.3 and
1.13.4), decoding method (greedy and modified_beam), and `model_type`
(explicit and auto-detect). The fault is inside sherpa-onnx's NeMo-TDT
decoder, so the app cannot fix it by config — but it must not present it to
the user as "dictation is broken". The offline handler now tracks whether it
ever heard speech-level audio; if the model returns nothing anyway, it falls
back to the capture ASR engine for that session and returns a `warning` +
`model_silent` naming the model that failed. The pref is left alone, so the
user stays in control. A quiet user still yields a quiet result.
2. Stranded pill. Error states deliberately never auto-dismiss so a failed
paste keeps the transcript on screen. But the mic / model-missing / server /
connection paths have no transcript to rescue, so they parked the widget on
the user's screen forever ("the dictation bubble is permanently sticking
when it's not used"). One effect now dismisses an error pill that carries no
text, covering every existing path and any added later; delivery failures
keep their text and their stay-up behaviour.
`is_model_silent` is extracted so the decision is unit-tested rather than
buried in the socket handler.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Merging #1171 (OMNIVOICE_TRUSTED_NETWORKS) into this branch moved the WS
loopback guard in capture_ws.py from a local `_LOOPBACK_HOSTS` set to the
shared `is_local_host()` helper, which reads `_LOOPBACK_HOSTS` from
`api.dependencies`. The runtime WS test still monkeypatched the now-removed
`capture_ws._LOOPBACK_HOSTS`, so it failed at collection-time with
`AttributeError: module 'api.routers.capture_ws' has no attribute
'_LOOPBACK_HOSTS'`. Patch the symbol at its new home so "testclient" is
treated as loopback and the WS connect reaches the typed-error-frame path.
A textual auto-merge that broke at runtime; #1171's own capture_ws test was
already source-level and unaffected. Green locally (17 passed).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Dubbing's vocal-separation step died with `TypeError: An asyncio.Future,
a coroutine or an awaitable is required` whenever the backend ran on an
event loop without native async-subprocess support — notably the Windows
`SelectorEventLoop` that uvicorn forces under `--reload` (`bun desktop`).
On that loop `spawn_subprocess` returns a thread-backed `_AsyncCompatProc`
whose `.stderr` is a plain SYNC pipe, but `run_proc_streaming_stderr`
unconditionally did `await asyncio.wait_for(p.stderr.read(256), ...)`. A
sync `.read()` returns bytes, not a coroutine, so `wait_for` raised. The
extract step survived only because it uses `communicate()` (async-wrapped).
The wrapper now advertises `uses_sync_pipes`; the streaming reader keys off
it and, on that loop, runs the process to completion via the wrapper's async
`communicate()` and replays stderr as the same `('stderr', line)` events —
no live progress bar on the degraded loop, but demucs actually runs. The
native async path (Proactor/posix — every release build, macOS, Linux) is
untouched. Regression test covers the fallback (fails before, passes after)
and pins the native path.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
When the backend is torn down while the TTS model is loading — the user closes
the window during first-load, uvicorn is told to stop, or a port bind fails —
transformers' weight materializer (which runs in its OWN thread pool) raises
`RuntimeError: cannot schedule new futures after interpreter shutdown`. That was
caught by the generic model-load handler and logged as "Model loading failed"
with a full traceback + a phantom /model/status error, so a normal shutdown
looked like a crash in the backend-crash report.
Recognise the interpreter-shutdown teardown (walking __cause__/__context__,
since transformers' lazy-import machinery buries it several layers deep, and
matching "interpreter shutdown" specifically so an ordinary single-pool reset
is NOT silenced) and log it calmly instead. Unit-tested.
Note: this is about how a shutdown-during-load is *reported*; the port-bind
collision that triggered it in testing is a separate, multi-instance race the
Rust bootstrap's attach-or-reclaim logic already prevents in normal use.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The BackendCrashNotice banner was `fixed top-[var(--space-4)] z-[70]` — pinned to
the very top of the window, where `.header-area` (the navbar) sits at
z-index:100. The higher-z navbar painted over the banner, tucking the alert and
its "View crash details" button underneath it (reported: the button is hidden
under the navbar).
Anchor the banner just BELOW the navbar (top: calc(var(--space-4) + 2.25rem),
clearing the ~2rem header) and raise it above the navbar's stacking level
(z-110) so the whole alert and its actions are always visible and clickable.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`scripts/setup.py` prints ✓/⚙ status glyphs. When its stdout is a pipe rather
than an interactive console — which is exactly the case under `bun run
setup:api` and in CI — Windows Python encodes with the cp1252 codepage, which
can't represent those characters, so the script dies with UnicodeEncodeError
mid-setup and takes `bun desktop` down at the setup:api step. It only "works"
interactively by luck of the console encoding.
Reconfigure sys.stdout/sys.stderr to UTF-8 (errors="replace") at startup so the
output is identical whether run interactively or piped. No-op where the streams
already speak UTF-8 (macOS/Linux, modern Windows Terminal) or can't be
reconfigured. Verified: `bun desktop` from a stale terminal now completes
setup:api with piped stdout and launches the app.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(dev): self-heal cargo PATH so `bun desktop` works from a stale terminal
`tauri dev` shells out to cargo, so `bun desktop` died with "failed to run
'cargo metadata' ... program not found" on any terminal opened before rustup
was installed — the shell holds a stale PATH snapshot without ~/.cargo/bin even
though cargo is installed and on the persisted User PATH (a new terminal finds
it). That's a confusing first-run-from-source papercut, hit repeatedly on
Windows.
The frontend `desktop` script now runs through scripts/desktop-dev.mjs, which
prepends ~/.cargo/bin when cargo isn't already resolvable, then launches
`tauri dev` with that healed env. Cross-platform (~/.cargo/bin everywhere), a
no-op when cargo is already on PATH, and it passes an explicit env with the
correct-case Path key (Bun doesn't propagate process.env mutations to children,
and Windows uses "Path" not "PATH"). If Rust isn't installed at all, it prints
an actionable install hint instead of the cryptic cargo error. Verified E2E:
from a cargo-less PATH, `tauri dev` now compiles instead of failing.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(changelog): note the bun desktop cargo-PATH self-heal (#1180)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: debpalash <tapudattaht@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The "auto" region previously did a single HEAD to github.com and picked
"global" if it answered at all — so a painfully slow-but-reachable GitHub still
routed first-run downloads (python-build-standalone, ffmpeg) direct instead of
through the faster ghproxy mirror. Now auto-detect PINGS BOTH paths in parallel
and picks the fastest: it stays direct on an unthrottled network (GitHub wins,
no proxy hop) and hands off to the mirror when GitHub is slow (≥20% slower) or
unreachable. Both probes run concurrently, so the check still costs one timeout.
The direct/mirror decision is factored into a pure `pick_region` helper with
unit tests (no network needed); explicit regions (china/russia/restricted) are
unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The bootstrap fix hid the console windows the Rust shell spawns, but the running
backend is a second, larger source: it makes 70+ subprocess spawns (ffmpeg for
dubbing, engine sidecars, yt-dlp, demucs, WhisperX), only one of which set any
creation flag — and third-party libraries (imageio-ffmpeg, yt-dlp) shell out to
ffmpeg themselves. Since the backend runs console-less, on Windows each console
child got a fresh cmd window flashed on screen during generation/dubbing.
Patch subprocess.Popen once at backend startup (before anything spawns) to OR
CREATE_NO_WINDOW into every child's creationflags — covering run/call/
check_output (all built on Popen) and every stdlib-using library, from a single
auditable choke point. Composes with the existing CREATE_NEW_PROCESS_GROUP and
honours an explicit CREATE_NEW_CONSOLE. No-op on macOS/Linux. Unit-tested.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two Windows production-only regressions found by installing and running the
released v0.3.22 MSI on a clean machine (RTX 4070 / CUDA).
1. Black screen on every launch. The first-run splash crashes React during
render with "Cannot access 'logs' before initialization" — an empty #root,
i.e. a black window. Root cause: in BootstrapSplash the derived const
`isUnrecoverable` (which reads the `logs` useState result) sat *between* two
`useState` calls. esbuild's release minifier merges the declarations into one
comma-list and can hoist the read ahead of its `useState` binding, producing
a temporal-dead-zone access. Dev, unminified, and the e2e suite (which runs
against the un-minified dev server) never trip it, so it shipped. #1159 fixed
the source-visible ordering but left the derived value wedged among the
hooks, so the minifier landmine remained. Fix: declare every hook before any
derived const that reads a hook result — the minifier then has nothing to
reorder. Verified deterministically: the shipped bundle contains exactly one
use-before-init-useState hazard at this site; a build of this change contains
zero, and the app renders. Guarded by tests/frontend/bootstrap-splash-hook-order.
2. Storm of console windows during first-run install. Bootstrap spawns dozens
of helper processes (uv venv/sync, managed-python, capability probes); on
Windows each flashed its own cmd-style console window because run_streaming
and the direct probes never set CREATE_NO_WINDOW. Added one Windows-only
`no_window` chokepoint in tools.rs and routed every bootstrap/tools/backend
subprocess through it (mirrors the flag the backend spawn and nvidia-smi probe
already set). stdout/stderr are still piped to the splash log. No-op on
macOS/Linux — default behaviour there is unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Self-hosting behind a reverse proxy or on a LAN used to force a blunt choice:
OMNIVOICE_SERVER_MODE (trust every non-loopback source) or the API-key/PIN gates
— which a proxy that strips the Authorization header breaks for browser clients
entirely.
Add OMNIVOICE_TRUSTED_NETWORKS (comma-separated CIDRs) whose addresses are
treated as trusted by the CONSUMPTION gates (PIN/API-key middleware, dictation
WebSocket) via is_local_host — a LAN/proxy client is exempted from consumption
auth. Admin routes (require_loopback → /system/set-env, /api/settings/*) stay
true-loopback-only (is_loopback, not is_local_host) to preserve the two-tier
privilege model: consumption trust ≠ admin trust (RCE-class surface). Opt-in,
default empty → zero behavior change. The granular companion to server-mode (#261).
Tests: is_loopback / is_local_host / require_loopback contract for trusted CIDRs,
adjacent subnets, malformed entries, the two-tier split (trusted-network rejected
by the admin gate), and the default (no-trust) case. Docs + CHANGELOG.
Only the TTS model (~2.4 GB) is required on first run; ASR models are
per-platform curated picks (curated_on in models.yaml) installed on demand.
Every transcription surface returns a typed asr_model_missing error with a
one-click download CTA instead of silently pulling multi-GB Whisper weights.
Settings -> Models is a grouped, platform-aware catalog. New guided
permissions UX (wizard System Check + Settings -> Permissions + mic
pre-flight) with native mic-state checks and OS settings deep-links. New
parakeet-mlx engine brings Parakeet TDT v3 to Apple Silicon (language-gated
capture preference so multilingual dictation never regresses). Docs:
expressive-speech page, Flush/Unload + CPU-fallback triage, clone-length FAQ.
Hardening: preflight fails open for custom model pins, ROCm curation no
longer inherits NVIDIA picks, Windows mic probe reads the NonPackaged
consent key, CaptureWidget setup race fixed, offline-cache CI simulation
fixes so empty-cache runners stay green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
POST /v1/audio/speech returned synthetic audio without the AudioSeal
provenance watermark while /generate marked the same text — and the
audit behind #1169 found the same class of gap in five more producers.
EU AI Act Art. 50(2) (applicable 2026-08-02, expressly carved out of
the open-source exemption in Art. 2(12)) makes machine-readable marking
of synthetic audio a provider obligation, so per-door coverage gaps are
compliance bugs.
Root cause: coverage grew call-site-by-call-site (three separate
embed_watermark calls) with nothing forcing a new producer to opt in.
The fix, per the whole-class rule:
* services/watermark.py grows `mark_synthetic(wav, sr, *, context,
force=False)` — THE named chokepoint (delegates to embed_watermark:
pref-gated, no-op without AudioSeal, never raises / degrades to
unmarked) — plus `will_mark()` for cache-key derivation.
* Gaps closed at the tensor stage, before any encoding:
- openai_compat `_run_tts` (the reported gap; all response_formats)
- tts_stream /ws/tts (per-sentence, before PCM16 framing)
- generation stream=true preview chunks (marked streamed copy; the
saved take keeps its single whole-take mark in finalize)
- batch dub pipeline (assembled track, before WAV write / aac mux)
- longform chapter render — covers /audiobook, /longform/render
(Stories), /audiobook/preview and /audiobook/resume; the chapter
cache key now carries a watermark tag so stale unmarked cache
entries can never be served for a marked-on render
- dub preview-segment (docstring had declared it exempt)
- archetype render (served preview + materialized profile reference)
* Already-covered paths (generate finalize, dub segments, persona
bundles) migrated onto the same chokepoint.
* Documented non-producers/gaps instead of fake coverage:
/stories/encode is a pure transcoder of user uploads (must NOT mark);
the opt-in SoniTranslate sidecar synthesizes+muxes externally and is
a documented provenance gap at its route.
* Regression tests: tests/test_synthetic_audio_watermark_1169.py runs
detect_watermark() on the actual response audio of every producing
route (real watermark service, fake AudioSeal nets, fake engine;
verified fail-before on the pre-fix tree — 11 of 13 fail).
* Recurrence-proofing: tests/test_watermark_route_coverage.py
structurally asserts every synthesis call site references
mark_synthetic (justified allowlist), producers keep their call,
and embed_watermark is never called outside the chokepoint.
Settings toggle semantics are unchanged (the issue's two legal
questions stay flagged for a lawyer, deliberately unanswered here).
Two small README/packaging fixes.
1. Add a self-updating total-downloads badge next to the stars badge.
shields.io re-queries the GitHub releases API on every page view, so the
number (currently ~137k across 563 release assets) stays current without
ever editing the README again.
2. Fix license detection. GitHub's API reported this repo as
"Other" / NOASSERTION, which breaks the license UI and the corporate
license scanners that gate adoption — the exact users the commercial
license exception is for.
The cause was packaging, not content: LICENSE carried a 53-line
plain-language notice prepended above the AGPL text, which pushes the
file below licensee's similarity threshold. The AGPL-3.0 body was
already byte-identical to the canonical text at gnu.org.
LICENSE is now the verbatim canonical AGPL-3.0 text and nothing else.
The notice — including the commercial-license offer, the scope section,
and the Apache-2.0 carve-out for the bundled omnivoice/ model — moves
verbatim to LICENSE-NOTICE.md, linked from LICENSE and the README.
No terms changed.
Co-authored-by: user <user@users-MacBook-Air.local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The two builds flip-flopped against the runner disk ceiling: CUDA hit
ENOSPC this morning (fixed by adding the reclaim step), then ROCm hit it
this afternoon even WITH the original list. Reclaim everything neither
job can use (~40-45 GB: full hostedtoolcache, swift, boost, global
node_modules, apt cache) and keep the list byte-identical in both jobs
so they can't drift apart again.
Agent-built contributions are common; re-explaining a codebase this size
every session wastes context and tokens. Point contributors at a local
MCP memory layer (memxt, by the maintainer — disclosed) and the repo's
skills package so agents start with the hard rules loaded.
The ROCm job got the reclaim step when it shipped; the CUDA job kept
running on default free space and finally hit 'No space left on device'
during uv pip install (2026-07-16, two consecutive main builds red).
Mirror the same ~25-30 GB toolchain cleanup.
The route did 'from core import job_store' at call time, re-resolving
through sys.modules on every request. Suites that purge and re-import
the core/services tree under a temporary data dir leak that stale world;
after one ran, the route's call-time import saw a different DB_PATH and
returned an empty library despite seeded jobs — the order-dependent
test_route_handler_returns_jobs_envelope full-suite flake. Also: a
failing list_jobs now logs error-level with the stack (it logged a
warning while silently serving an empty library).
Fail-before regression test simulates the leaked world; full combined
suite green (3225 passed).
Extends notebooks/OmniVoice_Studio_Colab.ipynb (setup cells 1-7 unchanged —
owner-verified on a real T4) with a Part 2 feature tour: one self-contained,
idempotent cell per feature, inline playback, honest runtime notes, loud
failures pointing at the backend log. Covers multilingual TTS (/generate),
zero-shot cloning (ref_audio multipart, incl. a commented own-voice upload
variant), voice design (/design/describe -> instruct), voice profiles
(save/list/reuse by id), TTS->ASR round trip (/transcribe), AI-watermark
detection with a generated-vs-plain-tone contrast (/watermark/*), the
OpenAI-compatible /v1 audio API via the official openai client, a two-voice
story (/longform/render SSE), a chaptered m4b audiobook (/audiobook SSE),
an optional miniature EN->ES video dub (upload -> prep poll -> transcribe ->
translate -> generate -> mux), and vocal-isolation stems off the dub job.
Every endpoint verified against the router sources and the 254-route
inventory. README/README_CN section text and the CHANGELOG entry now
describe the expanded scope.
Docs-sync for the first-run analytics consent work:
- README + README_CN FAQ ('Does OmniVoice collect any data?'): the app now
ASKS on first run; still nothing without an explicit yes; lists the new
event kinds (install/update/crash/error-type/uninstall ping) and that
source builds never even ask.
- CLAUDE.md: the Local-first constraint bullet and the 'no third-party
telemetry endpoints' note now reflect the owner-sanctioned reality
(opt-in PostHog EU product analytics behind the first-run prompt,
2026-07-16); auto BUG REPORTING stays GitHub-prefill only.
- backend/api/routers/system.py: the /stats/usage comment claimed PostHog
was rejected outright (PR #1110) — reworded: this endpoint stays local
regardless of consent; the consent-gated path lives in core/analytics.py.
- CHANGELOG [Unreleased]: user-facing entry for the consent prompt + new
lifecycle events.
Before deleting anything, uninstall.sh / uninstall.ps1 now send ONE
best-effort `app_uninstalled` event — but only when the user opted in:
consent is read from the same prefs.json the app writes, AND the ping needs
the backend-written analytics_info.json (present only while analytics is
enabled — consent + build token — and removed on opt-out), because the
generic scripts ship no token of their own. Payload is content-free: app
version, OS name, random per-install id. 2-second timeout, silent failure,
one honest console line ('Sending anonymous uninstall ping (you opted in to
analytics).'); not opted in => nothing sent, nothing printed, and the
dry-run never sends either way.
Tested by exercising the bash script for real (fake $HOME + a curl shim
recording argv: consented/not-consented/dry-run/missing-info flows, plus
data-still-deleted-after-ping) and by static contract checks on the ps1
(consent gate, TimeoutSec 2, try/catch, no baked token). docs/install/
uninstall.md documents the behavior (docs-sync).
New content-free events on the same rails as everything else (dual gate:
build token AND explicit consent; allowlisted metadata only; never raises):
- app_installed — once per install, fired the first time the install is both
consented and configured (consent lands mid-first-run, so the marker is
only burned when the event was actually sent).
- app_updated — persisted last-version marker vs current; from_version /
to_version. The marker advances on every startup, consented or not, so a
later opt-in never replays pre-consent history.
- app_crashed — ONE authoritative source: the backend run sentinel (#1164).
The desktop shell's markers describe the same deaths (its watcher restarts
the backend, whose next startup finds the sentinel), so the frontend never
emits a crash event — no double-count, pinned by test. Props: exit_kind,
coarse stage, BUCKETED uptime (never raw seconds), version, os.
- error_occurred — hooked into core.error_journal.record: error_class +
route-head stage only, deduped by journal fingerprint, hard cap 10/session.
Allowlist extended (from_version/to_version/exit_kind/uptime_bucket/
error_class/stage) in backend and frontend alike, now pinned equal by a
locked mirror test. Also writes DATA_DIR/analytics_info.json (present iff
analytics is enabled; removed on opt-out) so the uninstall scripts can send
a consent-gated app_uninstalled ping without any baked token.
The opt-in PostHog analytics existed but was buried in Settings → Privacy,
so release builds produced no OSS-useful data. Consent is now ASKED, once:
- SetupWizard gains a consent step (between models and dictation) with two
equal-weight Yes/No buttons — shown only in builds that ship a destination
token and only if the user was never asked. Skipping the wizard or jumping
past via the rail = not prompted = analytics stays OFF.
- Existing installs (the wizard never reruns) get a one-time dismissible
banner on app start with the same choice; dismiss counts as No. Any
explicit choice marks analytics_prompted, so the ask never repeats.
- Backend: set_opted_in() also persists the new analytics_prompted pref;
GET /api/settings/analytics reports it. A broken prefs file reads as
'not prompted' (may re-ask) but never as consent (fails closed).
- All strings via i18n across the 21 locales.
The privacy promise is unchanged: nothing is sent without an explicit yes,
silence is not consent, and source builds (no token) never even ask.
notebooks/OmniVoice_Studio_Colab.ipynb boots the full app (web UI included)
on a free Colab T4: frontend built in-notebook with bun (releases ship no
standalone web bundle), backend installed via 'uv pip install --system .'
(the Docker image's path — keeps Colab's preinstalled CUDA torch), cuDNN 8
compat via scripts/setup.py, UI exposed through Colab's built-in kernel port
proxy (cloudflared alternative documented in-cell). Optional HF-token cell
reads Colab Secrets; smoke-test cell hits /health and plays a real /generate
WAV inline. All cells idempotent and fail loudly with actionable messages.
README.md + README_CN.md: the Colab section now carries the Open-in-Colab
badge for the in-repo notebook. CHANGELOG: [Unreleased] Added entry.
The give-up branch now probes GET /system/last-run-crash in browser mode
(the #1164 HTTP fallback), so the two tests that asserted a raw fetch-call
count were counting a diagnostics probe as a retry — count only the calls
against the requested path instead. Also register ov_last_backend_contact
in the factory-reset key registry as PRESERVED: it is crash evidence
(session-scoped last-response timestamp), not a preference, and wiping it
mid-incident would erase the 'was it ever answering?' signal.
Regenerated with scripts/dump_api_routes.py — the inventory guard
(tests/test_api_route_inventory.py) rightly flagged the two routes the
run-sentinel forensics added.
In dev there is no supervisor: concurrently's --kill-others-on-fail tears
the whole stack down the moment uvicorn exits, the cause scrolls away with
the terminal, and the browser tab just says it can't reach the backend —
which is exactly how #1164 arrived with zero diagnostics.
- scripts/dev-backend.mjs: dev:api now runs uvicorn through a wrapper
(command args byte-identical, stdio inherited). On a non-Ctrl+C, non-zero
exit it prints a boxed banner: exit code/signal, the last 20 lines of
omnivoice.log (data dir resolved exactly like backend/core/config.py),
an OOM hint (SIGKILL/137 + the Linux journalctl -k check), and a pointer
to the crash notice the run sentinel raises on the next backend start.
Exits with the child's own code so --kill-others-on-fail still works.
Verified live: started the dev backend, SIGKILLed it, banner printed
with the real log tail and exit code 137.
- docs-sync: troubleshooting.md gains §14c (browser/dev/Docker crash
forensics: the mode-aware error, the dev banner, run_sentinel.json /
last_run_crash.json / GET /system/last-run-crash, cap+ack+version-gate
semantics) and §14's crash-notice blockquote no longer implies the
notice is desktop-only; CONTRIBUTING.md documents the dev:api wrapper.
- CHANGELOG.md: [Unreleased] entry for the #1164 class fix.
Tests: tests/frontend/devBackend.test.mjs (5) — the uvicorn args are
pinned byte-identical, data-dir resolution mirrors config.py, tail/banner
content incl. the OOM shapes.
The crash-notice UI (#941) was desktop-only: utils/backendCrash.ts returned
null outside the Tauri shell, so browser/dev/Docker users never saw the
honest 'the backend died' story even now that the backend records it itself
(the run sentinel, previous commit).
- backendCrash.ts: outside Tauri, getLastBackendCrash()/
getUnacknowledgedBackendCrash() fall back to GET /system/last-run-crash
(2.5 s timeout, every error swallowed to null — while the backend is DOWN
this fails instantly and the mode-aware message stands; the record becomes
fetchable once the backend is back). The record is adapted to the existing
CrashMarker shape — exit_desc 'process ended uncleanly (previous run)'
(describeCrashExit falls through to exit_desc on null code+signal),
uptime from the sentinel's uptime hint, last_stderr = last-activity line +
the scrubbed omnivoice.log tail. acknowledgeBackendCrash() POSTs the ack
watermark. Result: BackendCrashNotice.jsx and apiFetch's crash branch
light up in every deployment with ZERO changes to them. The fallback uses
raw fetch (with the PIN/API-key headers a LAN/remote deployment needs),
never apiFetch — whose give-up path calls back into this module.
- bugReport.js: new '## Backend reachability' section — deployment mode,
cached last backend contact (reported even while the backend is down and
the live fetches fail), and, when the report is built from a transport
ApiError, its structured diagnostics (first failure, attempts, mode,
scrubbed transport error). The existing crash section now rides along in
browser reports too, via the HTTP fallback. Still bounded by the encoded
URL ceiling and scrubbed end to end.
Tests: backendCrash.http.test.ts (adapter + fallback contract, 7),
BackendCrashNotice.browser.test.jsx (end-to-end browser smoke over a
stubbed HTTP backend, 2), bugReport.test.js reachability section (4).
The give-up error for a transport failure was one-size desktop copy —
'restart the app (or check Settings → Logs → Backend)' — which is
meaningless advice in the two deployments that have no shell: a
'bun run dev' browser session and a Docker/LAN 'server' page. And it never
said the one thing that splits this bug class in half: did the backend
ever answer this session?
- utils/backendContact.ts: apiFetch records a contact timestamp on EVERY
response (success and HTTP error alike — both prove the process is
alive; module var + sessionStorage so a reload mid-outage remembers).
describeLastContact() then tells one of two honest stories: 'it was
answering Xs ago and then stopped — most likely crashed or was killed
mid-request' vs 'it has not answered at all this session — it may never
have started'.
- utils/deploymentMode.ts: desktop (Tauri) / dev (Vite) / server (page
served by the backend itself — Docker, LAN share, remote GPU).
- api/client.ts: the generic give-up branch now builds the message from
mode + last contact. dev → points at the bun-run-dev terminal and
omnivoice.log; server → docker logs/journalctl, noting the page itself
can go down with the backend when Docker serves it. Desktop keeps its
existing copy (crash markers + lifecycle stages already cover it).
Every status-0 ApiError now carries structured diagnostics in .detail:
{transport, mode, lastContactMs, firstFailureTs, attempts} for the
bug-report prefill (no consumer read the old string detail).
- i18n: new backendUnreachable.* keys in en.json, mirrored with faithful
translations into all 20 other locales. The messages resolve through
i18next when initialized and fall back to self-interpolated English
when not (node:test loads client.ts without the app bootstrap) — the
diagnostics path must never crash on the localization layer.
Tests: src/test/client.unreachable.test.ts (12 new) — mode detection,
both last-contact stories end-to-end through apiFetch, contact recorded
on HTTP-error responses, and the ApiError.detail contract; client.test.ts
updated for the structured detail.
Backend process deaths (OOM kills, shutdown races) were only diagnosable
through the DESKTOP shell's crash markers (src-tauri/src/crash.rs). A
'bun run dev' browser session, Docker, or a LAN-share client has no shell,
so the same death produced "Can't reach the local OmniVoice backend" with
zero diagnostics — issue #1164's exact shape (crash_log.txt only records
CAUGHT route exceptions, never process death).
New core/run_sentinel.py makes the backend self-forensicate,
deployment-agnostically:
- lifespan startup writes DATA_DIR/run_sentinel.json {pid, started_at,
version, last_activity}; clean shutdown (incl. uvicorn --reload restarts,
which run the lifespan shutdown) removes it — verified by
test_write_then_clean_clear_yields_no_crash.
- next startup: a leftover sentinel with a dead pid = the previous run died
uncleanly → a record in DATA_DIR/last_run_crash.json with the death
window (ended_between), last known activity, version, and a scrubbed
40-line tail of omnivoice.log (core/scrub.py).
- a leftover sentinel whose pid is ALIVE (psutil, with create_time defeating
pid reuse) is a concurrent second instance — never reported as a crash,
its sentinel left untouched.
- touch_activity() marks meaningful work starts (task-manager worker,
/generate with engine id, dub transcribe, cold TTS model load) — closed-set
identifiers only, throttled to one small write per 2 s, never raises.
Record-store semantics deliberately mirror crash.rs: newest-first cap of 3,
ack is a timestamp watermark (never deletion — bug reports keep the
evidence), reads are version-gated in memory and never write.
API: GET /system/last-run-crash (+acknowledged), POST
/system/last-run-crash/ack, and a 'last-run-crash-<ms>' error notification
in /system/notifications (id embeds detected_at so a re-crash re-notifies;
coexists with the crash-last-session heuristic).
Fail-before/pass-after: backend/tests/test_run_sentinel.py (14 tests) — an
unclean shutdown previously yielded no backend-side record at all.
docs(specs): dictation flow program — local WhisperFlow-class dictation on Parakeet
Three-agent research synthesis (product landscape, in-repo capability
map, Parakeet/Nemotron feasibility) distilled into a six-phase plan:
VAD + true-streaming Parakeet, personal dictionary + hotwords,
app-aware/agent-prompting modes, insertion reliability + Wayland chain,
local command mode, docs/evals.
docs: add Trendshift badge to the GitHub and Docker Hub readmes
docs(changelog): add the #1162/#1161 fixes to [Unreleased]
fix: prevent probeAudioDuration from hanging on unresolved Promise (#1162)
* fix: prevent probeAudioDuration from hanging on unresolved Promise
The original Promise constructor only accepted `resolve` — no `reject`
callback. The `error` event handler called `resolve(null)` instead of
rejecting. If the Audio element never emits `loadedmetadata` or
`error` (rare browser conditions, GC races, invalid blob URLs), the
Promise hangs forever with no settlement path.
Added:
- 10-second timeout that rejects if neither event fires
- Proper rejection on error event
- `{ once: true }` on listeners to prevent double-invocation
- Dedicated cleanup function
* fix: settle probeAudioDuration with null on error/timeout instead of rejecting
The 10s timeout stays (a media element that never fires any event
genuinely hung this promise forever — verified). But both failure paths
now resolve(null) rather than reject: the only caller, ingestRefAudio,
awaits without a try/catch, and a clip this webview can't decode must
still be accepted — the backend decodes it with ffmpeg (Tauri WebKit
lacks several codecs). Regression test covers all four behaviors,
fail-before verified against the reject() version.
* style: oxfmt pass on format.js
---------
Co-authored-by: mergetest <nizam4103@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
fix: return empty text instead of partial garbage when EPUB HTML parse fails (#1161)
* fix: return empty text instead of partial garbage when EPUB HTML parse fails
`_html_to_title_body` used `except Exception: pass` when the
HTMLParser raised. The parser accumulates state incrementally across
`handle_starttag`/`handle_data`/etc. callbacks — so a mid-parse
failure returns whatever partial/corrupted `title` and `_parts`
were accumulated before the error, with zero indication anything went
wrong. EPUB chapter content would be silently garbled.
Now returns ("", "") and logs the parse failure with traceback.
* fix: keep partial chapter text when EPUB HTML parsing fails mid-chapter
The warning log stays — a silent 'except: pass' hid real failures. But
returning empty text made the caller's 'if not body.strip(): continue'
silently drop the whole chapter from the audiobook, trading a possibly-
truncated chapter for a definitely-missing one. Keep whatever the
extractor collected before the failure and log the event. Regression
test builds a real 3-chapter EPUB, injects a mid-chapter parser failure,
and asserts the chapter survives with its pre-failure text (fail-before
verified against the empty-return version).
---------
Co-authored-by: mergetest <nizam4103@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
docs(readme-cn): full retranslation — sync with the current English README
docs(readme-cn): mirror today's README refresh into the Chinese translation
docs(readme): give the maker's other apps their due — logos, details, and a sponsor-section cross-link
The 'more local open-source' section grows from a bare two-row table
into proper cards: verified logo assets from each repo, a one-line
pitch plus grounded detail (from the repos' own metadata), star badges,
and links. The Sponsor/Donate section gets a one-line cross-promo —
'more apps from the creator' — since a star on a sibling project is
support too.
docs(readme): tighter, kinder to new users
Cut the repetition, keep the charm: the local-first pitch was made three
times before the first screenshot (quote + cell table + CTA block — now
just the quote), Discord had four CTAs (now two), and the Intel-Mac
caveat appeared three times (now once per context, linked). Screenshots
trimmed 8 -> 6. Engine name lists that repeated the validated matrices
inline (Why table, FAQ) now point at the matrices instead. Fixed the FAQ
claiming '10 TTS engines' (it's 14). Added a download button to the hero
so a new user's path from first glance to installed is one click.
Docs-drift + install-docs validators and the CJK guard all pass.
docs(docker): refresh the Docker Hub overview and docker guide
- Add a what-you-need line (RAM/disk/GPU from the README requirements
table, compressed pull sizes measured from the registry) so homelab
users can size the deployment before pulling.
- Update stale version examples (:0.3.6 / :0.3.17 -> :0.3.22).
- Fix the 'main is always one patch ahead' claim — with
AUTO_VERSION_BUMP off, main can equal the released version; say
'at or ahead of the last release', which is true in both modes.
chore(release): codify all deployment channels as release rules; preview always builds from main
A release now has an explicit channel checklist (docs/RELEASING.md §5b):
GH Release + stable updater manifest, preview updater channel, GHCR +
Docker Hub in both CUDA and ROCm flavors, and the Docker Hub overview
sync (whose continue-on-error step must be verified by step log — it
403s silently on tokens without description-edit scope).
Preview/RC policy is now enforced, not just documented: release.yml's
preview-gate fails publish_preview dispatches from any branch but main,
since the preview manifest and rolling Docker tags all track main.
Also fixes docs/RELEASING.md §4-5, which still described the pre-2026-06
versioning scheme (tauri.conf.json + Cargo.toml as sources, 'Tauri
ignores package.json') — the exact opposite of the current single-source
rule — and docs/update-channels.md, which invited previews off feature
branches.
docs(changelog): add the #1159/#1160/#1158 fixes to [Unreleased]
#1159 and #1160 merged without changelog entries; also covers the
useAppData silent-catch logging absorbed from declined PR #1158 and the
#1160 follow-up traceback sweep.
docs(event-bus): document the single-loop no-await invariant in _broadcast (#1163)
The QueueFull-recovery block was misread as racing with consumers (an
AI-generated PR proposed "fixing" it). Pin down why it is correct so the
misreading does not recur. Comment-only change.
fix(frontend): log swallowed data-loading failures in useAppData (absorbed from #1158)
The five data-loading callbacks (profiles, generation history, dub
history, projects, export history) swallowed API failures with empty
catch blocks — a failed fetch looked identical to an empty library, and
"my voices vanished" reports carried nothing to diagnose. Each catch
now console.warn-s with an accurate per-fetch message (the declined PR
copy-pasted mismatched ones). The backend-startup retry loop and the
localStorage-restore catch are left silent on purpose: failing there is
the expected path they exist to absorb.
Co-authored-by: bultodepapas <bultodepapas@gmail.com>
fix(backend): stop discarding tracebacks in error-level exception logs (#1160 follow-up)
#1160 fixed one traceback-losing logger.error in dub_pipeline.save_job;
this sweeps the remaining class. 19 sites across 10 files where a real,
unexpected failure was summarized as "...: %s" at ERROR level — losing
the stack trace that makes crash reports diagnosable — now use
logger.exception (diarization/ASR crashes, dictation load/final
failures, ffmpeg mixes that silently degrade output, Smart Fit retime
fallbacks, RVC init/inference, models.yaml catalog load, gallery
search/download 500s, dub-history JSON decode, MCP CLI fatal exit).
Deliberately left alone: WARNING/INFO/DEBUG logs, expected classes with
self-sufficient messages (GPU/ASR timeouts, request validation,
cryptography-availability checks), sites that re-raise immediately
(db migration, _ensure_mcp), subprocess returncode checks where stderr
IS the diagnosis, and sites already logging exc_info/format_exc.
test(bootstrap): render regression test for the stage="failed" TDZ crash (#1159)
The #1159 fix merged without a test: the existing
BootstrapSplashFailedRecovery.test.jsx only exercises the
useBootstrapStage hook, so nothing ever rendered <BootstrapSplash
stage="failed"> and CI could not see the ReferenceError. These tests
mount the failed card directly (recoverable + unrecoverable variants);
both throw on the pre-fix line order and pass on the fixed one.
fix: log full traceback when dub job persistence fails (#1160)
`save_job`'s `except` block caught the re-raised exception from
`db_conn` (which rolls back and re-raises on failure) but only logged
the error message without the traceback — making the root cause
undiagnosable in production. Changed `logger.error` to
`logger.exception` so the full stack trace is preserved.
fix: prevent TDZ ReferenceError crash when bootstrap fails (#1159)
`const isUnrecoverable` on line 384 referenced `logs` which was
declared on line 385 via `useState([])`. When `isFailed` is true
(backend setup failure), the short-circuit `&&` accesses `logs` in
its temporal dead zone, throwing ReferenceError and crashing the splash
screen — precisely when the user most needs the error UI.
Moved the useState declaration above isUnrecoverable.
feat(docker): publish ROCm/AMD GPU image variant (#1165) (#1166)
The Docker image was CUDA-only, so AMD GPUs (e.g. RX 7900 XTX under
Podman) silently ran on CPU. Every preview and release now also ships a
ROCm variant built from the same Dockerfile:
- deploy/Dockerfile: parameterize the runtime base with a BASE_IMAGE
build-arg (default unchanged: pytorch/pytorch 2.8.0 CUDA). Add
PIP/UV_BREAK_SYSTEM_PACKAGES for the ROCm base's PEP-668-marked
Ubuntu 24.04 Python (no-op on the conda CUDA base), and a build-time
GPU_FLAVOR guard asserting the dependency install did not clobber the
base image's GPU torch/torchaudio — a future dep bump that forces a
torch reinstall now fails the build instead of shipping a CPU-only
"ROCm" image.
- .github/workflows/docker.yml: new build-and-push-rocm job (separate
job for runner disk — the ROCm base is ~25 GB unpacked, so it frees
the preinstalled toolchains first). Tags mirror the CUDA semantics
with a -rocm suffix (:rocm rolling preview, :stable-rocm, :X.Y.Z-rocm,
:X.Y-rocm, :sha-xxxx-rocm) on both GHCR and Docker Hub, same secret
gating. flavor latest=false so release tags can't clobber :latest.
No cache-to: the ROCm layers would blow the 10 GB GHA cache budget.
- deploy/docker-compose.yml: new opt-in 'rocm' profile passing the GPU
through via /dev/kfd + /dev/dri, with HSA_OVERRIDE_GFX_VERSION=11.0.0
documented (user-set, not baked in — backend auto-sets it for known
consumer GFX IDs).
- Docs-sync: docker.md (ROCm quick start incl. Podman/Quadlet, tag
table, troubleshooting), dockerhub-overview.md, README AMD note,
linux.md ROCm section cross-link, CHANGELOG [Unreleased].
Base image: rocm/pytorch:rocm7.2.4_ubuntu24.04_py3.12_pytorch_release_2.8.0
— torch 2.8.0 exactly matches the CUDA image (identical resolution, so
uv keeps it), py3.12 satisfies requires-python >=3.11 (the ubuntu22.04
variants are py3.10 and do not).
Closes#1165
Co-authored-by: mergetest <nizam4103@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
docs(changelog): add the #1156/#1153/#1155/#1152 fixes to [Unreleased]
fix(dub): diagnose export failures honestly; dodge the Windows 32K argv limit (#1152)
A Windows dub export died at ffmpeg *spawn* with [WinError 206] — the
mux argv scales with tracks/segments and exceeded CreateProcess's 32,767-
char limit — but the catch-all told the user their (8-char) filename was
too long AND to check whether ffmpeg was installed.
- explain_ffmpeg_failure() maps the three real failure modes to their own
advice: argv-too-long (names the limit + the actual size, suggests fewer
languages per export), ffmpeg-unlaunchable (the only case that suggests
checking the install / FFMPEG_PATH), and ffmpeg-ran-and-failed (surfaces
ffmpeg's stderr, no install advice). All three dub_export catch sites
(video mux, audio export, MP3 encode) now use it.
- run_ffmpeg() on Windows moves an oversized -filter_complex graph (the
dominant argv consumer — the bed-mix/apad branches grow per track) into
a -filter_complex_script temp file, so the spawn never hits the limit
in the first place; the script file is removed after the run.
Regression tests: tests/test_ffmpeg_failure_diagnosis.py.
fix(win): stop the Fortran-runtime console-close abort and cp1252 UnicodeEncodeError crash classes (#1153, #1155)
Two Windows-only backend crash classes, one boundary (process spawn/stdio):
forrtl: error (200) (#1153 and the crash markers in #1155/#1152): MKL's
Intel Fortran runtime installs a console CTRL handler that aborts the
whole backend (exit 2 / 0xC000013A) when a console CLOSE/LOGOFF event
reaches it. The backend was spawned with no console isolation, so OS
console events could reach it mid-session. Now:
- the desktop shell spawns the backend with CREATE_NO_WINDOW |
CREATE_NEW_PROCESS_GROUP (no console → no console events, stdio is
piped anyway) and sets FOR_DISABLE_CONSOLE_CTRL_HANDLER=1;
- backend/main.py setdefaults the same var before torch/numpy can load
MKL, covering scripts/run.sh and bare uvicorn launches too.
'charmap' codec can't encode (#1155): kittentts print()s the user's text
on every generate; on Windows the child's stdout is cp1252, so Vietnamese
text raised UnicodeEncodeError and surfaced as a bogus '400 Bad Request'.
The process-wide SafeFileWrapper only swallowed OSError (its EPIPE job).
Now:
- stdio is reconfigured to UTF-8 (errors=backslashreplace) at startup;
- SafeFileWrapper also swallows UnicodeError — logs are best-effort,
synthesis is not;
- the shell sets PYTHONUTF8=1 for the child (Windows→parity with
macOS/Linux; process env wins for power users);
- the crash-log append opens with encoding=utf-8 so tracebacks carrying
user text can't re-trip the same codec.
Regression tests: tests/test_windows_stdio_guards.py (cp1252 stream write
must not raise; main must set the Fortran guard + UTF-8 stdio).
fix(backend,shell): a missing MCP SDK can no longer kill the backend — and a failed setup now self-heals (#1156)
Root cause: mcp_server._ensure_mcp() called sys.exit(1) when the mcp
import failed; SystemExit is a BaseException, so main.py's best-effort
'except Exception' around the /mcp mount never caught it and the whole
backend died with exit code 1 on startup.
- _ensure_mcp raises ImportError (catchable) with the underlying error —
the import can fail with the package present (broken pywin32 transitive
import on Windows), so 'not installed' was a misdiagnosis. The
standalone CLI keeps its exit(1) contract.
- New mcp_server.mount_mcp(app) contains Exception AND SystemExit at the
integration boundary (same exit-containment class as #1143's engine
boundary); main.py's mount guard catches SystemExit too.
- The 'Setup failed' splash card now auto-dismisses when the backend
becomes healthy: 'failed' used to stop the IPC poll loop while the
successful IPC reply had already disarmed the #879 HTTP watchdog, so
nothing could observe a recovered backend. A /health recovery poll now
runs for the failed stage (startHealthRecoveryPoll).
- Relaunching the app while bootstrap is Failed now retries the backend
spawn (same path as the Retry button) instead of just refocusing a dead
window (tauri single-instance callback).
Regression tests: tests/test_mcp_graceful_degradation.py (SystemExit →
ImportError, mount containment, CLI exit contract) and
frontend/src/test/BootstrapSplashFailedRecovery.test.jsx (failed → ready
on health, stays failed while dead).
chore(gitignore): ignore locally-installed third-party skill packs under .claude/skills/
Follows the existing speckit-* precedent: skill dirs are ignored by default,
and skills meant to ship with the repo (omnivoice) are re-negated explicitly.
docs(changelog): start [Unreleased] with the #1154 remote-auth API-key gate fix
fix(remote-auth): show an API-key gate (not PIN) for API-key 401s in remote-backend mode (#1154)
* fix(remote-auth): route API-key 401 to an API-key gate, not the PIN form
When OMNIVOICE_API_KEY is set (remote-backend mode), a non-loopback browser
gets 401 "API key required" from BearerKeyMiddleware. But client.ts fired
`ov:pin-required` on every 401, surfacing the PIN gate — whose payload
(sessionStorage ov_pin / X-OmniVoice-Pin) can never satisfy the API-key
middleware. A remote user was stuck on a PIN form they could not pass.
Read the 401 `detail` and dispatch a single `ov:auth-required` CustomEvent
carrying the mode; RemoteAuthGate renders the matching PIN or API-key form.
Adds a `?api_key=` deep-link bootstrap (one-shot — scrubbed from the URL so a
reload can't re-clobber a corrected key) and a guarded saveApiKey helper.
Backend is unchanged — the two 401s are distinguishable by their `detail`
body ("API key required" vs "PIN required"). Docs: remote-gpu.md gains a
"From a browser" subsection for the new ?api_key= deep link.
* fix(remote-auth): preserve URL hash when scrubbing credentials
The replaceState that scrubs ?api_key=/?pin= rebuilt the URL from pathname
(+ optional query) and dropped url.hash, nuking any deep-link fragment
(e.g. #settings). Rebuild with pathname + (?query) + hash.
Addresses greptile + coderabbit review feedback on #1154.
* fix(remote-auth): guard 401 routing against a non-string/malformed detail
String(detail) can itself throw on a 401 detail whose toString is broken
(e.g. { toString: null }), aborting the auth-event dispatch. Match only real
strings with typeof; anything else falls back to PIN mode.
Addresses coderabbit's 17:03 re-review finding on #1154.
* fix(remote-auth): read the deep-link API key from the URL fragment (#api_key=)
Move the remote-backend deep link from ?api_key= (query) to #api_key=
(fragment): fragments are never sent to the server, so the durable key stays
out of the GPU box's and any reverse proxy's request logs on the page load
(greptile P1). ?pin= stays on the query (QR flow, session PIN).
The bootstrap is extracted into a pure, unit-tested _parseDeepLinkCredentials
helper (pin from the query, api_key from the fragment, one-shot scrub of both,
plus a legacy ?api_key= scrubbed-without-reading so a stray query key never
lingers). Docs document #api_key= with encoding guidance for keys containing
+ / & / # / =.
release: freeze v0.3.22 — version bump, lockfiles, changelog (#1150)
The dubbing release. package.json (source of truth) + the three mirrors
(Cargo.toml, pyproject.toml, version.py fallback) to 0.3.22; uv.lock +
Cargo.lock refreshed; CHANGELOG's Unreleased section (29 entries) becomes
## [0.3.22] — 2026-07-14 with the headline, split Added blocks merged.
Gates on the frozen content, all green before any version mutation:
backend 3033 + 204, frontend 1253, format, lockstep 6/6.
Co-authored-by: mergetest <nizam4103@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
fix(dub): hydrate partial translations on tab switch; dialect guard moves into the store (#1149)
* fix(dub): hydrate partial translations on tab switch; dialect guard moves into the store
Review round on #1148, both findings real:
- Greptile P1 "missing translations leave mixed text": the in-browser
translations map can be PARTIAL (tracks generated before per-language
persistence, partial regens); the non-destructive switch then left those
rows in the previous language under a single-language preview. New
GET /dub/segments-text/{job}?lang= exposes segments_i18n (the
authoritative per-language map every generate rebuilds); the tab click
hydrates only the gap rows, failure-silent, and skips stale responses if
the user switched again mid-fetch.
- CodeRabbit "clear stale dialect": the dropdown paths each cleared a
non-matching dubDialect by hand; the guard now lives inside
switchDubLangCode so every caller (dropdown, multi-language loop, preview
tabs, future ones) inherits it. Matching dialects survive.
Tests: endpoint (i18n map served, never-generated track -> empty map, legacy
job -> empty map), hydration (stored rows swap instantly, missing row
hydrates from the mock backend and is cached into translations), dialect
guard (cleared on mismatch, kept on match). Suites: dub sweep 262, frontend
1253, both green.
* test(api): register /dub/segments-text in the route-inventory snapshot
The inventory guard caught the new endpoint exactly as designed.
---------
Co-authored-by: mergetest <nizam4103@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
fix(dub): Export-step language tabs switch the transcript segments too (#1148)
Owner request with screenshot: the Original/Bengali/German/… pills above a
finished dub only swapped the preview VIDEO; the segment list kept showing
the last generated/edited language — German audio playing over Bengali text.
The pills now also route through switchDubLangCode (the P1.2 user-driven
language switch: outgoing text snapshotted into translations[prev], incoming
swapped in, non-destructive when no saved entry exists) plus setDubLang —
exactly what the language dropdown and the multi-language generate loop
already do, so fingerprint/staleness semantics are identical. The Original
pill deliberately leaves the editing language untouched: there is no
'original' editing language, and every row already renders the original
line under its translation.
Tests: clicking the German pill swaps segment text to the stored German
translation, snapshots the outgoing Bengali, and sets dubLangCode; the
Original pill leaves dubLangCode alone. Fail-before verified (wiring
stashed → text swap test fails). Full frontend suite: 1251 passed.
Co-authored-by: mergetest <nizam4103@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
fix(shell): version-gate crash markers, pin the WebView repair contract, run the Rust suite in CI (#1145)
* fix(shell): version-gate crash markers, pin the WebView repair contract, run the Rust suite in CI
Two deferred items from the recurrence audit, plus the CI gap that made
them possible:
- crash.rs: a persisted "backend crashed" marker now only surfaces for
the release that wrote it. After an upgrade, markers from the previous
version (quite possibly the build whose crash the upgrade fixed) are
ignored and pruned on read instead of resurfacing unacknowledged as if
the new build had crashed. backend_version gains #[serde(default)] so
legacy version-less markers still deserialize — as "", which the gate
treats as stale by design. Preview stamps (X.Y.Z-N) count as their
release.
- commands.rs: the #879 WebView2 cache repair's filesystem half is
extracted into clear_webview_cache_at() (paths + retry policy as
parameters, zero behavior change) and its contract is pinned by tests:
no marker → nothing touched; marker consumed first, unconditionally
(one-shot — a failing repair can never loop across launches); missing
cache is success; a locked cache is retried then abandoned with a log,
never bricking startup.
- ci.yml: the Tauri shell check only ran `cargo check`, which neither
compiles nor runs #[cfg(test)] code — so the shell's ~90 unit tests
(crash.rs, reset.rs, bootstrap.rs, …) never executed anywhere in CI.
`cargo test --lib` now runs them natively on all three OSes.
* fix(shell): crash-notice read path is strictly read-only — a prune-save there could destroy a fresh marker
Greptile's P1 is real, and hotter than stated: get_last_backend_crash is
not just a startup check — streamDropError (#1119) polls it every second
for 8 s after a stream drops, which is exactly when the death watcher is
inside record_crash's load→push→save. The previous commit's read path did
load→prune→save when stale-version markers existed (the post-upgrade
state), so a poll could load the pre-crash snapshot, lose the race, and
save over the freshly recorded marker — silently deleting the only
evidence of the crash it was being polled to find.
Smallest fix: reads never write. The read path (extracted as
read_notice_from(path, version) so the contract is testable) filters
stale-version markers in memory only; disk pruning stays on the write
paths (record_crash, acknowledge_backend_crash), where load-modify-save
already existed pre-PR and is paced by a crash or a user click rather
than a 1 Hz poll. Regression test pins the file as byte-identical across
reads, stale markers filtered and current ones surfacing as before.
---------
Co-authored-by: mergetest <nizam4103@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
feat(dub): Voice match toggle — per-line prosody vs one consistent reference per speaker (#1147)
* feat(dub): Voice match toggle — per-line prosody vs one consistent reference per speaker
Owner report: "still 4 segments different in voice as they are 4 times done
from each segment?" — Wave 3.2 clones each dub line from a reference cut from
its OWN source audio (great prosody match), but the voice IDENTITY drifts
line to line, and heuristic-diarized jobs have no pooled speaker clones to
anchor it. The precedence was hardcoded; now it's a per-dub-job setting.
DubRequest.voice_match:
- "per_line" (DEFAULT, unchanged): segment clip preferred, speaker clone
fallback — byte-identical to the previous behaviour.
- "consistent": ONE reference per speaker for the whole dub. `auto:` bindings
use the pooled speaker clone; when none exists (heuristic diarization skips
extraction entirely — the key case) a deterministic pick among that
speaker's segment clips (longest ≥3 s, tie-break lowest segment id) is
reused for every line. Server-default self `auto-seg:` bindings join the
pick (they're what prepare stamps on heuristic jobs — the Voice dropdown
can't even render them, so no user choice is overridden); explicit CROSS
auto-seg bindings still honour their clip. The shared pick is multi-use,
so it stays warm in the clone-prompt cache (#1132 cache_ref semantics) at
both the main generate and the OOM-retry call site.
voice_match is part of the segment fingerprint when non-default (mixed in
like track_lang, so all stored hashes keep their values): flipping the toggle
marks segments stale instead of letting "Regen changed" splice mixed-identity
voices (#281 class). The client sends the mode on both /tools/incremental
recompute paths.
UI: a compact Voice-match Segmented control next to the Timing picker in the
dub panel, persisted in the prefs slice; labels + tooltips in all 21 locales.
Tests: resolution through the real dub_generate path for both modes (incl.
the 4-segment heuristic job unifying on one ref — fail-before/pass-after),
pick determinism + tie-breaks, schema validation, fingerprint semantics, and
frontend store→request wiring.
* docs(changelog): Voice match toggle entry under Unreleased (#1147)
---------
Co-authored-by: mergetest <nizam4103@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
fix(engines): bundle en_core_web_sm — no mid-generation GitHub download (#1146)
* fix(engines): bundle en_core_web_sm — no mid-generation GitHub download
Post-merge review finding on #1144 (valid): with pip present, misaki/spaCy's
first-use auto-download WORKS now — meaning the first English MLX-Audio
generation performs a raw GitHub release download that (a) bypasses the app's
entire HF-mirror/endpoint system (restricted-network users have no recourse
mid-generation) and (b) fails offline. Local-first says default features
shouldn't spring surprise outbound requests at generation time.
en_core_web_sm-3.8.0 is now a pinned URL dependency in pyproject/uv.lock
(~12 MB wheel): it arrives at install/update time via the normal dependency
flow (where network failures are visible and retried), survives drift-sync
by construction, and spacy.util.is_package() finds it so misaki never
triggers its downloader at all. The #1143 containment stays as the backstop
for any other CLI-shaped dependency.
Also clarifies the venv test per review: pytest's interpreter IS the
uv-synced venv in CI and the packaged app, so find_spec verifies the lock;
the test now also pins the bundled model.
Validated: uv sync --frozen clean; en_core_web_sm importable.
* fix(engines): direct-URL dependency + frozen-bundle collection (review)
Two of three review P1s were real:
- Docker build break: `uv add` wrote a bare "en-core-web-sm" dependency with
the URL only in [tool.uv.sources] — Docker's `uv pip install --system .`
reads project metadata only, would resolve the bare name against PyPI
(where spaCy models don't exist), and the image build fails. Now a direct
"name @ url" dependency, the same form kittentts has always used, so every
installer (uv sync, pip, Docker) sees the same source. Re-locked;
uv sync --frozen clean.
- Frozen bundle: backend.spec ships mlx_audio, whose Kokoro path loads
en_core_web_sm DYNAMICALLY (spacy.load by name) — PyInstaller never sees
the import, so a frozen build would hit misaki's downloader at first
English generation. collect_all('en_core_web_sm') added inside the
mac-ARM block (plain data package, no nanobind hazard — the reason
collect_all is banned for mlx itself doesn't apply).
Declined with precedent: "hard-coded GitHub URL breaks restricted networks"
— kittentts has shipped as exactly this GitHub-release URL form in the same
dependency list since it was added; install-time GitHub fetches are the
project's accepted pattern (the bootstrap's gh-proxy mirror exists for
restricted networks), unlike mid-generation fetches, which this PR removes.
---------
Co-authored-by: mergetest <nizam4103@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
fix(engines): ship pip in the managed venv — the #1133 root trigger (#1144)
The containment fix (#1143) makes a CLI-shaped dependency's sys.exit
survivable; this removes the reason it fired at all. mlx-audio's Kokoro
phonemizer (misaki) auto-downloads en_core_web_sm via spacy.cli.download,
which shells out to `python -m pip install <url>` — and uv-managed venvs
ship no pip, so the download always failed.
Why a real dependency instead of installing pip (or the model) ad-hoc at
engine load: the updater's drift sync reconciles the venv against the
lockfile (#1029/#1030, --inexact), so anything outside the lock is stripped
on the next update — the failure would quietly return after every release.
pip in pyproject/uv.lock survives sync by construction.
Validated against all lock consumers: uv sync --frozen clean; Docker's
`uv pip install --system .` reads pyproject; version-lockstep test reads
only the version field. Regression test asserts pip is importable in the
managed env.
Co-authored-by: mergetest <nizam4103@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
fix(engines): contain SystemExit at the pool boundary — a CLI-shaped dependency killed the backend (#1133) (#1143)
* fix(engines): contain SystemExit at the pool boundary — a CLI-shaped dependency killed the backend (#1133)
Auto-report #1133 (8GB M1, v0.3.21, engine mlx-audio, exit code 1 at 21s
uptime) carried the whole story in its stderr tail: mlx-audio's Kokoro
pipeline uses misaki's G2P, whose __init__ runs spacy.cli.download() IN
PROCESS when en_core_web_sm is missing. spaCy's downloader is written as a
CLI: with no pip in the venv (uv-managed venvs ship none), its error printer
calls sys.exit(1). SystemExit is not an Exception, so every except Exception
on the path waved it through; it rode the executor future into the event
loop, where uvicorn treats SystemExit as "shut down" — backend dead.
Class fix, not a spacy special-case: _contain_system_exit() wraps every
callable dispatched through run_on_gpu_pool_guarded (all engine loads AND
generates funnel through it, #1033) and asr_backend.run_transcribe_guarded,
converting SystemExit into a RuntimeError that names the real failure mode.
Any engine dependency written as a CLI is now covered on both the TTS and
ASR sides.
Not done here (follow-up candidates): pre-provisioning en_core_web_sm for
the Kokoro/mlx-audio path so the download never triggers, and/or shipping
pip into the managed venv. Both are provisioning decisions; this PR makes
the failure survivable and honest first.
Tests: SystemExit from a pool job -> RuntimeError naming SystemExit(code),
executor still usable afterwards; same for the transcribe guard. Both fail
with the containment reverted. Full suite: 3016 passed.
* fix(engines): containment helper moves to a leaf module (CodeQL cyclic-import)
utils/containment is stdlib-only, so model_manager and asr_backend both
import it at module top with no cycle — the call-time back-import CodeQL
flagged is gone.
---------
Co-authored-by: mergetest <nizam4103@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
fix(tts): Vietnamese consistency — Voice vs Audiobook divergences (#1142)
* fix(tts): Vietnamese consistency — Voice vs Audiobook divergences (#1139)
Three root causes behind "Vietnamese Voice generation is inconsistent
compared to Audiobook":
1. Numbers: num2words' vi cardinals are wrong for 2001-2099 (misused
"lẻ": 2024 → "hai nghìn lẻ hai mươi bốn") and vi has no year form, so
normalization mangled the years the engine used to read natively.
Vietnamese now keeps its digits, and _num2words_lang's display-name
path now gates on _NUM2WORDS_LANGS like the ISO path (the loophole
that let "Vietnamese" bypass the vetting "vi" would have failed).
2. Seed: the longform resolver fetched a profile's pinned seed but only
the cache signature ever used it — book renders ran unseeded. Both
longform synth wrappers now seed torch per segment via the new pure
segment_seed(base_seed, text) helper (crc32-decorrelated, order- and
cache-independent, mirroring /generate's used_seed + i).
3. Quality preset: the audiobook synth inherited num_step=32 /
guidance_scale=2.0 from model-config defaults by accident of
omission while /generate defaults to 16 — the main audible gap.
Now explicit (LONGFORM_NUM_STEP / LONGFORM_GUIDANCE_SCALE), pinned
by a test so upstream default drift can't silently change books.
The Voice-page fast default (16) is deliberately unchanged.
Also (issue part 3): the finished audiobook's player + Download link
lived in component useState and evaporated on tab switch — the last
render's filename is now store-backed and persisted.
Regression tests fail-before/pass-after (verified by stashing the fix):
vi digit passthrough + vetted-set gate invariant; segment_seed +
seeding in both synth branches + explicit preset kwargs; lastOutput
store round-trip. Full backend suite 3004 passed; frontend 1237 passed.
* fix(ui): loadProject clears lastOutput; document longform seeding contracts (review)
Review-bot findings on #1142, evaluated:
- FIXED (Greptile P1 "Output Escapes Its Project" + CodeRabbit):
loadProject now resets lastOutput like newProject already did, so
loading project B never presents A's finished render as B's output.
Regression test added (set lastOutput → loadProject → cleared).
- REFUTED (P1 "Global RNG Races Between Workers"): the exposure is
identical to /generate's existing #526 seeding — generation.py calls
torch.manual_seed on the same global RNG inside the same GPU pool,
and has since that PR. The pool is 1 worker on MPS/CPU and small-VRAM
CUDA (model_manager._pick_gpu_workers), where determinism is strict;
a >1-worker CUDA pool is best-effort for BOTH paths. A race-free fix
means threading a per-call torch.Generator through the model's
samplers app-wide (covering /generate too) — out of scope for this
PR and pointless to do one-sided. Contract now documented on
_seed_segment_rng.
- REFUTED (P2 "Repeated Text Reuses One Seed"): identical takes for
identical repeated lines is the pipeline's shipped semantic — the
content-addressed SegmentCache (segment_cache_key hashes text +
voice sig, not position) already replays one WAV for every identical
span — and seeding only activates when the user pinned a seed, i.e.
asked for reproducibility. Position-based keys would shift every
later span's seed on a one-paragraph insert, breaking the
cache-independent partial re-render guarantee. Documented on
segment_seed.
- DECLINED (P2 "Persisted Filename Can Outlive File"): longform
outputs in OUTPUTS_DIR are not auto-pruned (prune_cache_dir bounds
only longform_cache), so a dangling name requires manual deletion;
auto-clearing on an <audio> error would instead wipe a valid link
whenever the backend is briefly down at mount. Projects → Audiobooks
stays the authoritative library.
Also rebased onto main past #1141 (CHANGELOG resolved keeping both
Unreleased→Fixed entries, this PR's on top).
Affected suites: 264 passed; frontend format clean, 1245 tests passed.
---------
Co-authored-by: mergetest <nizam4103@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
feat(hardening): six recurrence guards from the closed-issue-history audit (#1141)
* feat(hardening): six recurrence guards from the closed-issue-history audit
An agent audit swept every closed issue, clustered the error classes, and
checked each for fix + regression test + upgrade/reinstall survival. Six of
the "fixed but fragile" gaps are closed here; each guard has a regression
test in tests/test_recurrence_hardening.py (9 tests).
1. Evict-then-load (class 1, ~90 issues): a plain TTS load on a tight
unified-memory box could still be OS-killed — the dub path frees memory
before ASR loads (#1119) but nothing did before a TTS load.
_make_room_before_tts_load() releases the idle capture-ASR model, clone
prompts, and allocator caches when free RAM < the unified headroom.
Deliberately NOT admission control: the #1111 decision (advisory-only,
never refuse a load on an estimate) stands; this only does earlier what
idle reclaim does later, and roomy machines skip it entirely.
2. Honest SIGKILL attribution (class 1): crashCauseHint() says "the OS ran
out of memory (RAM)" for signal 9 instead of guessing VRAM on machines
that have none. VRAM guidance kept for real GPU aborts (signal 6 etc.).
3. Clone-kind save sanitize (class 3, recurred 3x): the server-side instruct
heal was gated to design-kind; a clone profile saved by any bypassing
client could persist prose that 400s on every use. profiles.py now
sanitizes both kinds at the single choke point.
4. Stale user_env validation (class 5): ~/.config/omnivoice/env is inherited
verbatim by reinstalls; path-valued keys (OMNIVOICE_CACHE_DIR/DATA_DIR)
that don't exist and can't be created are dropped for the run with a loud
log line (file untouched — replugging the drive restores the setting).
The two #480 precedence tests updated to use creatable paths (they test
precedence, not path validity).
5. omni_ui schema guard (class 6): sanitizeOmniUi() whitelists + shape-checks
every persisted field before restore — one malformed field used to throw
mid-restore and silently discard everything after it, and every future
field re-opened the #1067 class. Includes a lockstep test failing when
useAppData reads a field missing from the schema.
6. safe_replace EXDEV helper (class 7): os.replace across devices raises
EXDEV (the Windows D:-drive Errno 18/22 class); utils/fsops.safe_replace
degrades to copy+fsync+replace. Adopted at the two cross-directory movers
(log rotation, persona restore); temp-sibling writers stay on os.replace.
Plus: the generate timeout scales with text length (class 4's 503 wave —
+1s per 40 chars past the first 1200, env floor respected), so long texts
on slow hardware stop dying at exactly 300s with a "set an env var" remedy.
Deliberately NOT done, with reasons:
- ASR auto-promotion to the crash-isolated engine after a wedge: the code
records an explicit owner rule against silent engine switching
(asr_backend.py "we never switch engines automatically") — flagged to the
owner instead of overridden.
- Rust items (webview cache-clear unit test, crash-marker versioning across
updates): deferred to their own PR — the local cargo target was reclaimed
for disk space, so they can't be verified locally right now.
Full suite: 2999 backend + 1243 frontend.
* docs(changelog): correct PR ref to #1141
* fix(hardening): review round — reclaim at the shared load boundary, write-probe path validation
Both Greptile P1s were real:
- "Startup preload skips reclaim": _make_room_before_tts_load() ran only in
get_model(); preload_model() calls _load_model_with_timeout() directly, so
a memory-tight machine was protected on demand loads but could still be
OS-killed during the startup preload — the exact window the guard exists
for. The reclaim now lives in _load_model_with_timeout(), the boundary both
callers share.
- "Read-only paths pass validation": an existing directory on a read-only
mount passes makedirs+isdir but fails on first real use, so the stale
setting survived validation only to break downloads later. The check now
probes actual write capability (create+delete a probe file). New test with
a chmod-0o500 dir (skipped under root, where the probe cannot fail).
- CodeQL: the two intentional best-effort excepts in fsops.py now carry
their explanatory comments.
Full suite: 3000 passed.
---------
Co-authored-by: mergetest <nizam4103@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
fix(dub): stereo, full-band music bed — separate the HQ extraction, pin the mix to stereo (#1138)
* fix(dub): stereo, full-band music bed — separate the HQ extraction, pin the mix to stereo
Owner asked for a channels/Hz/samples comparison of a dub against its
original to tune generation toward the source. The measurements found a
class, not a knob:
L/R correlation: original 0.754, dub 1.000 (mono in a stereo container)
stereo width (S/M): 0.375 vs 0.003
LUFS: -17.8 vs -17.2 (already fine)
Two stacked causes:
1. INGEST: Demucs separated audio.wav — the 16 kHz MONO extraction made for
ASR. The music bed therefore inherited mono AND an 8 kHz bandwidth
ceiling at its source (Demucs upsamples to 44.1 kHz internally, so the
stems LOOKED like 44.1k stereo files while carrying neither). Ingest now
extracts a second full-quality file (44.1 kHz stereo, pcm_s16le) just for
separation; ASR keeps its 16 kHz mono file; Demucs cost is ~unchanged
(it resampled to 44.1 kHz internally either way). Best-effort: if the HQ
extraction fails, separation falls back to the ASR file — exactly the old
behavior. The stem-move path follows the input's basename.
2. MIX: amix negotiates ONE channel layout across inputs, and the
synthesized voice is mono — so even a true-stereo bed was collapsed at
the mix. bed_mix_filter now pins BOTH legs to stereo
(aformat=channel_layouts=stereo); upmixing the mono voice duplicates it
dead-center, which is where dubbed dialogue belongs anyway.
Verified with real ffmpeg: the new graph preserves a stereo bed's width
through the mix (and the ingest test pins that demucs receives audio_hq.wav
with -ac 2 -ar 44100 while ASR keeps -ac 1 -ar 16000). Both tests fail with
their half of the fix reverted. Full suite: 2989 passed.
* fix(dub): pre-HQ stem caches are not reused (review)
Greptile P1, real: the content-hash cache restores a previous job's stems for
the same video and skips Demucs — so every video processed BEFORE the
HQ-extraction change would keep its 16 kHz-mono-derived bed forever, and the
fix would never apply to exactly the videos users re-upload to hear the
difference. find_cached_job now requires the audio_hq.wav marker in the
cached job dir; older candidates are skipped with a log line and separation
reruns once at full quality. Regression test covers both directions of the
gate.
---------
Co-authored-by: mergetest <nizam4103@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
feat(dub): underrun fill — short dubbed lines are slowed toward their slot instead of leaving dead air (#1137)
* feat(dub): underrun fill — short dubbed lines are slowed toward their slot instead of leaving dead air
The dub pipeline has always handled audio that is too LONG for its slot
(atempo compression, Smart Fit's audio/video split, trims). Audio that is too
SHORT was start-aligned and abandoned — and that is the common case, not the
corner: translations routinely speak faster than the source delivery.
Measured on a real 4-segment dub, 8.8 of 18.7 seconds of original speech time
had no dubbed voice. What fills those holes is the separated bed's
under-speech residue (37% of the original energy, measured), so the user
hears them as BOTH "little silences" AND "the music is numbed" — and sees
them as lip-sync failure, since the mouth keeps moving after the dub stopped.
The fill: when a line's natural duration covers less than UNDERRUN_TOLERANCE
(95%) of its slot, slow it toward the slot with the same pitch-preserving
atempo pipe the compression path uses, bounded at min_audio_rate (default
0.85x — comfortably natural; atempo handles <1 natively). Wired into both
fitting strategies:
- fit_planner._fit_one: need < 1 now resolves to audio_rate=max(need, floor),
status "audio_slowed" — planner stays a pure function; golden fixtures
regenerated per their own instructions (10 substantive lines: five
underrun segments across four scenarios flip to audio_slowed@0.85).
- dub_generate smart_fit branch: applies the rate in both directions (the
target formula was already direction-agnostic).
- dub_generate strict_slot branch: mirror of its compression arm.
- stretch_video and concise strategies deliberately untouched (natural-rate
by design / never-intervene by design).
OMNIVOICE_UNDERRUN_MIN_RATE overrides the floor (1.0 disables; clamped to
atempo's sane range). The per-segment fit badge shows "slowed N.NNx" with a
tooltip, translated in all 21 locales.
Tests: planner contracts (fill bounded by floor, tolerance zone untouched,
disable switch, empty-audio guard), the flipped unit/golden/integration
expectations updated with the rationale, and the existing smart_fit
integration test now exercises the fill through the real mix loop (its seg0
comes out audio_slowed@0.85 end to end). Full suite: 2987 backend + 1236
frontend.
* fix(dub): strict-slot slow-downs report themselves honestly (review); ru pitch wording
Review round on #1137:
- Greptile P1 "slowdown reports fits" — REAL: the strict_slot underrun fill
fell through to the unconditional {"status": "fits"} entry, so a slowed
segment's badge hid the applied rate (and compression_applied mislabeled
it). The branch now emits {"status": "audio_slowed", "audio_rate": …} like
the smart_fit path — same honesty contract everywhere.
- Greptile P1 "padded audio hides underruns" — REFUTED with evidence: nothing
pads strict-slot audio before the check (_load_entry_wav returns the
natural-length WAV; only error/silence slots are slot-sized, and those are
synthetic silence by design). On-disk segment WAVs measure both shorter and
longer than their slots, which pre-padding would make impossible.
- CodeRabbit: Russian tooltip now says "высота тона сохранена" (pitch), not
"высота сохранена" (height).
---------
Co-authored-by: mergetest <nizam4103@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
fix(dub): background bed no longer plays quiet and muffled — cancel amix normalization, mix at 48 kHz (#1136)
* fix(dub): background bed no longer plays quiet and muffled — cancel amix normalization, mix at 48 kHz
Reported live: "background music is so much not like the original." Two
stacked fidelity bugs in every bed-mix site, measured with real ffmpeg on a
real dub job:
1. LEVEL — ffmpeg's amix NORMALIZES its inputs, so the per-site weight
strings meant "favor dialogue slightly" but actually played the music bed
at ~57% of its original level (batch.py stacked an explicit volume=0.15
under the same normalization, leaving its bed near 8%).
2. BANDWIDTH — the voice track is synthesized at 24 kHz and amix negotiates
one common rate, so the 44.1 kHz bed was silently downsampled to 24 kHz:
everything above 12 kHz (cymbals, air, brightness) vanished.
Six call sites carried six hand-rolled variants of the same filter string
(dub_export x5, batch x1) with inconsistent input ordering — the same
copy-divergence pattern that orphaned the clone-prompt cache (#1130). They now
share one builder, services.ffmpeg_utils.bed_mix_filter(): both inputs
resampled to 48 kHz before the mix, a compensating volume multiply that
cancels amix's normalization exactly (the weights ARE the absolute gains: bed
0.9, voice 1.1), and a transparent peak limiter for the rare summed peak that
full-scale mixing makes possible.
Measured A/B on the reporting user's job (bed vs bed-through-mix, silent
voice): 57% -> 90% of original level, 24 kHz -> 48 kHz output. The remaining
-0.9 dB is deliberate dialogue headroom, one constant to change if policy
shifts.
Tests: the export command must carry the resample + compensation + limiter
(fails on the old strings), builder label-uniqueness for multi-track graphs,
and the existing export suites unchanged.
* fix(dub): amix renormalizes when a stream ends — disable normalization instead of compensating for it
Greptile P1 on this PR, confirmed real by measurement: amix's normalization is
DYNAMIC — it rescales the remaining inputs whenever one ends. The previous
commit cancelled it with a constant post-mix multiply, which is exact only
while both streams are active; once the (even marginally shorter) voice track
ends, the bed's internal scale jumps to 1.0 and the fixed multiply BOOSTS the
tail music into the limiter. Measured on the real job with a deliberately
short voice: bed at 90% while the voice runs, 189% after it ends. The original
A/B used equal-length streams, which is why this never showed.
Fix: amix normalize=0 (a plain sum) with per-input volume gains — levels are
exact for the whole timeline regardless of stream lifetimes. Same measurement
now: 90% / 90%.
normalize= arrived in ffmpeg 5.x, and system-ffmpeg users can be older, where
an unknown option rejects the whole graph (= no export at all). The builder
probes `ffmpeg -h filter=amix` once per process and falls back to the
compensated form on legacy builds — its tail quirk is the lesser evil next to
a failed export, and every bundled/imageio tier ships 7.x.
Tests: both paths pinned (normalize=0 + per-input gains on modern; the
compensation multiply on legacy), probe monkeypatched per test.
* test(dub): anchor the amix monkeypatches to the call chain — module aliases miss under random order
---------
Co-authored-by: mergetest <nizam4103@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
fix(dub): a rate-limited polish pass no longer skips fitting, fails the UI, or ignores Retry-After (#1135)
* fix(dub): a rate-limited polish pass no longer skips fitting, fails the UI, or ignores Retry-After
Observed live (owner's Bengali dub, 4 segments): every cinematic reflect call
429'd against a free-tier OpenRouter model and the UI declared "4/4 segment(s)
failed" over a translate that succeeded. Root-causing that surfaced a class,
not a message bug:
The cinematic reflect/adapt chain is OPTIONAL polish — on any failure the
segment keeps its literal translation and is fully usable. But every such
degradation (no-llm, reflect/adapt errors, adapt-diverged, wrong-script,
cinematic-budget) was reported under the same "error" key as real translation
failures. Three consumers took that at face value:
1. useDubWorkflow counted the rows as failed -> the red N/N toast;
2. _stamp_predicted_rate_ratio and _stamp_duration_plan skipped them ->
no rate badges, no fits/tight/impossible verdicts;
3. _apply_fit_pass and the condense pass skipped them -> overlong lines went
to synthesis unfitted and came out audibly time-compressed at mix. This
is a direct contributor to "later segments got worse" in rate-limited
Cinematic dubs.
Split the vocabulary: "error" now means the row has no usable text (base
translation failed); optional-pass fallbacks ride a separate "degraded" key.
Downstream filters keep gating on "error" only, so degraded rows flow through
every fitting pass. The UI shows an amber "translated, polish skipped
(<reason>)" toast and a mild row tooltip instead of a red failure, and editing
a row clears the stale annotation.
And the retry that makes most of this moot: _chat now honors a 429's
Retry-After once (capped at 30s, jittered so the 6-wide segment fan-out does
not re-stampede the same window). OpenRouter's free pool says "Retry-After: 2"
- giving up instantly turned a two-second wait into a whole failed pass.
Tests: producer contract (every cinematic fallback returns degraded, never
error - 5 updated + retained), consumer contract (degraded rows still get
rate-ratio prediction and duration plans; error rows stay excluded), and the
retry (honors small Retry-After with jitter, caps absurd ones, one retry only,
non-429s never retry). Full suite: 2981 backend + 1236 frontend.
* docs(changelog): correct PR ref to #1135
* fix(dub): review round — localize the degraded strings, un-suppress the mixed toast, clear stale annotations on edit
Three review findings, all valid:
- Localization parity (Greptile): the two new user-facing keys existed only in
en.json. Every other key in these namespaces is translated in all 21
locales, so the fallback-to-English behavior would have been a regression of
the repo's parity convention. Both keys now translated in all 20 non-en
locales, inserted beside their siblings.
- Mixed responses suppressed the degraded story (Greptile): when a translate
returned both real failures and degraded rows, only the red failure toast
fired. The degraded warning now fires alongside it — real failures don't
erase what happened to the rows that succeeded plainly.
- Ordinary edits kept stale annotations (CodeRabbit): the restore path cleared
translate_error/translate_degraded but a normal text edit didn't, so a row
kept wearing "polish pass skipped" over words the user had just written.
Editing the text now clears both annotations.
Frontend suite: 1236 passed; i18n probe green across all 21 locales.
---------
Co-authored-by: mergetest <nizam4103@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
perf(dub): single-use per-segment refs no longer evict the prompts a dub reuses; add docs/performance.md (#1132)
* perf(dub): single-use per-segment refs no longer evict the prompts a dub reuses; add docs/performance.md
The scan-resistance fix:
A dub cuts a distinct reference clip per segment (Wave 3.2 / #486 — each line
clones its own source delivery) and falls back to the per-speaker clone for
segments under 3 s. Both paths flow through the voice-clone prompt cache — an
LRU of 8. Streaming hundreds of one-shot per-segment clips through that LRU
evicts the per-speaker and locked-profile prompts that every fallback segment
reuses, so the speaker ref was re-encoded (~0.4 s each, measured with
scripts/bench_pipeline.py) again and again across the render.
Note what this deliberately does NOT do: the bench's "166 misses vs 2 speakers"
framing suggested keying refs per speaker — but per-segment refs are the
intentional prosody-matching feature, and the re-transcription behind them is
the #1004 correctness fix. Their encode cost is the price of the feature, not
waste. The waste was only the eviction side-effect, and that's what this
removes: _get_clone_prompt(store=False) still reads the cache (a hit is free)
but never inserts, and the dub loop marks exactly the segment-scoped refs
(auto-seg: bindings and auto: bindings resolved to a segment clip) as
single-use. Per-speaker, locked-profile, and preview refs cache as before.
cache_ref is popped in generate_with_cached_ref before the model call — the
model's generate() has an explicit signature and would TypeError — and unknown
engines ignore it (**kw adapters).
The doc:
docs/performance.md is the first performance documentation in the repo — none
of the ~15 perf env vars appeared anywhere in docs/, the Performance panel's
only control is Windows-only, and slowness reports (#1032) arrived as mysteries
instead of settings checks. Covers the three classic causes of "it got slow",
where generation/dub time goes, every knob with defaults and warnings (raising
OMNIVOICE_GPU_WORKERS on a small GPU is the #567 crash, not a speedup), platform
notes, and how to run the bench so reports carry numbers. Linked from README's
install section.
Tests: store=False semantics (encodes, never inserts, still reads), the flood
scenario end to end (a speaker prompt stays warm through 3x the cache cap of
one-shots), and the pop contract (cache_ref never reaches the model). Full
suite: 2974 passed.
* docs,dub: review round — qualify the per-file cache claim; note the OOM-retry tradeoff
- CodeRabbit: docs/performance.md's "the reference encode is cached per file"
now carves out the dub's per-line clips (single-use by design — nothing for
a cache to save).
- Greptile P2 (OOM retry re-encodes a single-use ref): acknowledged in a code
comment as deliberate — caching the retry's ref would reintroduce the
eviction this flag prevents, to optimize a path that only runs after an OOM
already cost seconds.
* docs(performance): probe-based torch.compile wording; honest accelerator + cache claims (review)
Greptile's repeated OOM-retry finding is deliberately skipped: retaining the
prompt across the retry would require passing prompt objects through the
adapter protocol (backend.generate takes paths), to save 0.4s on a path that
only runs after an OOM already cost seconds — the tradeoff is documented at
the call site.
---------
Co-authored-by: mergetest <nizam4103@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
fix(engines): an Install click during the mount status probe was silently dropped (#1131)
* fix(engines): an Install click during the mount status probe was silently dropped
refreshInstall serializes status requests per engine so a slow backend can't
land responses out of order (an old 'running' overwriting a newer 'succeeded'
would restart the poller forever). But the guard dropped ALL overlapping
callers, including the one that must never be dropped: the Install click's
first status refresh. If the click landed while the mount-time re-attach probe
still held the slot, the refresh returned null, the state kept the pre-install
'idle' snapshot, the 1.5s poller (which only watches 'running' jobs) never
started, and the progress panel never appeared — no error, no retry. The
backend install DID start; the UI just never showed it.
Fast machines win the race almost every time, which is why this surfaced as a
rare CI-only failure of "a failed job renders the error with its remediation
and offers Retry" (observed on #1130's run, a PR with zero frontend changes).
The inflight guard now maps id -> the in-flight promise; advisory callers (the
poller, the probe) still drop on overlap, but the click passes force: true and
waits the in-flight request out before fetching its own fresh snapshot —
strictly ordered, never dropped.
The regression test holds the mount probe open with a gated promise, clicks
during the window, and only then releases the probe — deterministic where the
CI flake was scheduler-luck. Fails before the fix (panel never renders, 3s
timeout), passes after. Full frontend suite: 1234 passed.
* fix(engines): bound the forced wait, serialize rapid clicks, reject stale responses by epoch
Round-2 review findings on #1131, both real:
- Greptile P1 "forced waiters break serialization": two rapid Install clicks
waking from the SAME awaited probe both proceeded without re-checking the
slot — two concurrent requests, out-of-order responses possible again. The
forced wait is now a loop that re-checks the map after every await.
- Greptile P1 "install inherits probe stall": a wedged probe (no abort signal)
made the forced click wait forever — trading "silently dropped" for
"silently stuck". The wait is now bounded (FORCE_WAIT_TIMEOUT_MS, 5s), and a
per-engine request EPOCH makes proceeding safe: a response may only be
applied if no newer request started since it was issued, so the wedged
request's eventual stale response is discarded instead of clobbering the
fresh 'running' snapshot. The epoch is now the actual ordering guarantee;
the inflight slot is just throttling.
- CodeQL js/missing-await on `=== req`: intentional promise-identity compare,
restructured to compare a plain { promise } wrapper object so the alert
class can't fire.
Both new tests fail against the round-1 fix (maxActive=2; panel never appears
after a 5s fake-timer advance) and pass now. Frontend suite: 1236 passed.
* fix(engines): clear the losing race leg's 5s timer (review)
---------
Co-authored-by: mergetest <nizam4103@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
perf(tts,dub): the reference clip was re-encoded on every chunk; the dub loaded a 3 GB model to throw it away (#1130)
* perf(tts,dub): the reference clip was re-encoded on every chunk; the dub loaded a 3 GB model to throw it away
Two independent pieces of pure waste on the generate path, both measured with
scripts/bench_pipeline.py on a 16 GB M2 (a reference encode costs 0.40s).
1. The voice-clone prompt cache was built, then orphaned.
#427/#473 added a bounded LRU that encodes a reference clip once and reuses it,
because "every cloned generation re-encodes the reference audio from scratch".
It was wired into OmniVoiceBackend — the *adapter* path. But /generate for the
default engine forks to the *native* model path (that fork predates the cache,
#324) and passed ref_audio=<path> straight through, so the codec encoder re-ran
the reference on every model.generate() call: once per text chunk, once per
pause-span, once per audiobook segment, and once per request.
That perf PR has therefore only ever sped up /v1/audio/speech. The Generate
button never touched it.
Every native call site now goes through one helper (generate_with_cached_ref) so
the rule lives in a single place: chunked /generate, its streaming twin (#1088),
the [pause] stitcher (#276), and the audiobook renderer. Saving is
0.40s x (calls - 1): ~3.6s on a 10-chunk text, ~66s on a 166-segment audiobook.
Same-class bug found in the same cache: /v1/audio/speech accepts
preprocess_prompt, but the adapter dropped it before it reached the model AND
the cache key omitted it — so the flag was silently ignored, and honoring it
without keying on it would have served (and poisoned) the wrong prompt. Both
fixed together.
2. A dub loaded the TTS core just to free it again.
The transcribe preflight called get_model() — pulling in the ~3 GB TTS model —
for one reason: to read a preloaded `_asr_pipe` off it. That attribute only
exists under OMNIVOICE_PRELOAD_TTS_ASR, which is off by default. So every dub
loaded the model, harvested None, had offload_tts_for_asr() free it 60 lines
later (on unified memory that is a full UNLOAD, #1119), and then cold-reloaded
the same model in dub_generate (~8s). Load -> unload -> reload, for an attribute
that was always None. It now loads only when there is something to harvest.
Also fixes a latent NameError: asr_on_vocals was assigned only inside the
model-loaded branch but read from _gen_body, so an early preflight bail raised
NameError instead of the real error.
Tests: the existing cache tests passed the whole time the cache was dead, because
they test the cache in isolation with a stub model. The new tests assert the
wiring instead — that a real render encodes the reference ONCE regardless of how
many generate calls it takes. All four encode-count tests fail before this change
and pass after; the dub tests likewise.
* docs(changelog): the reference re-encode and the dub's throwaway model load (#1130)
* fix(memory): unloading the TTS model must drop its cached reference prompts too
Follow-on to the cache wiring in this PR, and a real gap it opened.
clear_clone_prompt_cache() was called from exactly one place:
OmniVoiceBackend.unload(). That was sufficient while the prompt cache was
adapter-only — but the native /generate path now populates it, and the native
path unloads through model_manager (idle_worker, _offload_unified_memory), not
through the adapter. So cached prompt tensors would have survived an unload.
That directly undercuts #1119: on unified memory offload_tts_for_asr() sets
model = None precisely to hand the RAM to the ASR model. Prompts left behind sit
in the memory the unload was trying to reclaim. The tensors are small (integer
codes, not waveforms), so this is hygiene rather than a leak — but "unload means
unload" is the whole point of that change, and the next thing cached here might
not be small.
model_manager.release_tts_side_caches() is now called wherever the global model
is dropped. Best-effort by construction: cache hygiene must never be able to
break an unload, because a failed unload is how the backend gets OOM-killed.
The test binds services.tts_backend at CALL time, not import time: several suites
purge sys.modules["services.*"] for DB isolation (test_model_load_timeout,
test_model_manager_preload), so a module-level alias goes stale mid-run and the
assertion would inspect a different module's cache than the code under test just
filled. Production already imports it at call time.
Full suite: 2968 passed, in both deterministic and random order.
* fix(tts): keep the prompt cache best-effort, and stop the unload hook closing an import cycle
Three review findings, all real.
1. Greptile P1 — the shared helper dropped the inline fallback.
OmniVoiceBackend.generate() has always caught a failure from
generate(voice_clone_prompt=...) and retried with the inline ref, so the cache
stays a pure latency optimization. generate_with_cached_ref did not: a model that
rejected a precomputed prompt would have turned a working /generate, streaming
render, or audiobook job into a hard error. Moving the native path onto the cache
would then have made it LESS robust than before it was cached at all.
The helper now carries that fallback, and OmniVoiceBackend delegates to it
instead of keeping a second copy. Two subtly-diverging copies of this logic is
precisely how the cache ended up wired into the adapter and nowhere else; there
is now exactly one.
2. CodeQL — cyclic import.
release_tts_side_caches() imported services.tts_backend, which already imports
model_manager: a real cycle, not a false positive. A registration hook fixed the
cycle but replaced it with a worse problem — the hook runs at import time and
pulls model_manager (and core.config) in earlier than before, which perturbs
DATA_DIR binding and broke test_longform_jobs::test_route_handler_returns_jobs_envelope
in the full suite (passed in isolation, failed in order — caught locally, not in CI).
It now reaches the module through sys.modules instead: no import, no cycle, no
import-time side effect. And it is the more correct expression of the invariant
anyway — a module that was never imported has no cache to clear.
3. CodeRabbit — the audiobook and streaming call sites had no encode-count test.
Added one for the audiobook synth path (the worst case: hundreds of segments on
one voice).
New tests fail before their respective fixes: stripping the try/except from the
helper fails the prompt-rejection test.
Full suite: 2970 passed, deterministic and random order.
---------
Co-authored-by: mergetest <nizam4103@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
perf(bench): a memory-safe profiler for the pipeline — so "make it faster" stops being a guess (#1129)
Every performance question this week ("can we batch by cores?", "why is dubbing
slow?") was answerable only by measuring, and twice the intuitive answer was wrong:
* Concurrency on Apple Silicon buys NOTHING. Measured, 4 segments:
1 worker 19.3s | 2 workers 20.7s (0.93x) | 3 workers 19.2s (1.00x)
One GPU, already saturated — extra workers interleave. Scaling the GPU pool by
free RAM (the "intelligent batching" that sounds obviously right) would have
added OOM risk on a 16 GB box for zero throughput. _pick_gpu_workers()'s
hardcoded `MPS -> 1` is correct, and now provably so.
* The clone-prompt cache misses on every segment (a dub writes one reference per
segment: 166 distinct keys, cache can never hit). That looked like the dub's
hidden cost. It is 0.40s/segment — ~2% — and it is not even waste: each
reference is genuinely different audio, and encoding it is the *feature*
(per-line prosody). Dropping to per-speaker refs would save ~65s/dub and cost
quality. Not a free win; not taken.
What actually dominates is TTS itself, which scales with text length (3.2s for a
short line, 8.7s for a 2.5x longer one) and is GPU-bound on a GPU that one
inference already fills.
The profiler is deliberately gentle with memory, because a profiler that OOMs the
machine reproduces the very bug class it exists to fix (#1119): stages run one at a
time, models are unloaded between them, a stage is SKIPPED if free RAM is under the
floor rather than starting a load the OS would kill, and each measurement is a fixed
small number of passes — no looping to convergence.
Co-authored-by: mergetest <nizam4103@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(asr): Apple Silicon dubbing ran Whisper on the CPU — the ~4x dub slowdown, the abandoned chunks, and the fictional ETA (#1128)
* fix(asr): Apple Silicon dubbing ran Whisper on the CPU — pick the engine by hardware
_auto_detect() probed WhisperX first, unconditionally, with no device check. WhisperX
is always installed, so it always won — and WhisperX (like faster-whisper) is CTranslate2,
which has NO Metal backend. On every Mac, dub transcription therefore ran whisper-large-v3
on the CPU while the GPU sat idle. The MPS branch below it was unreachable in practice.
Measured on an M2, one 30 s dub chunk of large-v3:
WhisperX (CPU) 90.4 s <- 3x SLOWER than realtime
MLX (GPU) 20.5 s
MLX (GPU) + forced align 20.3 s <- ~4.4x faster, identical word timings
That is a 16-minute video taking ~48 minutes and looking like a hang. It is also why the
slowest chunks exceeded OMNIVOICE_TRANSCRIBE_CHUNK_TIMEOUT_S (120 s) and were ABANDONED
after 2 attempts (chunks 33/34 in the reported run), losing transcript outright — while
#730's advice blamed a "VRAM-starved GPU" on a machine with no VRAM.
The engine pick is now device-aware: Apple Silicon gets MLX, everywhere else is unchanged
(on CUDA, WhisperX already uses the GPU and remains the default).
Crucially this does NOT buy speed with lip-sync accuracy. WhisperX's value is its wav2vec2
forced alignment (±10-30 ms word boundaries vs Whisper's ±100-300 ms), and dub lip-sync
depends on it. Alignment takes *plain segments*, so it is independent of whichever engine
produced them: it is extracted into forced_align() and layered on MLX's GPU transcript.
Verified — the boundaries agree with WhisperX's (multiple: 0.62 vs 0.71; different: 1.57 vs
1.55) and every word carries timing. Alignment prefers MPS (20.3 s vs 28.4 s, byte-identical
timings) and falls back to CPU rather than silently dropping to loose timestamps.
Also fixes the ETA, which was pure fiction: TranscribeOverlay estimated
`ceil(duration/60)*3 + 8` seconds — an assumption of ~20x-realtime transcription. For a
16-minute video it predicted 56 s against a real ~48 min, then clamped to "~0s remaining"
with the bar frozen at 95% for the rest of the job. The backend already streams a real
progress fraction (dub_core.py emits `progress` on every `segments` event) and the UI simply
ignored it. It now extrapolates from the observed rate, and shows nothing until it has a
rate to extrapolate from.
* style: oxfmt
Formatting only — no behaviour change. CI's format:check gate (not run locally
before the push) rejected the two new/edited dub files.
---------
Co-authored-by: mergetest <nizam4103@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(analytics): the backend never received the token — its half was dead in every build (#1126)
core/analytics.py reads POSTHOG_PROJECT_TOKEN from its own environment at RUNTIME,
but the backend runs on the *user's* machine, where nothing sets it. So in a shipped
build token_configured() was false forever: every backend event — including the
speech_generated capture — was silently dropped, no matter what secret CI held. Only
the frontend half ever worked, and nothing would have told us.
The token is really a build input. release.yml already passes the POSTHOG_PROJECT_TOKEN
secret to the tauri-action step as VITE_POSTHOG_KEY, and that step compiles the Rust
shell as well as the frontend bundle — so option_env! bakes it into the shell on exactly
the builds that ship it, and spawn_backend() hands it to the child process.
The guarantees are unchanged and now pinned by tests:
- no token baked in (every source build) => nothing passed => no destination => the
backend cannot transmit, and the toggle isn't offered;
- a real process env var still wins, so a dev can point a local run at their own project;
- consent remains a separate gate (prefs, default off) — a destination alone sends nothing.
Hardened against silent recurrence, since this failure mode is invisible: build.rs gets
rerun-if-env-changed (option_env! is compile-time, so a cached build would otherwise keep
the token it first saw), and tests assert the whole chain — release.yml still passes the
secret, backend.rs still bakes it, build.rs still busts the cache.
Also fixes a CHANGELOG contradiction that would have shipped in the release notes: the
Usage-panel entry still claimed PostHog "was proposed and rejected ... there is no
analytics service, no token", directly under two entries announcing it.
Co-authored-by: mergetest <nizam4103@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat(analytics): wire posthog-js — consent-gated, autocapture OFF (#1123)
* feat(analytics): wire posthog-js — consent-gated, autocapture OFF
The owner supplied the standard snippet:
posthog.init(TOKEN, { api_host, defaults: '2026-05-30' })
Shipping that verbatim would have broken the guarantee we just made, twice:
1. It initialises AT MODULE LOAD — it starts tracking every user before they
have consented to anything. The README now says "OmniVoice sends nothing out
of the box"; this would have made that false on the very next release.
Analytics is therefore started ONLY after the user opts in (Settings →
Privacy), and the stored consent is what restores it at launch.
2. posthog-js AUTOCAPTURES by default, and `defaults: '2026-05-30'` turns that
on. Autocapture sends the text content of the DOM elements a user interacts
with. In THIS app the DOM holds the script they are about to synthesise,
their voice names and their file names — exactly the content we promise never
leaves the machine. It is explicitly disabled, along with session recording
(which records the screen) and pageview capture.
utils/analytics.ts: hardenedConfig() — autocapture false, disable_session_recording
true, capture_pageview/pageleave false, mask_all_text + mask_all_element_attributes
as defence in depth, and opt_out_capturing_by_default so init alone can never
capture. Events pass sanitizeProps(), mirroring the backend allowlist: a key not
on it is DROPPED and long strings refused, so a future caller cannot leak content
by adding a field. Backend down / no consent / no destination → stays off.
The token is taken from VITE_POSTHOG_KEY at BUILD time and is never committed —
a token-shaped literal trips the secret scanner and is a bad habit regardless.
release.yml injects it from a repo secret; the backend already reads
POSTHOG_PROJECT_TOKEN the same way. No token => no destination => the Privacy
toggle isn't offered and nothing can be sent, which is the right default for a
source build. A test fails if a phc_ literal is ever committed to that file.
posthog-js added to frontend/package.json; root bun.lock regenerated and
`bun install --frozen-lockfile` verified (the Docker gate).
11 tests: autocapture/session-recording/pageview off, starts opted-out, allowlist
drops text+paths+names, long strings refused, consent honoured in all three
failure directions, and no token literal in source. Frontend suite 1229 passed.
* test(analytics): guard the committed-token rule in the suite, not just in the scanner
The frontend typecheck failed on the guard I added: it reached for `node:fs`,
which has no type definitions in the frontend tsconfig (and would have been
cwd-dependent at runtime anyway). Wrong layer.
Source-scanning guards in this repo are Python tests (test_no_hardcoded_cjk,
test_no_literal_borders), so this one moves there — and gets strictly stronger
in the process: it scans every tracked file rather than analytics.ts alone, and
matches a PostHog key by SHAPE (phc_[A-Za-z0-9]{20,}), so a *different* key
can't slip through where the old test only knew about the one gitleaks caught.
---------
Co-authored-by: mergetest <nizam4103@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(memory): on unified memory, "offload" must mean UNLOAD — the 16 GB dub OOM (#1119) (#1122)
offload_tts_for_asr() exists to make room before WhisperX large-v3 (~3 GB) loads
for a dub. On CUDA it moves the TTS model to CPU. On Apple Silicon it did
NOTHING — an early return with the comment "MPS / CPU / DirectML don't benefit
from manual offloading".
That reasoning is right about the STRATEGY and wrong about the CONCLUSION. On
unified memory, moving a model "to CPU" frees nothing, because it is the same
physical RAM. But that means the fix is to RELEASE the model — not to skip
making room altogether.
Measured on a 16 GB M2, at the moment a dub begins:
TTS model resident 3,107 MB
backend footprint 4,170 MB
free RAM 4.17 GB
large-v3 then wants ~3 GB of that, alongside the app and macOS. The OS kills the
backend mid-transcription, and the stream "drops before emitting any segments".
On a unified-memory host the TTS model is now actually released when free RAM is
below a headroom threshold (default 6 GB, OMNIVOICE_UNIFIED_OFFLOAD_HEADROOM_GB),
and left warm when there's room — so a roomy machine pays no reload. get_model()
lazily reloads it on the next generation, so restore is correctly a no-op. The
CUDA path is untouched.
Verified end to end in a real process: model loaded → offload_tts_for_asr() →
`mm.model is None` and free RAM recovered. Previously it returned immediately and
freed nothing.
6 tests (releases when tight / stays warm when roomy / restore is a no-op /
no model is a no-op / a failing probe never aborts the dub / CUDA path unchanged).
This is a CAUSE, not another error-message fix.
Refs #1119#1113
Co-authored-by: mergetest <nizam4103@gmail.com>
fix(dub): stop losing the race for the crash marker — the stream-drop guess is back (#1119) (#1121)
Reported on v0.3.21, which ALREADY had the #1098 fix. The user still got
"Transcribe stream dropped… Likely ASR backend failed to load" — the guess that
fix was supposed to retire.
Why: streamDropError() consults the crash marker before falling back to the
guess, but it asked exactly ONCE, at the instant the stream dropped. The shell
learns of a dead backend from a ~2 s poll — it must notice the child exit and
write the marker. So the check raced that poll and lost: no marker yet ⇒ "no
crash" ⇒ fall back to the guess, even when the backend had just died.
That is precisely the race #1102 fixed for apiFetch. This path never got it — I
fixed the symptom in one place and left the identical bug in the other.
streamDropError now polls for the marker across a short window (8 s, 1 s apart)
before believing there was no crash, so a late-arriving marker is found and the
user gets the real cause — exit code + captured stderr, one click from the crash
notice — instead of a guess. Outside the Tauri shell there is no marker to wait
for, so it asks once and returns immediately (no 8 s stall for a browser/Docker
user). Injectable sleep/clock so the race is directly unit-testable.
3 new tests (a LATE marker is found, not missed / no marker ever still yields the
caller message / no shell asks exactly once). Frontend suite 1218 passed.
Fixes#1119
Co-authored-by: mergetest <nizam4103@gmail.com>
feat(privacy): opt-in analytics — hardened, off by default, enforced by code (#1120)
The rejected PR #1110 had a genuinely careful PII-free event design, but shipped
three things a local-first app can't: exception autocapture ON (raw tracebacks —
home paths, and in this codebase HF tokens out of exception messages — bypassing
core.failure.sanitize() entirely), no user consent or disclosure, and 3,069 lines
of PostHog wizard scaffolding. This is the same capability with those fixed.
core/analytics.py, three rules, each enforced and tested rather than promised:
1. OFF unless the user says yes. TWO gates must both be true: a build-provided
POSTHOG_PROJECT_TOKEN *and* the user's analytics_enabled pref, default False.
A default install transmits nothing, so "nothing leaves your machine" stays
literally true for everyone who doesn't opt in. A broken prefs file fails
CLOSED. OMNIVOICE_ANALYTICS_DISABLED=1 is a hard kill switch above both.
Withdrawing consent tears the client down immediately — no restart.
2. NO exception autocapture. Explicitly disabled; a test asserts the constructor
arg, because the SDK's default is the leak.
3. Metadata ONLY, by allowlist. Every property passes sanitize_properties(),
which DROPS any key not on _ALLOWED_PROPS and refuses long strings — so no
future caller can leak a take's text, a path, or a voice name by adding a
field. text_length is the LENGTH; the text itself has no way through.
The person id is a random per-install UUID — not hardware, hostname, or username.
UI: Settings → Privacy → "Help improve OmniVoice" states in the panel exactly
what is sent, exactly what never is, and that it can be turned off — rather than
burying it in a policy. No destination in the build (any source build) → the
toggle isn't shown, because an inert switch would be a lie.
Docs: README FAQ answers "does OmniVoice collect any data about me?" honestly.
Also fixed a bug I'd introduced in my own wiring: the generation event referenced
variables not in scope, and the call site's bare `except: pass` swallowed the
NameError — so the event would have silently never fired. The call site now logs.
12 tests (default-off / opt-in without token still can't transmit / both gates /
kill switch / consent withdrawal / prefs failure fails closed / allowlist drops
text+paths+names / long strings refused / autocapture OFF / never raises /
random install id). Backend 2936 passed; frontend 1211 passed.
Refs #1110
Co-authored-by: mergetest <nizam4103@gmail.com>
fix(api): an alive-but-unresponsive backend now says so, instead of "it stopped" (#1113) (#1117)
A v0.3.21 user hit "Can't reach the local OmniVoice backend — it may still be
starting up, or it stopped" — on the release that was supposed to end that class.
Reading the report tells us what happened WITHOUT reproducing it: they got the
generic message, not the crash story. On 0.3.21 apiFetch consults the crash
marker, and a real process death always writes one. No marker ⇒ the backend did
not die. And the shell was still reporting `ready` ⇒ the process was alive.
So both halves of that sentence were false: it had not stopped, and it was not
starting. It was ALIVE and not answering — a job wedged holding the engine
(troubleshooting §14: a generate/transcribe too heavy for the available memory
starves the worker). Telling that user to "restart the app" is the wrong advice
for a stuck job, and it buries the real cause.
When the reconcile window expires and the shell STILL says `ready`, we now know
the process is running, so say that: name the wedged-job cause, point at
Settings → Logs → Backend for what it was last doing, and at a smaller
model/engine as the usual fix. The genuine "stopped or starting" message stays
for the case where the shell has no idea (no shell — browser/Docker), and the
crash story still wins whenever a marker exists.
This does not claim to stop the wedge — it stops the app from lying about it,
and gives the next reporter the right words.
2 new tests; frontend suite 1213 passed.
Refs #1113
Co-authored-by: mergetest <nizam4103@gmail.com>
fix(bootstrap): stop clobbering the real failure reason with "never started" (#1112) (#1116)
An Intel-Mac user got "Backend process exited (never started) — no error output
captured", and reported that Retry and Clean & Retry did nothing at all. Both
symptoms have one cause, and it destroys EVERY precise bootstrap diagnosis — not
just the Intel one.
ensure_venv_ready() diagnoses the real reason a start failed (Intel Macs can't
run the backend — PyTorch ships no macOS x86_64 wheels, #889; a failed uv sync;
a blocked GitHub) and records it via fail() as Failed{that reason}. It then
returns None, spawn_backend returns None, and spawn_backend_and_wait — seeing no
child — OVERWROTE the stage with the generic "Backend process exited (never
started) — no error output captured". The honest cause was written and
immediately bulldozed.
Which also explains the dead buttons: the UI's hint matcher keys off the
specific message text, so with it gone the Intel hint ("retrying can never
help") never fired. The user was offered a Retry that re-failed identically
every time, looking like the button did nothing.
- bootstrap.rs: already_diagnosed() — a caller that knows the CAUSE outranks one
that only knows the SYMPTOM. When the stage is already Failed, the spawn
watcher keeps it. A real exec failure still forms the generic message (it
writes its diagnostic to backend_err.log and leaves the stage un-Failed), and
a genuine post-start crash is untouched.
- BootstrapSplash: isUnrecoverableFailure() — an Intel Mac can never be retried
into working, so don't offer the dead end; say so instead. Keyed off the same
hint the matcher produces, so the two can't drift.
3 Rust tests + 2 frontend tests. Rust 81 passed; frontend 1213 passed.
Fixes#1112
Co-authored-by: mergetest <nizam4103@gmail.com>
feat(firstrun): show the app version beside the app name on all three first-run screens (#1115)
The version was already in the Models & Engines masthead, but on Setup and
Installing it was buried in a footer as "OVS · v0.3.x" — the two screens a user
is most likely to screenshot when something goes wrong during install. Move it up
beside the app name on both, so all three acts of the first run (setup →
installing → models & engines) carry the same masthead and any screenshot
identifies the build at a glance. The footers keep their real content (the
download total on Setup); the duplicate version line is gone.
Frontend suite 1211 passed (incl. the css-token guard, which is what catches a
token that doesn't exist and silently renders nothing).
Co-authored-by: mergetest <nizam4103@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
feat(privacy): Settings → Usage — local-only insights instead of cloud analytics (#1114)
* feat(privacy): Settings → Usage — local-only insights, the answer to cloud analytics
A PostHog integration was proposed and rejected (PR #1110, closed): sending
usage events to a third-party endpoint would break the one promise this product
is built on — nothing leaves your machine — and local-first is the reason people
choose it over ElevenLabs. But the question analytics was meant to answer ("how
am I using this?") is a fair one, so answer it locally.
services/local_stats.py aggregates the history the app has ALREADY written to
the user's own SQLite DB: takes, audio produced, compute time, starred, active
days, voices/dubs/projects/exports, and distributions by mode and language.
GET /stats/usage serves it over loopback; Settings → Usage renders it.
The three properties that stop this becoming telemetry by accident:
- READ-ONLY. No new table, column, or event stream. Delete the feature and not
one byte of stored data changes.
- NO CONTENT. Counts and totals only — the `text` column of a take is never
read and never returned; no paths, no ids, no person. Pinned by a test that
asserts the payload contains no take text, no /Users/ path, no row id.
- NO NETWORK. There is no client, no endpoint, no token. It has no way to send
anything anywhere.
The panel states the guarantee in the UI, because a privacy promise the user
can't see isn't worth much.
Route added to the API-surface snapshot (the inventory guard caught it, as
designed — one line: GET /stats/usage).
4 backend tests (aggregation / never-leaks-content / empty install / missing
table degrades to 0) + 4 frontend tests. Backend suite 2924 passed; lint,
format, typecheck clean.
Closes the analytics question opened by #1110.
* fix(settings): use the real --chrome-fg-dim token in UsageTab (css-token guard)
cssTokens.test.js is a frontend guard that every var(--…) a component
references actually exists — an undefined custom property with no fallback is an
invalid declaration, so the style silently does nothing. UsageTab referenced
--chrome-fg-subtle, which doesn't exist; the dim sub-label token is
--chrome-fg-dim (what the other settings panels use).
My miss: I ran the full BACKEND suite but only the two new frontend test files,
so this guard never ran locally. Full frontend suite now green (1211 passed).
---------
Co-authored-by: mergetest <nizam4103@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
feat(memory): honest /model/loaded accounting + a free-memory budget probe (#1111)
Two gaps the model-management investigation surfaced, now closed.
1. /model/loaded reported only the OmniVoice core, so a resident second engine
(mlx-audio, cosyvoice, …) and the warm dictation ASR were INVISIBLE — the
memory picture looked ~2 GB lighter than reality on exactly the boxes that
OOM. list_loaded() now enumerates the in-process engine instances (from the
generate path's cache) and the capture ASR singleton too, and adds a
`system` block: free/total RAM (and free VRAM on a dedicated GPU) plus a
low-memory advisory. Verified live: after an mlx-audio generate the panel
shows `engine:mlx-audio` and `system: {ram_available_gb, ram_total_gb}`,
where before it showed nothing.
2. services/memory_budget.py: available_memory() reads FREE memory now (device
caps only reports total, once per process) — free system RAM via psutil,
free VRAM via torch.cuda.mem_get_info on a dedicated GPU; on MPS the RAM
figure is what matters (unified memory). low_memory_warning() returns an
advisory below a headroom threshold (OMNIVOICE_LOW_MEMORY_HEADROOM_GB,
default 2). The generate path calls log_if_low() before a load, so a later
OOM kill leaves a breadcrumb pointing at the load that tipped it instead of
a silent death.
Advisory only — nothing is blocked: the OS reclaims cache, and refusing a load
on an estimate would brick machines that would cope. The single-active-engine
eviction (#1105) is what actually reclaims room; this makes the picture honest
and leaves forensics.
6 new unit tests (threshold logic / VRAM-precedence / never-raises); frontend
LoadedModelsResponse typed for the new `system` field + id shapes. Backend
suite 2918 passed; typecheck clean.
Co-authored-by: mergetest <nizam4103@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
fix(uninstall,storage): remove the saved-env leftover; count sidecar engines in disk usage (#1108)
Two recon findings from the reset work, fixed properly (whole class + tests +
docs), plus the destructive reset path is now exercised end-to-end.
1. ~/.config/omnivoice/env survived every uninstall. The app persists the
model-cache location (and a possible HF_TOKEN) there via
backend/core/user_env.py, but the in-app "Remove all data" (uninstall.rs),
uninstall.sh, and uninstall.ps1 all walked past it — so a reinstall silently
inherited the old file and redirected downloads to a maybe-deleted location.
All three now remove it. It's the same expanduser("~/.config/omnivoice/env")
path on every OS, so the Windows script uses %USERPROFILE%\.config\omnivoice.
is_recognizably_ours accepts it (contains "omnivoice"); docs tables updated.
2. Disk usage measured the wrong engines dir. storage_report.default_engines_dir()
returned backend/engines (built-in engine *modules*, no venvs), while sidecar
installs live in DATA_DIR/engines/<id>. So a multi-GB IndexTTS-2 install was
invisible in the engine-venv category and rolled into data/"other". Now points
at DATA_DIR/engines and sizes the WHOLE install (venv + checkout + weights),
with the data category claiming that subtree so it isn't double-counted.
Reset hardening: extracted purge_scopes() as a pure fs function (no AppHandle),
so the actual delete loop runs in tests against a real on-disk install tree —
"everything" wipes the install but spares the venv/foreign temp/sibling folders,
a settings reset keeps content+config+models, and a poisoned data_dir="$HOME"
deletes NOTHING. This is the live drive-through of the destructive path, minus
the GUI.
Also: gitignore the node_modules symlink form (the directory rule node_modules/
never matched a worktree symlink, so it kept slipping into commits).
Tests: Rust 78 (6 new), storage_report 20 (2 new incl. once-not-twice count +
default-dir guard), frontend 1207, i18n probe green, format+lint clean.
Co-authored-by: mergetest <nizam4103@gmail.com>
release: freeze v0.3.21 — version bump, lockfiles, changelog (#1107)
package.json (single source of truth) + the three toolchain mirrors to 0.3.21;
Cargo.lock + uv.lock regenerated (version lines only). CHANGELOG [Unreleased]
renamed to [0.3.21] — 2026-07-12, "the memory release", sections merged into
house style (one Added, one Fixed).
Ships the 16 GB OOM class fixes end to end: idle-release the dictation ASR
(#1104) + one TTS engine resident at a time (#1105), plus the scoped
Settings → Storage reset/uninstall pair (#1089/#1099/#1100) and the release-
asset-split workflow fix (#1106) so this tag uploads to a single release.
test_app_version.py lockstep: 6 passed. bun install --frozen-lockfile: clean.
Co-authored-by: mergetest <nizam4103@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
feat(memory): one TTS engine resident at a time — stop stacking models on 16 GB (#1105)
Measured on a 16 GB M2: a generate on omnivoice (~2.8 GB core) followed by a
generate on mlx-audio left BOTH resident (footprint 3.9 → 4.3 GB) because the
OmniVoice core lives in model_manager.model while every other engine lives in
engines._ENGINE_INSTANCES — two caches that never coordinated, and the latter
was never unloaded. That accumulation is a direct contributor to the memory
pressure behind the "Can't reach the local backend" OOM deaths.
- services/engine_memory.py: evict_other_tts_engines(keep_id) unloads every
OTHER resident TTS engine before the incoming one loads — spans both stores
(the OmniVoice core under its async lock, and the instance cache). No-op when
nothing else is resident, so steady-state single-engine use pays nothing; only
a real switch evicts. Default on; OMNIVOICE_SINGLE_ENGINE_RESIDENT=0 to keep
several warm. Wired into the /generate path right after the engine resolves.
- TTSBackend.unload() (the ABC default) now actually frees the held model: it
clears _MODEL_ATTRS (_model/_tts) and empties the device cache. Every
in-process engine but OmniVoice previously inherited a NO-OP unload(), so an
engine switch dropped the instance ref but left its model for GC with the GPU
cache un-emptied. One change fixes all of them and is future-proof.
- FasterWhisperBackend.unload() cleared self._asr — an attribute it never
assigns — so its model in self._model was never freed. Fixed.
Live-verified: omnivoice → mlx-audio now DROPS footprint 2300 → 1541 MB (core
evicted) instead of climbing to 4305 with both resident. 7 new unit tests
(eviction spans both stores / keeps the active engine / no-op when disabled /
a failing unload doesn't abort the sweep / ABC unload frees + is idempotent),
order-independent. Backend suite green.
Refs the 16 GB OOM class (#1076#1092#1093#1101)
Co-authored-by: mergetest <nizam4103@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
fix(release): attach uninstall scripts with gh release upload, not a 2nd softprops publish (#1106)
v0.3.20 shipped with ONLY the Linux AppImage — the macOS dmg and Windows msi
were missing from the published release. Root cause: the uninstall-scripts job
(added in #1097) ran softprops/action-gh-release@v2 as a SECOND publish for the
tag, which raced tauri-action's per-matrix draft and split the platform
installers across two releases (a draft holding mac/windows, a published one
holding linux + checksums + the scripts). The updater manifest split too — each
release's latest.json covered only its half of the platforms.
Fix: attach the scripts with `gh release upload <tag> … --clobber` (which adds
assets to the EXISTING release and can never create a second one) instead of
softprops. `needs: [build]` guarantees the release exists first; --clobber keeps
a re-run idempotent.
(v0.3.20 itself was already repaired by hand — the mac/windows bundles were
re-attached from the draft, the latest.json manifests merged into one covering
all 8 platform keys, and the stray draft deleted. This prevents recurrence.)
Co-authored-by: mergetest <nizam4103@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
feat(settings): factory reset gets scopes — preferences, settings, assets, everything (#1100)
* feat(settings): factory reset gets scopes — preferences, settings, assets, everything
Factory reset did exactly one thing: clear localStorage. The only other option
was "Remove all data", which deletes the Python env and quits. Between "forget
my theme" and "wipe the machine" sat every reset a user actually needs — drop a
corrupt model download, remove a wedged sidecar engine, put the settings back
without losing a single voice — and none of them existed.
Settings → Storage → "Reset & remove" now offers four tiers (UI preferences /
all settings / downloaded assets & models / everything OmniVoice did) plus a
per-scope checklist. Every scope shows its real on-disk size, and the number on
the confirm button is exactly what gets freed.
Why the shell and not the backend: a loaded model memory-maps its weights out of
the HF cache (locked on Windows while mapped), and ensure_dirs() runs at import,
so a backend cannot delete voices/ or outputs/ and still write to them. reset.rs
stops the backend, deletes, and starts it again — and that restart is also the
repair: the fresh process re-runs ensure_dirs() and alembic, so a removed
database comes back empty rather than missing. retry_bootstrap's respawn path is
extracted to bootstrap::respawn_backend so both callers share one implementation.
Deliberate scope choices:
- "Everything" stops short of the managed Python env, so a reset hands back a
working app on the first-run screen. The env is the uninstaller's business.
- A settings reset keeps the storage locations (config.json, the user env file).
Clearing the model-cache pointer would strand gigabytes at a path the app no
longer looks in — install shape is not a preference.
- content deletes the DB with the media: rows without files is how you get a
library full of broken entries.
- The shared HF cache is flagged as shared only when it IS — computed, so Windows
and portable installs (app-private cache) get no caveat they don't need.
Safety: nothing is removed unless it sits inside a validated root — one carrying
an OmniVoice-owned path component OR holding an OmniVoice signature file, which
is what lets a custom data dir on an external volume be cleared while a mis-set
data_dir: "/" is refused. Voices/projects/audio need the word typed.
9 Rust tests (guard, scope composition, shared-cache computation) + 14 frontend
(planning purity, typed confirm, disk-vs-frontend split, shared warning).
Border utilities follow the design guard (tests/test_no_literal_borders.py).
* feat(settings): give the Storage panels a design — proportional sizes, live totals, real tokens
"Remove all data" listed four folders as a flat run of text: a 7.5 GB model cache
and a 391-byte config file rendered at identical visual weight, so the one thing
worth seeing — where the space actually went — was the one thing you couldn't.
And the 391 B folder said "0 KB", which reads as "nothing here".
- New shared StorageTargetRow, used by BOTH destructive panels so they read as
one system: icon, label, dimmed path (truncated, full text on hover), size, and
a proportional bar showing that row's share of what will be freed. Unticked
rows claim none of the bar — the bars must sum to what the button promises.
- The shared HF cache moves OUT of the confirm dialog into its own "Optional"
row with the checkbox and the caveat in the list. Ticking it now moves the
running total in front of the user, instead of springing a different number on
them at the point of no return. The dialog lists exactly what is going.
- One byte formatter for both panels (settings/bytes.js). models/format.fmtBytes
floors at kilobytes, hence "0 KB"; it stays where it is for the model store.
Real fix underneath: three of the tokens these panels styled with DO NOT EXIST
(--chrome-fg-subtle, --chrome-bg-raised, --color-warning). An undefined var()
makes the declaration invalid, the browser drops it, and the element silently
inherits — which is why the paths that were meant to recede rendered at full body
weight. That is a whole class of bug that fails invisibly, so it gets a guard:
src/test/cssTokens.test.js fails on any var(--token) in JSX not defined in a
stylesheet, with runtime-injected tokens (Radix, inline-style hues) allowlisted
by reason. Six pre-existing offenders elsewhere in the app are recorded as
known-broken and ratcheted so the list can only shrink — they are real bugs, but
each is a visual change that wants its own review.
Frontend suite 1196 → 1205 (6 UninstallPanel component tests incl. the live
total and the bar proportions; 3 token-guard tests, verified fail-before).
* fix: green CI + finish the token sweep + snapshot the panels
Three things on top of the redesign:
1. CI was red on tests/probe/test_probe_i18n.py — removing the eight dead
`factory_reset_*` keys from en.json orphaned them in all 20 other locales
(the probe forbids a non-en key absent from en). Removed them everywhere.
This guard scans locales at pytest time; a frontend-only run never sees it.
2. Finished the undefined-token sweep instead of grandfathering it. Six bare
`var(--token)` references resolved to nothing; the only genuinely undefined,
fallback-less one in shipping panels was `--chrome-input-bg` (input fields
AND progress-bar tracks AND skeletons across StoragePanel, StorageUsagePanel,
HistoryRetentionPanel, ModelStoreTab — tracks were rendering with no
background at all). Repointed to --chrome-hover-bg. The rest
(--chrome-menu-bg, --chrome-bg-inset, --border, --input-bg, --muted) already
carry `var(--x, fallback)`, which is valid CSS. So cssTokens.test.js now
checks only the BARE form and ships with zero exceptions — no known-broken
ratchet, because there is nothing left broken.
3. Registered both Storage panels in the visual-regression harness (a Tauri
`invoke` stub added to providers.jsx alongside the existing fetch stub) and
committed baselines across all three themes. This is how I actually looked at
the redesign: the bars render proportional (the 720 KB voices row fills, the
391 B row is a sliver), the shared-cache row sits in its own Optional group,
and every token now resolves in default/midnight/catppuccin. `_forceAdvanced`
on ResetPanel opens the checklist for the snapshot; no effect on the toggle.
Full backend suite 2897 passed (incl. the i18n probe). Frontend 1205.
* style: oxfmt the new panels and specs
Format-check is a CI gate (oxfmt --check); the new files weren't run through
oxfmt --write. No behavior change.
* chore: stop tracking the node_modules symlink
A worktree-local symlink slipped past .gitignore (which lists node_modules/ —
the directory form — so it never matched the symlink file). Removed from the
index; the symlink stays on disk for local test runs.
---------
Co-authored-by: mergetest <nizam4103@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(memory): idle-release the dictation ASR — the real cause of the 16 GB OOM deaths (#1104)
Four "Can't reach the local OmniVoice backend" reports (#1076/#1092/#1093/#1101)
all died at the same moment: during a generate, on a 16 GB machine. Measuring it
(phys_footprint, not RSS — RSS badly undercounts MPS unified memory) showed the
generate was never the problem: it costs ~116 MB. The problem is the BASELINE —
the backend sits at ~6.2 GB *idle*: TTS 3.8 GB plus ~2 GB of warm capture ASR.
The TTS model has always been idle-unloaded (model_manager.idle_worker). The
capture/dictation ASR singleton never was — one dictation warmed it and it
stayed resident for the life of the process. So the app dutifully freed 3.8 GB
of TTS while silently holding 2 GB of ASR forever, and on a 16 GB Mac that
baseline plus the app plus macOS is enough for the OS to kill the backend
mid-generate. That death surfaces as the "can't reach the backend" error — the
class #1102 made honest and this fixes at the source.
- asr_backend.release_idle_capture_backend(idle_s): unloads the warm capture
singleton once it's been unused that long; no-op under a live lease, when
nothing's loaded, or when recently used; never raises (idle_worker calls it
on a loop).
- capture_lease(): pins the singleton for a live dictation session's whole life
(the stream holds the backend without re-resolving it, so the reaper must not
unload the model mid-sentence); wrapped around both sherpa handlers in
capture_ws. Releasing restarts the idle clock.
- Both capture getters stamp _touch_capture() so any handout resets the clock.
- idle_worker runs the reaper each tick with the same idle timeout the TTS model
uses, then free_vram().
Cost: a ~1.4 s model re-warm on the next dictation after a full idle timeout —
the same bargain the TTS model already makes. 8 new unit tests (releases when
idle / never while leased / never when recently used / lease released on raise /
nested refcount / failing unload still drops the ref / no-op when empty).
Backend suite 2905 passed; verified live against the running backend.
Fixes the crash class behind #1076#1092#1093#1101
Co-authored-by: mergetest <nizam4103@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
release: freeze v0.3.20 — version bump, lockfiles, changelog (#1103)
package.json (single source of truth) + the three toolchain mirrors to 0.3.20;
Cargo.lock + uv.lock regenerated (version lines only). CHANGELOG [Unreleased]
renamed to [0.3.20] — 2026-07-12 with the release headline, sections merged into
house style (one Added, one Fixed).
Ships the #1101 stale-"ready" race fix (0.3.19 users are hitting it today), the
in-app uninstaller (#1099), and the Linux/Windows backend-log-dir fix.
test_app_version.py lockstep: 6 passed. bun install --frozen-lockfile: clean.
Co-authored-by: mergetest <nizam4103@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
fix(api): don't believe a stale "ready" — close the #1094 race that #1101 hit (#1102)
A 0.3.19 user still got "Can't reach the local OmniVoice backend" (#1101), on
the very release that was supposed to end that class. The fix had a hole.
apiFetch asks the shell whether a start/restart is in progress before erroring.
But the shell's stage is a 2-SECOND POLL, not a live probe: when the backend dies
mid-generate, supervise_backend needs up to ~2 s to notice the child exit, write
the crash marker, and set_stage(StartingBackend). apiFetch asked exactly ONCE, at
the end of the ~2.9 s transport cascade — so it very often still read `Ready` and
fell straight through to the generic toast. Worse, the crash marker usually
wasn't written yet either, so even the honest crash story (#941) was missed and
the user got the vague message.
The bug was trusting `ready` as authoritative. A transport failure CONTRADICTS
it: if the backend were reachable, the fetch would have succeeded. So `ready` is
now treated as a STALE belief — we keep retrying across a bounded reconciliation
window (12 s), re-asking each time, which lets the supervisor catch up and flip
to `starting` (→ the long wait + the restarting banner) and gives the crash
marker time to land so the error can name the real cause. `failed` (the shell
gave up) and `unknown` (no shell — browser/Docker) still error immediately, so a
genuinely dead backend is as prompt as before.
The reporter's trace is the signature: generate:start (design) →
generate:stream-fallback → the toast, on a 16 GB M1/MPS box — i.e. the backend
process died under memory pressure during generation, which is the underlying
crash this now surfaces honestly instead of guessing at.
Regression test reproduces it against the shipped 0.3.19 logic (fails) and passes
on the fix. Frontend 1184 passed; backend 2897 passed.
Refs #1101
Co-authored-by: mergetest <nizam4103@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
feat(settings): in-app uninstall — Settings → Storage → Remove all data (#1089) (#1099)
* feat(settings): in-app uninstall — Settings → Storage → "Remove all data"
The v0.3.19 uninstaller was a SCRIPT, which never reaches the people who need
it: anyone who installed the .dmg / .msi / AppImage has no repo to run
scripts/uninstall.sh from — exactly the reporter in #1089, an AppImage user.
"Where is uninstall in the app?" had no answer. Now it does.
New Tauri commands (uninstall.rs):
- uninstall_scan — every folder this install owns, with real sizes, resolved
through the same setup.rs helpers the app itself uses, so custom + portable
locations are cleaned instead of the defaults being assumed.
- uninstall_purge — stops the backend (marking the kill intentional so the #567
supervisor doesn't respawn one into the directories being deleted), removes
the folders, and lets the UI quit the app: the Python env it runs on is gone,
so there is nothing to return to.
This lives in the Rust shell, not the backend, because the biggest thing to
remove is the managed Python environment and the backend is RUNNING FROM IT — a
process can't delete its own interpreter (and Windows locks the files).
Safety: every path must pass is_recognizably_ours() before any remove_dir_all —
absolute, not `/` or $HOME, and carrying an OmniVoice-owned component (unit
tested both ways). The shared Hugging Face cache is reported separately and is
OPT-IN behind its own checkbox with the caveat spelled out: it's the standard HF
cache other ML tools share, so sweeping it up silently would delete models this
app never downloaded. Deleting voices/projects is irreversible, so the confirm
requires TYPING the word, not just a click.
Also fixes a real bug in what shipped in v0.3.19: the scripts and docs missed
where the BACKEND writes its logs — ~/.local/state/OmniVoice on Linux and
%LOCALAPPDATA%\OmniVoice\Logs on Windows (backend_log_path(), backend.rs) — so
every Linux/Windows uninstall left a stray log dir behind. Covered now in the
scripts, the docs, and the in-app scan.
And the scripts now ship as release assets, so cleanup is possible without
launching the app at all.
Rust: 2 new guard tests. Frontend: 6 new tests (the size on the confirm button
must equal what actually gets deleted); suite 1182 passed. Docs synced.
Refs #1089
* fix(settings): drop token border utilities from UninstallPanel (design guard)
tests/test_no_literal_borders.py::test_no_token_border_utilities_in_jsx is a
backend guard that scans JSX — so a frontend-only test run misses it. It forbids
`border-[var(--chrome-border)]` structural utilities: the app-wide border removal
converted every panel/row frame away from them, and they render a stray hairline
the moment the token doesn't resolve transparent.
Row dividers → spacing + an alternating `--chrome-hover-bg` tint; the opt-in
checkbox card → a background tint; the confirm input → the sanctioned arbitrary
`[border:1px_solid_var(--chrome-border)]` property form the other settings inputs
already use (explicitly not flagged by the guard).
Guard green.
---------
Co-authored-by: mergetest <nizam4103@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
release: freeze v0.3.19 — version bump, lockfiles, changelog (#1096)
package.json (single source of truth) + the three toolchain mirrors
(Cargo.toml, pyproject.toml, core/version.py) to 0.3.19; Cargo.lock + uv.lock
regenerated (version lines only). CHANGELOG [Unreleased] renamed to
[0.3.19] — 2026-07-12 with the release headline; all six entries carry their
(#PR) refs and extract cleanly as the GitHub Release body.
Rebuilt on current main so the freeze covers everything that landed after the
first cut: #1088 (streaming preview), #1097 (uninstaller), #1098 (stream-drop
honesty), plus the backfilled #1088 changelog entry.
test_app_version.py lockstep: 6 passed. bun install --frozen-lockfile: clean.
Co-authored-by: mergetest <nizam4103@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
fix(dub): a stream that dies with no terminal event tells the truth, not a guess (#1098)
"Transcribe stream dropped before emitting any segments. Likely ASR backend
failed to load — check backend log + Settings → Models." (#1062) is a GUESS, and
usually the wrong one.
The backend is contract-bound to emit a terminal event on every stream even when
it fails (guarded by test_transcribe_stream_never_closes_without_terminal_event),
so a stream that simply goes SILENT did not "probably fail to load a model" — the
backend PROCESS went away underneath it. On smaller GPUs the usual trigger is a
native out-of-memory abort while loading the ASR model on top of a still-resident
TTS model: it kills the process rather than raising a catchable Python error, so
no terminal event can be emitted. The reporter's box (RTX 3050, 8 GB) had run
three generates immediately before the dub, which is exactly that shape.
New utils/backendCrash.streamDropError(): consult the shell's crash marker (#941)
and, when one exists, surface the honest story — exit code, how long ago, the
captured stderr one click away via the crash notice, and the real next step (free
VRAM / smaller ASR model) instead of "check the backend log". With no marker (or
outside the Tauri shell) the caller's own message stands, so nothing regresses in
the web/Docker build. Wired into BOTH silent-drop paths in useDubWorkflow (the
transcribe stream and the prep stream), killing the whole class rather than the
one reported message. `getCrash` is an injectable seam (the endpoint_race idiom)
so the branch logic is unit-testable without a shell.
Frontend suite: 1176 passed (3 new).
Closes#1062
Co-authored-by: mergetest <nizam4103@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
docs(changelog): backfill the streaming-TTS-preview entry (#1088)
#1088 shipped a user-facing feature with no CHANGELOG entry, which the
release-notes rule forbids — release.yml extracts the section verbatim as the
GitHub Release body, so it would have shipped without its headline feature.
Backfilled in house style ahead of the v0.3.19 freeze.
feat(install): clean uninstaller + a straight answer to "where is my data?" (#1097)
A Linux AppImage user asked which folders to delete to remove OmniVoice and
whether an uninstaller exists (#1089) — they had to guess. They shouldn't have
to: the app is fully local, so uninstalling IS just deleting the folders it
wrote, and we never documented them.
- scripts/uninstall.sh (macOS/Linux) + scripts/uninstall.ps1 (Windows): find
every OmniVoice folder — app data, the multi-GB managed Python env, config,
logs — plus, listed SEPARATELY because it is a shared cache, the Hugging Face
model cache. Print each with its size as a DRY RUN and stop; delete only on
--yes (--models / -Models to include the shared cache). They honor the same
env overrides the app reads (OMNIVOICE_DATA_DIR, OMNIVOICE_CACHE_DIR,
HF_HOME, HF_HUB_CACHE), and never touch the app binary or anything outside
the paths they list.
- docs/install/uninstall.md: the complete per-platform path table (what each
folder holds and how big it is), the shared-HF-cache caveat, custom/portable
locations, per-platform steps to remove the app itself, and what to keep if
you plan to reinstall.
- Linked from the README FAQ, SUPPORT.md, and install troubleshooting.
Paths mirror backend/core/config.py + frontend/src-tauri/src/setup.rs.
Verified on macOS: dry-run lists the real dirs; sandboxed HOME runs confirm
--yes removes app folders while KEEPING the shared cache, --models removes it,
and the env overrides retarget correctly.
Closes#1089
Co-authored-by: mergetest <nizam4103@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
feat(tts): streaming playback preview — audio starts on the first chunk while the rest renders (#1088)
* feat(tts): streaming playback preview — audio starts on the first chunk while the rest renders
Long scripts meant staring at a spinner until the ENTIRE render finished.
POST /generate now takes stream=true (NDJSON: start → N × chunk → done),
synthesizing the existing Wave 1.2 sentence-boundary text chunks
sequentially and yielding each chunk's audio (base64 PCM16, with the same
effect chain the final take gets) the moment it's rendered — engine-
agnostic by construction, no per-engine token streaming.
The saved take is untouched: the raw chunks go through the SAME concat →
effect-chain → watermark → save → history → retention pipeline as the
classic path (extracted verbatim into _finalize_generation, now shared by
both), so the on-disk file is byte-identical to a non-streamed render
(regression-tested). [pause]-marker inputs and short single-chunk texts
keep their unchanged single-shot pipeline and stream as one chunk. Each
chunk gets its own generate budget on the GPU pool (model load already
warmed under the #1039 load budget before streaming starts), so a long
script can't time out merely for being long.
Frontend: when the preview would auto-play anyway and Web Audio exists
(all three Tauri webviews), useTTS drives the stream and the global
mini-player (#1042) starts playback from the first received chunk —
scheduled AudioBufferSource nodes with the backend's linear crossfade,
live seek within the buffered region, progressive peaks, growing
duration, and a "Streaming preview…" label that flips to "Generated
audio" on completion. Underruns (synthesis slower than playback) wait at
the buffered edge and resume when the next chunk lands. Any MID-stream
failure (in-band error event, transport drop, Web Audio failure) tears
down silently and falls back to the classic whole-file flow — the user
sees nothing beyond the old wait. Pre-stream HTTP errors keep their
ApiError identity so real 400/503s surface exactly as before, without a
wasted second render.
Tests: backend — incremental delivery proven at the ASGI boundary (fake
engine with per-chunk delay; TestClient/httpx buffer whole bodies and
can't observe it), final-file identity vs the classic path, mid-stream
error → error event with no done/history/file, single-chunk short text,
classic default unaffected. Frontend — NDJSON client, chunk player
(seek/stop/label flip), PCM decode, peaks, fallback error taxonomy.
Verified end-to-end over real HTTP (uvicorn): 1.0s chunk-arrival spread,
saved file byte-identical to the classic take.
* style: oxfmt line-length fix in streamingTts.js
* test(tts): cover the native OmniVoice model path in streaming preview tests
Per-chunk generate calls with duration=None + saved-take identity vs the
classic render, mirroring the pluggable-engine coverage.
* test(tts): make streaming preview tests hermetic — pin outputs dir + history DB
The three streaming tests read the saved take back through
core.config.OUTPUTS_DIR while the router writes it through
api.routers.generation.OUTPUTS_DIR. Those are separate module bindings, and a
full-suite run can split them apart — an earlier test that reloads
core.config/main under a tmp data dir (test_dub_transcribe's app_client) moves
one binding and not the other, so the save lands in one dir and the read-back
looks in another: the CI FileNotFoundError under outputs/<uuid>.wav.
Add an autouse fixture that pins BOTH OUTPUTS_DIR bindings and the history DB
(via the ensure_schema.__globals__ get_db seam the takes suite already uses
against the #909/#932 module-purge leak) at per-test throwaway paths, so each
test is hermetic and order-independent. Deterministic fail-before (divergent
bindings pre-seeded) → 3 failures identical to CI; pass-after → green.
---------
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: mergetest <nizam4103@gmail.com>
chore: retire finished planning archives from the repo root (#1095)
Removes ~110 files of process noise (all preserved in git history):
.planning/ (GSD-era phases/quick-plans/issue-clusters; workflow retired
2026-07-08), specs/ (spec-kit specs for shipped features 001-007),
design/ (pre-React ASCII mockups), research/ (legacy Gradio archive),
and .agents/ (rules for a third-party agent tool no longer in use).
The four load-bearing decision docs move to docs/adr/ with an archival
note; every live pointer follows (gguf engine module docs + quant_map,
inject-apprun.sh, pyproject/test comments, fixture README + its seed
script — kept byte-identical). The CJK allowlist drops the deleted
legacy_gradio entries; STRUCTURE.md and ROADMAP.md document the removal
instead of linking into it.
Backend suite: 2891 passed.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
fix: first-run wizard version + mirror-unreachable rescue + lifecycle-aware backend reachability (#1094)
Three fixes from the same first-run session report:
- SetupWizard shows v{APP_VERSION} in its masthead (same identity mark as
the install splash footer), so setup screenshots identify the build.
- A dead configured HF mirror no longer strands the wizard: the
install_error SSE now carries docs_topic (core.failure.classify), and
WizardLibrary renders the MirrorRescue quick-pick (extracted from
SetupWizard, now including the official preset) next to the failed row,
retrying it the moment a new endpoint is applied. PUT /hf-mirror clears
the install cooldowns (no 429 on the immediate retry) and clearing to
official also drops the legacy hf_endpoint pref that silently kept the
dead mirror in effect. The hint's false "applied when the app starts"
claim is corrected: downloads resolve the endpoint per call, retry
first, restart only if it still fails.
- "Can't reach the local OmniVoice backend" stops firing during real
start/restart windows: a respawn takes 10-20+s (venv spawn + torch
import) but the transport cascade gave up at ~2.9s. apiFetch now asks
the shell (bootstrap_status via utils/backendLifecycle) whether a
start/restart is in progress and keeps retrying while it is (capped at
120s, matching the supervisor's respawn budget); the new
BackendRestartBanner finally implements the reconnecting banner the
#567 supervisor has emitted events for all along. Truly dead backends
(or non-Tauri deploys) still error promptly.
Regression tests for all three layers; docs synced
(downloading-models.md, troubleshooting.md §14b); CHANGELOG [Unreleased].
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
feat(asr): complete activation flow for the OpenAI-compatible remote ASR engine (#1087)
The openai-compat-asr backend (#877) shipped with settings routes but no
discoverable activation path: the config panel hid in Settings → Models,
its hint text claimed "there's no in-app engine picker for ASR yet"
(stale — the matrix has one), and there was no way to check a server
actually answers before pointing a dub/dictation run at it.
Configure → test → activate now live on one screen, Settings → Engines:
- The ASR family tab mounts the config panel (URL / model / optional
API key) below the engine matrix; saving refetches the matrix via a
new reloadToken prop so the engine row flips unavailable → available
and its "Use" button appears without a manual refresh.
- New "Test connection" button + loopback-gated
POST /api/settings/asr-openai-compat/test: saves first (same
stale-config contract as /llm-providers/{id}/test), then probes
GET {base_url}/models — no audio leaves the machine. The structured
verdict maps to localized, actionable messages: latency + whether the
configured model is listed on success; classified auth_failed /
http_error / timeout / unreachable / ok_no_models failures. detail is
core.scrub-ed; the key is never logged or echoed.
- Engine reads persisted config fresh per transcribe (regression test) —
config changes need no backend restart. Never default-active: ASR
auto-detect only picks local engines.
- i18n for every new string (en.json); no hardcoded CJK; identical
behavior on macOS/Windows/Linux (pure HTTP + React).
- Docs-sync: docs/engines/openai-compatible-asr.md rewritten around the
one-screen flow with LM Studio / llama.cpp / Groq / OpenAI examples
and the privacy note; README engine table cell updated.
Verified end-to-end against a fake OpenAI-compatible server: UI drive
(configure → test → row flip → Use) plus a real transcription through
the backend's /v1/audio/transcriptions immediately after a config
change, no restart.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
chore(backend): delete dead services/batched_tts.py + guard against orphaned service modules (#1086)
services/batched_tts.py (added 2cd1ab4f, April 2026) was an unintegrated
throughput experiment with zero call sites anywhere in the repo — no router,
service, test, spec, or frontend reference. Its headline "CPU/GPU pipelining"
pre-loaded a ref-audio tensor it then never used (model.generate received the
file path), it drove the raw OmniVoice model bypassing the engine-aware
backend (resolve_generation_backend / applies_own_mastering, #311/#312), and
it accumulated all segment tensors in memory — the RAM-spike class the live
dub path fixed by streaming to disk (#639/#709). Everything it aimed at is
superseded in place by the live routes (batch.py, dub_generate.py,
model_manager's guarded GPU pool).
Recurrence guard: tests/test_no_orphan_service_modules.py fails if any
backend/services module has no referencer in backend/, tests/, or scripts/
(fail-before/pass-after verified: flagged exactly ['batched_tts'] before the
deletion, green after). An _INTENTIONALLY_UNREFERENCED allowlist with
mandatory justification covers legitimate exceptions and self-cleans on
staleness. batched_tts was the only orphan in the services layer.
Also fixes the stale batched_tts.py mention in the .planning architecture
diagram (swapped for the live chunked_tts.py).
Full backend suite green after deletion: 2877 passed, 20 skipped, 10 xfailed.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
docs: migration guide for stranded Real-Time-Voice-Cloning users + sharper local-first promise (#1085)
RTVC (CorentinJ's 50k-star SV2TTS repo) is archived; its users need a
maintained home. New docs/migration/real-time-voice-cloning.md maps
every RTVC concept to its OmniVoice equivalent (encoder+utterance →
reference clip, toolbox → app, vocoder choice → Settings → Engines,
demo_cli.py → REST API/CLI/MCP), is honest about what RTVC did that we
don't (research toolbox, three-stage training, MIT license, smaller
footprint), and walks the first clone with verified UI labels only.
Wired into docs/features.yaml's existence-checked docs list and linked
from the README Quickstart.
README tagline now states the local-first promise verbatim at the very
top: "No accounts. No API keys. No cloud." — everything else on the
front page is unchanged.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
release: freeze v0.3.18 — version bump, lockfiles, changelog (#1084)
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
feat(engines): one-click IndexTTS-2 sidecar install from Settings → Engines (#1083)
* feat(engines): one-click IndexTTS-2 sidecar install from Settings → Engines
IndexTTS-2 required four manual terminal steps (git clone, uv venv,
uv pip install -e ., export OMNIVOICE_INDEXTTS_DIR). This turns that into
a guided in-app install:
- backend/services/sidecar_install.py — parametrized sidecar provisioner
(SidecarSpec/SPECS so future sidecar engines are one entry, not another
installer). Resumable background job with step-by-step status: disk-space
preflight (needs-X/have-Y message), source fetch (git clone --depth 1
primary, GitHub tarball fallback when git is absent/fails), dedicated
venv via uv (OMNIVOICE_BUNDLED_UV → PATH resolution; transformers<5
isolation preserved — the parent env is never touched), import-probe
verification, IndexTeam/IndexTTS-2 weights into <checkout>/checkpoints
(where the sidecar actually loads from) via snapshot_download with the
auto-selected/configured HF endpoint + token — no hardcoded
huggingface.co — and persistence of OMNIVOICE_INDEXTTS_DIR (os.environ
for immediate use, prefs.json env.* for the next launch). Idempotent:
partial installs repair, downloads resume, healthy installs (incl. a
user's own clone) report already_installed and are never touched.
- API: POST /engines/{id}/install starts the job, GET
/engines/{id}/install/status polls it, DELETE /engines/{id}/install
removes an app-managed install (loopback-gated; refuses user-managed
clones). list_backends() gains one_click_install.
- Frontend: Settings → Engines shows an Install button on the IndexTTS2
row with per-step progress, live log tail, weight-download %, and
error+remediation; the manual setup snippet is demoted to a collapsed
"Manual install" fallback. All strings via i18n (en.json).
- OMNIVOICE_INDEXTTS_DIR joins the Settings env-var allowlist
(single-sourced from the installer SPECS).
- Docs: docs/engines/indextts.md leads with the one-click flow; manual
steps become the fallback section. CHANGELOG Unreleased entry added.
- Tests: tests/test_sidecar_install.py (24 cases — happy path, disk-space
fail, git-absent/git-failing tarball fallback, partial-install repair,
already-installed/running gating, uninstall safety, spec↔bootstrap
contract, router wiring) + 6 new EngineCompatibilityMatrix RTL cases.
API route snapshot regenerated.
* fix(engines): harden the sidecar installer — review findings
- Route namespace: /engines/sidecar/{id}/install — a dynamic
/engines/{id}/install would shadow the literal
POST /engines/sonitranslate/install (engines router registers first);
regression-guarded by test_sidecar_routes_never_shadow_literal_engine_routes.
- Weights completion marker: a killed-mid-download multi-shard weights dir
(config.yaml + plausible shards) no longer passes for healthy; the marker
is written only after snapshot_download returns, so re-runs resume.
- _run_logged: drain thread + proc.wait(timeout) + POSIX process-group kill
— a grandchild holding the stdout pipe can no longer hang the step past
its timeout.
- Job log lock: the status poll's list(deque) copy no longer races the
worker's appends (RuntimeError under active logging).
- Self-heal: a healthy managed install whose env var was lost (prefs wiped)
is re-pointed by start_install instead of reported already_installed
while the engine stays unavailable; legacy bootstrap installs (Probe-2
venv) are trusted via the engine's own probe.
- Single-sourced uv/venv-layout resolution: engines.indextts.bootstrap now
delegates _locate_uv/_venv_python_path to services.sidecar_install.
- Frontend: stable poll interval (keyed on the running-id set, not the
status map), reload on a job that finishes before the first poll,
re-attach to an in-flight job on remount, i18n'd Install aria-label,
manual-install <details> auto-opens on failure, snippet block hoisted
out of the JSX IIFE.
- list_backends: sidecar-installable set hoisted out of the per-engine
loop; exhaustive-shape registry test updated for one_click_install.
- Tests rebind the live services.sidecar_install module per test (other
suites purge sys.modules["services"], which made router tests
order-dependent).
* docs(changelog): fill in the PR ref (#1083)
* fix(security): validated tarball fallback + scanner-clean installer
- The pre-filter= extractall fallback (Python < 3.11.4) now extracts
member-by-member behind the same guards extractall(filter="data")
enforces — regular files/dirs only, no absolute paths, no ../ escapes,
resolved-path containment. Kills the new CodeQL py/tarslip (high) and
Bandit B202 (error) alerts; regression-tested with a malicious tarball
(test_safe_extract_members_blocks_tar_slip).
- snapshot_download tracks the weights repo's default branch on purpose
(same policy as every other model download; artifacts are
checksum-verified by hf_hub) — documented + B615 waived at the call.
- Explanatory comments on the intentional empty-except blocks
(CodeQL py/empty-except notes).
Verified locally: bandit -ll -ii on the module reports 0 MEDIUM+ findings.
* fix(engines): address Greptile review — Windows tree kill, prefs write race, poll robustness
- _kill_tree: Windows now uses taskkill /F /T so a git/uv helper spawned by
the timed-out child can't keep writing into the checkout (POSIX already
killed the process group). Unit-tested with os.name patched to nt.
- core/prefs: mutations (set_/delete) are serialized behind a module lock —
the installer worker persisting its env.* key concurrently with a Settings
write could previously drop whichever key saved first (whole-class fix:
every threaded prefs writer, not just the installer). Fail-before/
pass-after: tests/test_prefs_thread_safety.py.
- Matrix polling: at most one in-flight status request per engine (an old
'running' response can no longer land after a newer 'succeeded' and
restart the poller), and four consecutive poll failures drop the stale
snapshot instead of showing "Installing…" and hammering a dead backend
forever.
---------
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
feat(network): automatic Hugging Face endpoint selection — probe, pick, remember (#1082)
* feat(network): automatic Hugging Face endpoint selection — probe, pick, remember
Restricted-network first-runs (the #984 class: huggingface.co unreachable,
user dead-ends before discovering the mirror setting) now self-heal by
default, while explicit endpoint choices are never second-guessed.
- New backend/services/endpoint_race.py: parallel HTTPS reachability +
latency probes of huggingface.co and the hf-mirror.com community mirror
(3s timeouts). Probes are the only signal — no geo-IP, no third-party
calls. Reachable beats unreachable; with both reachable the official
endpoint wins unless the mirror is decisively faster (anti-flap
hysteresis). The pick is cached in prefs and re-raced only on first run,
a network-classified download failure, staleness (>7 days), or an
explicit "Test again".
- Manual mode is sacred: HF_ENDPOINT env, an hf_endpoint pref, or any
explicit Settings pick disables auto-switching entirely;
OMNIVOICE_HF_ENDPOINT_MODE=manual is a hard opt-out.
- Wiring: the wizard preflight races endpoints when nothing is configured
(honest copy when the mirror wins; warn-not-block when nothing is
reachable); Model Store installs and the model-cache auto-repair resolve
their per-call endpoint= through the cached decision, and a
network-classified failure re-races once per repo per process and
retries on the new winner (same guard pattern as the cache-recovery
ladder).
- Settings → Models → Hugging Face mirror gains "Auto (recommended)":
shows the current pick, measured latency, last-checked time, and a
"Test again" button (POST /api/settings/hf-mirror/test). Existing
explicit configs surface as the matching manual mode. Panel notes that
hf_hub checksums every download regardless of endpoint.
- Tests: policy/cache/failover matrices in tests/test_endpoint_race.py,
preflight + settings + repair-failover integration with mocked probers,
HFMirrorPanel mode tests, and a suite-wide conftest guard that pins the
probers so no test can hit the real network.
- Docs: downloading-models.md and install/troubleshooting.md describe the
automatic default and both opt-outs.
* docs(changelog): Unreleased entry for automatic HF endpoint selection
* fix(tests): endpoint-probe pin uses an isolated MonkeyPatch and clears the decision cache; dtype guard tolerates stubbed torch
The autouse probe pin requested the shared monkeypatch fixture, hoisting
its setup earlier for every test and reordering teardown against the fp16
guard — which then ran torch.get_default_dtype() on test_torch_compile_gate's
SimpleNamespace stub. The pin now uses its own MonkeyPatch context and also
clears the prefs-cached endpoint decision per test (one test's auto pick
leaked into other tests' preflight labels on CI ordering). The dtype guard
additionally skips non-module torch stubs outright.
* fix(tests): endpoint env vars can no longer leak out of the mirror-settings suite
set_hf_mirror writes os.environ[HF_ENDPOINT] during the test, and
monkeypatch.delenv(raising=False) on an absent var records nothing to
undo — so the write leaked process-wide and flipped later suites'
preflight network checks into the explicit-endpoint branch (the CI-order
failures). Guaranteed save/restore autouse fixture at the source, plus
defensive env shedding in the preflight suite.
---------
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
test: pay down export-router test debt + kill two test-order pollution classes (#1081)
Four pieces of test debt, root-caused and hardened:
1. exports.py test coverage (was: zero dedicated tests): new
tests/test_exports_api.py (26 tests) covering /export, /export/record,
/export/history, /export/reveal — happy paths, traversal/containment
guards (incl. symlink escape), destination validation, error mapping,
and the mp4 watermark-overlay branch with its plain-copy fallback.
Two real bugs found and fixed in the router:
- _safe_destination checked isabs() on realpath()'s output, which is
always absolute — dead check; a relative destination silently exported
to a cwd-dependent location instead of the documented 400.
- _safe_source let "." / ".." through the basename guard (caught only
later by realpath containment as a confusing 404); now 400 up front.
2. CI-Linux fp16 default-dtype leak (test_prefers_vocals_over_mix,
test_final_dub_track_and_seg_wav_are_watermarked): not reproducible on
macOS — instrumenting torch.set_default_dtype across both tests records
zero non-fp32 sets locally. Both tests now carry an opt-in
torch_dtype_isolation fixture (save/restore, so the leak can never
spread), and the conftest guard is demoted to pure insurance. A cheap
permanent recorder wraps torch.set_default_dtype /
set_default_tensor_type once torch appears and captures the setter's
stack only on a non-fp32 set; both fixtures print that stack when they
fire, so the next CI occurrence names the exact culprit call chain.
3. Test-order pollution (both reported combos): root cause was
collection-time sys.modules stubbing in backend/tests — seven modules
installed bare ModuleType stubs for core.config (and test_capture_ws.py
for services.model_manager/asr_backend/ffmpeg_utils, now all lazily
imported by the router anyway). pytest imports test modules during
collection, so the stubs leaked process-wide before any test ran:
- combo (a): monkeypatch.setattr("core.config.OUTPUTS_DIR", ...) in
test_longform_e2e died with AttributeError (core never gets a .config
attribute when the import is satisfied straight from sys.modules).
- combo (b): test_router_smoke's `from main import app` died with
ImportError: cannot import name 'find_ffmpeg' (unknown location).
Fix at source: new backend/tests/conftest.py sets a hermetic
OMNIVOICE_DATA_DIR (mirroring tests/conftest.py, #878) and the real
core.config is imported everywhere — zero sys.modules surgery. New
backend/tests/test_no_module_stubs.py guards the whole class (verified
fail-before/pass-after against the old stub). Stale rationale comments
in pyproject.toml and ci.yml updated to match.
4. batched_tts.py TODO(#312): investigated, comment corrected only —
#312 is closed (the live routes are engine-aware); this module has zero
call sites and stays an unintegrated experiment. See PR notes.
Full tests/ suite: 2796 passed. backend/tests standalone: 130 passed.
Both pollution combos re-run green in the reported orderings.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
fix(docker): ship alembic.ini in the image, add an image-level HEALTHCHECK (#1080)
Migrations in Docker fell back to the additive-column self-heal because
alembic.ini was never copied; the real migration chain now runs. The
HEALTHCHECK covers plain docker-run (compose files keep their own), with
a start period sized for first-boot schema creation. Docs example tag
freshened.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
release: freeze v0.3.17 — version bump, lockfiles, changelog (#1079)
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
fix(scripts): desktop-fresh kill guard referenced the wrong dry-run flag (#1078)
The kill-before-wipe block used DRY_RUN; the script's flag is dryRun —
any run with a live instance crashed with ReferenceError before wiping.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
fix(shell): deep health probe before attaching to a running backend; scripts kill before wiping (#1077)
A backend that keeps running while its install is deleted or replaced
underneath it still answers /health and /system/info from memory — the
launcher's version check passed and the UI attached to a process that
500s every DB-touching route (raw errors without CORS headers, so the
webview reports access-control failures). The attach path now requires a
DB-touching probe (/profiles) to return an actual 200 status line, and
replaces the squatter otherwise — the status line is parsed explicitly
because the raw HTTP helper previously returned 500 bodies as Ok.
desktop-prod/desktop-fresh now terminate our own running processes
(bundle, dev binary, app-scoped port-3900 listener) before wiping, which
is how the zombie was produced.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
docs(changelog): unreleased entry for #1074 (#1075)
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
fix(ui): default UI scale is 100% — native size out of the box (#1074)
New installs rendered at 130% zoom, which read as oversized on typical
displays. Fresh sessions now start at 100%; anyone who already picked a
scale keeps it (uiScale is persisted and wins over the default).
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
docs(changelog): unreleased entries for #1071 and #1072 (#1073)
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
feat(settings): Engines & Models go tabbed and compact — strict two-line rows, aligned columns (#1072)
Settings → Engines collapses from three stacked full matrices (where one
engine row could sprawl to 5+ lines and fill a viewport) into ONE section
with a TTS / ASR / LLM tab strip (the matrix's Radix Segmented — roving
tabindex + arrow keys, active engine named in each tab caption). The single
mounted matrix still issues exactly one GET /engines + one GET /model/loaded
per Settings open; switching tabs re-slices the fetched payload with no
refetch. openSettingsTab('engines') deep-linking is unchanged (nothing in
the app targets a specific family — audited).
Every engine row is now strictly two lines inside a fixed h-16 shell:
line 1 = EngineMark + truncated display name (full name via title, never
wraps) + active/in-memory badges; line 2 = engine id, cloning chip,
curated-model picker, one-line truncated hints (full text via title).
Header and rows share one grid template
(minmax(0,1fr) 108px 176px 92px 232px) so the STATUS / GPU COMPAT /
ISOLATION / ACTIONS columns align on every row; actions sit right-aligned
and vertically centered across both lines. Below 880px the three meta cells
re-place onto the row's second line (same DOM nodes) instead of forcing a
horizontal scroll. Unavailable-row details (reason, install hint, last
error, setup snippet) move out of the row into an aria-expanded expansion
panel that opens BELOW it, so sibling rows never lose alignment. The
previously hardcoded column labels are i18n'd (engines.col*).
Settings → Models already has its natural grouping as the role filter
strip (All/TTS/ASR/… with counts) + search over one list, so no tabs were
invented there; its rows get the same compactness treatment — 5px vertical
padding + 52px min-height two-line rows (virtualizer estimate updated),
with title attributes carrying the full label/repo text past the ellipsis.
Tests: EnginesTab suite rewritten for the tabbed layout (tab strip renders,
switching families doesn't refetch, single /engines + /model/loaded probes,
Use-on-ASR flows through the tab); matrix suite updated for the expansion
panel (reason/last-error/setup-snippet live behind the Why-unavailable
toggle) and extended with layout regressions: fixed-height two-line shell,
name truncation + title, header/rows sharing identical grid tracks,
panel open/close as a sibling below the row. 1139 frontend tests pass;
typecheck:ci, oxlint, oxfmt and vite build are clean.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
feat(setup): media tools become invisible — bundled by default, controllable in Settings (#1071)
Most users should never learn what ffmpeg is. The Setup Wizard's SYSTEM
PREFLIGHT stops listing FFmpeg / FFprobe / yt-dlp as user-installed
requirements ("brew install ffmpeg…"): they are internal dependencies the
app provisions for itself. Genuine user facts (OS, RAM, disk, GPU,
network, Python) are untouched.
Backend
- New services/media_tools.py: per-tool status {version, path, origin:
sidecar|bundled|system|custom}; background acquisition of a pinned,
SHA-256-verified static ffmpeg+ffprobe build (immutable-commit fetch
from the same upstream the static-ffmpeg pip package uses — that
package itself was audited and rejected: mutable raw/main URL, no
checksums, writes into site-packages); binaries are `-version`-probed
via the existing _binary_runs before being trusted, installed under
DATA_DIR (update-surviving, frozen-build-safe), zero new Python deps.
- ffmpeg_utils resolution chain gains the acquired-bundled tier — and
ffprobe finally has a bundled tier at all (imageio-ffmpeg ships none),
closing the source-install gap.
- New /media-tools router (loopback-gated, same contract as
/system/set-env): status, acquire, {tool}/custom-path | use-system |
restore, ytdlp/update | restore. Overrides persist via the existing
env.FFMPEG_PATH / env.FFPROBE_PATH prefs convention — one store, no
competing controls.
- yt-dlp updates: audited in-venv pip/uv upgrade and rejected (venv is
uv-managed with no pip; yt-dlp is a locked dep, so the updater's
--inexact drift sync would revert it). Instead the newest wheel —
verified against PyPI's own sha256 — lands in a DATA_DIR overlay
prepended to sys.path at startup: survives app updates, works in
frozen builds, and "Restore tested version" is just deleting the
overlay. Gallery now runs yt-dlp via `python -m yt_dlp` (module, not
PATH) so the CLI can never be a user-install task either.
- /setup/preflight drops the three tool rows, carries a media_tools
verdict, and self-heals: kicks the bundled download in the background
when no tier resolves (never re-fires after a failure — the wizard's
card owns Retry). diagnose + the ffmpeg-missing notification now point
at Settings → Audio tools instead of package managers.
Frontend
- Wizard: new MediaEngineCard — renders NOTHING when the engine is ready,
a one-line progress while acquiring, and only on failure an actionable
card (Retry / Use a system copy / Choose file…).
- Settings → Audio tools (new category, System group): FFmpeg + FFprobe
rows with version, path, origin badge, Use system copy / Choose file… /
Restore bundled, header-level "Update bundled build"; yt-dlp row with
Update + Restore tested version (+ restart affordance). Package-manager
commands appear only as copyable prose, never executed.
- The FFmpeg-path override moved out of Settings → Network (pointer row
deep-links to Audio tools; no second writer of env.FFMPEG_PATH).
Notifications gain a settings-tab action type.
- All strings i18n (en + defaultValue), a11y labels on every control.
Tests: 29 new backend (origin classification, checksum/size/probe
rejection, override persistence, overlay update/restore, router gating +
route-shadowing) + preflight contract tests (tool rows gone, verdict
present, auto-acquire fires once); 14 new frontend (wizard hide/progress/
failure-card, Audio tools rows/badges/actions). Route snapshot
regenerated. Docs (macos/linux install, troubleshooting §7b) describe the
new reality in the same commit.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
fix(scripts): desktop-prod builds clean, desktop-fresh emulates a brand-new machine (#1070)
desktop-prod fixes:
- `tauri build --debug` used to produce every bundle and THEN exit 1 at the
updater-artifact signing step (no TAURI_SIGNING_PRIVATE_KEY on dev
machines); the script papered over it with a blanket "non-fatal bundle
error" grep that also swallowed real bundling failures. Local emulation
builds now pass `--config '{"bundle":{"createUpdaterArtifacts":false}}'`
and only build the bundle the script launches (--bundles app / appimage,
--no-bundle on Windows), so the build exits 0. Any nonzero exit now FAILS
the script — the sole tolerated case is a specifically-detected
linuxdeploy/FUSE failure on Linux when the raw debug binary was produced.
- The HF cache wipe ran `rm -rf ~/.cache/huggingface` on macOS/Linux — the
SHARED global cache (backend/core/config.py only relocates it on Windows),
deleting models unrelated to OmniVoice. Non-app-scoped cache paths are now
kept with a "models will be reused" notice; FRESH_NUKE_HF=1 opts in.
- Honest clean marks (removed ✓ / already-clean ○ instead of ✗ for success),
`open -n` always (plain `open` focused a stale running instance instead of
launching the freshly built one), stale-AppImage removal on Linux.
New `bun desktop-fresh` (+ desktop-fresh:run), macOS-only with explicit
refusal elsewhere: true new-user emulation.
- Blank slate: everything desktop-prod cleans PLUS the traces that survive a
reinstall + data wipe — ~/Library/WebKit (webview localStorage), Caches,
HTTPStorages*, Preferences plist (+ defaults delete), Saved Application
State. Per-path found/removed/absent status with sizes; --dry-run prints
the full plan without touching anything.
- Dev-machine camouflage: launches by direct exec of the bundle's Mach-O
(which inherits env — `open` hands off to launchd and drops it) with PATH
stripped of /opt/homebrew/{bin,sbin} + /usr/local/bin and HF_TOKEN /
HUGGING_FACE_HUB_TOKEN / HF_HOME / HF_HUB_CACHE / HF_ENDPOINT /
OMNIVOICE_* unset, and prints a banner of what is hidden.
Shared pure helpers live in scripts/desktop-common.mjs, covered by 9 node
tests (tests/frontend/desktopScripts.test.mjs): every cleanable path is
app-scoped and under $HOME, the PATH/env sanitizers strip exactly the
intended entries, and the build args carry the updater-artifacts-off config.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
docs(changelog): unreleased entry for #1067 (#1068)
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
fix(dub): interrupted dub sessions no longer trap the app in an eternal spinner on relaunch (#1067)
The omni_ui session persisted dubStep verbatim, including in-flight values
(uploading/transcribing/generating/stopping). Quitting or crashing mid-dub
froze that step into localStorage, and every relaunch restored a wait on
work that died with the process — a blank Dub pane with an eternal spinner
that even reinstalling couldn't clear (the webview's localStorage survives).
Restores now clamp to settled states: editing when the session has segments,
idle otherwise; unknown/corrupt values are treated as transient.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
docs(changelog): unreleased entries for #1058-#1064 (#1065)
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
fix(settings): make provider/token panels honest — real Test now, gated probes, MCP bindings i18n + confirm (#1064)
HuggingFace token (ApiKeysPanel):
- "Test now" actually re-runs whoami: GET /api/settings/hf-token/state gains
?fresh=1 which drops the resolver's 300s validation cache (the invalidate
hook existed but was never wired to any endpoint), so a fixed network or
rotated token no longer shows a stale verdict for up to 5 minutes. Plain
panel mounts keep the cache.
- Initial load renders a "Checking token sources…" placeholder instead of
flashing a false amber "not set" for all three sources.
- Source rows are now a valid ARIA list (the old role="table" had rows with
no cells, hiding the status from screen readers).
- Enter in the token input respects the in-flight guard the Save button
already had (no duplicate POSTs).
LLM Providers:
- Test / Fetch models abort when the implicit save fails, instead of probing
the previously-stored config and pairing a green "Test ok" badge with a
save error.
- A failed initial load now offers a Retry button instead of dead-ending
until the panel remounts.
LLM Skills: the per-skill provider Select carries an accessible name
("Provider for <skill>") instead of announcing as an unlabeled combobox.
MCP voice bindings:
- All user-facing strings go through i18n (the panel was the only Settings
surface with hardcoded English throughout).
- First-run guidance moved out of per-row hints (which never rendered with
zero bindings and duplicated per row) into the section header + an
InfoHint that links to docs/mcp.md; an empty state invites the first add.
- Delete asks for confirmation via the shared askConfirm, disables the row's
button while in flight, and re-syncs the list even when the DELETE fails
(a 404 row no longer lingers on screen).
- The add row exposes the optional label the API already accepted (the row
title rendered b.label without any way to set it); default_engine stays
MCP-side-only and is documented as such.
- First component test file for the panel (load/empty/add/delete/error/a11y).
Tests: backend fail-before/pass-after for the fresh=1 cache bust; new
frontend coverage for every behavioral change above.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
fix(settings): network & privacy panels — clearable proxy after reload, HF-mirror panel never vanishes, guarded remote-backend save, honest privacy claims (#1063)
Settings → Network / Models / Sharing / Privacy / OpenAPI fixes:
- NetworkTab: a proxy persisted in a previous session can now be cleared —
the Clear button and "Set" badge derive from the backend-persisted value
(sysInfo.proxy_url), not only from a save in the current session. Proxy row
copy now matches its real semantics ("Applies now" badge; desc/toast no
longer claim a restart is needed or leak yt-dlp jargon — reworded in all
21 locales). FFmpeg path placeholder is platform-appropriate instead of
Windows-only on every OS.
- HFMirrorPanel: the panel no longer disappears when the initial GET fails —
the section shell always renders, with a loading state and an error +
Retry affordance. Saving now toasts, the active preset is marked
(aria-pressed), and the custom-URL row is labelled "Custom mirror URL"
instead of raw HF_ENDPOINT jargon (env var moved to the row note).
- RemoteBackendPanel: full i18n (was 100% hardcoded English); Save & reload
now validates the URL (http/https, parseable) and asks for confirmation
before saving a URL that hasn't passed a connection test — a typo'd base
no longer bricks every API call after reload. Dropped the contradictory
"Restart required" badge (saving reloads the app itself; description says
so). docs/remote-gpu.md updated to match (docs-sync).
- PrivacyTab: the "Network calls" row no longer shows the green "Offline
translator" assurance when the backend is down or reports 'unknown' —
green is reserved for confirmed-offline providers (nllb/argos/
libretranslate), everything unconfirmed shows a neutral "Unknown" badge.
The online-translator warning now deep-links to Translation settings.
- OpenApiPanel: a failed clipboard copy toasts an error instead of silence.
- a11y: all five text inputs across these panels now carry accessible names
(aria-label), previously announced only by their vanishing placeholders.
Tests: new colocated suites for NetworkTab, HFMirrorPanel,
RemoteBackendPanel, PrivacyTab; OpenApiPanel suite extended with copy
success/failure. Frontend suite 140 files / 1061 tests green; i18n parity
probes green (new keys en-only with defaultValue, reworded keys updated in
every locale).
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
fix(settings): factory reset covers all prefs, guarded log clearing, temp reclaim, full Performance i18n (#1061)
Settings system-group cleanup — every fix keeps existing behavior contracts
and adds a fail-before/pass-after regression test:
- Factory reset now does what it promises: clears every locally-persisted
preference via a single registry (utils/prefKeys.js) instead of only the
zustand blob — nav-rail side, capture live-typing, stories speed, logs
footer state, last settings category, dismissed tips, donate prompts, and
the legacy omni_ui blob included. User data and connection state
(omni_transcriptions, ov_backend_url, ov_api_key) are explicitly preserved,
and prefKeys.test.js scans the source tree so any future localStorage key
must be categorized or CI fails. The failure toast now carries the actual
error message.
- Disk-usage "Clear logs" is confirm-gated with the same wording as
Settings → Logs — it truncates the crash log (the bug-report artifact), so
it can no longer be a single stray click.
- Temporary files got a reclaim action: a confirmed "Clear temp files"
button backed by POST /api/settings/storage/temp/clear, which deletes only
the omnivoice* entries in the OS temp dir (symlinks unlinked, never
followed) and invalidates the cached report.
- Performance panel goes through i18n end to end (title, row, note, hint,
errors, aria-label) — it was the last fully hardcoded panel; the
non-Windows subtitle now reads "Windows only — not needed on this
platform" instead of "not applicable".
- History retention: GET failures now surface an alert and hold Save until
a load succeeds (404 from older backends stays silent), Enter saves, the
dead !res.ok branch is gone, and the bespoke button is the shared Button.
- Logs tab: "Open folder" reveals the log file, "Copy visible log" copies
the tail, the viewer autoscrolls to the newest lines, and the scroll box
is keyboard-focusable (role=log) with a labelled source switcher.
- Storage paths: the app-data row is labelled "App data stored at" (it was
borrowing the Privacy tab's "Uploads stored at"), and all three path rows
gained Open folder.
- i18n stragglers routed through t(): storage load/open/clear fallbacks,
the backend-status badge, and the frontend log buffer label.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
fix(settings): dictation & pronunciation panels — error states, language-scoped preview, i18n, a11y, dictionary import/export (#1060)
Settings → Dictation / Pronunciation / remote-ASR panel fixes:
- RefinementPanel no longer vanishes when the initial GET fails (backend
down/restarting): the section shell always renders, with the error, a
Retry button, and a loading line — matching its sibling panels.
- RefinementPanel offers the "Open LLM Providers" deep-link as soon as
llm_ready is false, not only after the first refinement failure.
- Pronunciation test preview gains a preview-language selector and sends
it to POST /pronunciation/test, so language-scoped entries finally show
up in the preview instead of a misleading "No entries match"; a hint
explains that Global previews skip language-scoped entries.
- Preview requests are debounced and sequence-guarded (a slow stale
response can never overwrite a newer one), failures surface as a
"preview unavailable" note instead of silently blanking, and the
preview re-runs after add/toggle/delete/import so it never goes stale.
- Dictionary backup & restore: Export JSON / Import JSON buttons wired to
the existing GET /pronunciation/export and POST /pronunciation/import
endpoints (import prompts replace-vs-merge when entries exist).
- a11y: each entry's enable switch is named after its term ("Enable
GIF"), all add-form/test inputs and selects carry aria-labels, and the
cramped language field gets a short placeholder with the long
explanation moved to the row hint.
- RefinementPanel + AecPanel converted to i18n (`dictation.*` keys; the
refine-failure helper now returns a key instead of hardcoded English),
per the all-UI-strings-through-i18n convention; "experimental" is now
sentence-cased via its key.
- Copy: empty state says "Add one below" (the form is below the list, in
every locale) and the test row is titled "Test a sentence" instead of
its ellipsized placeholder.
- AsrOpenAICompatPanel: Save is disabled until a field actually differs
from the server values and shows a "Saved" confirmation after saving —
URL/model-only edits are no longer silently ambiguous.
- Enter submits the pronunciation add form; Add is disabled while the
term is blank.
New pronunciation locale keys are translated in all 21 locales (keeping
that namespace fully covered); the new `dictation.*` namespace is en-only
with fallback, matching the `models.asrOpenAICompat*` precedent. No
backend changes — /pronunciation/test already accepted `language`.
Tests: RefinementPanel + AsrOpenAICompatPanel component tests added,
PronunciationPanel tests extended (language-scoped preview, stale-response
guard, preview error, re-run after add, Enter-to-add, per-entry switch
names, export/import round-trip), refine-note test updated for i18n keys.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
fix(settings): correct About links, wire RTL, and close core Settings UX gaps (#1059)
- About linked the wrong project: "OmniVoice on GitHub" and "Model card"
opened k2-fsa/OmniVoice. The GitHub button now derives from a single
REPO_URL constant in utils/bugReport.js (issue/search URLs derive from it
too), and the Model card button is gone — a multi-engine app has no single
model card. A test pins the button to the constant so links can't drift.
- Picking Arabic now actually flips the UI to RTL: the languageChanged
handler sets document dir + lang from i18n.dir(), covering any future RTL
locale as well.
- A Settings search that matches nothing now shows "No settings match" with
a Clear action instead of a silently blank sidebar (and an option-less
nav <select> in the narrow layout).
- Hotkey recording no longer swallows modifier-less presses in silence — it
shows inline "add a modifier" feedback, the Record button becomes a Cancel
toggle while listening, window blur cancels the global key listener, and
the row copy states the modifier requirement.
- About no longer dead-ends on fixable problems: "HF token set: no" and
failing self-checks deep-link into the owning Settings category via
openSettingsTab.
- Sidebar search now also matches translated setting-row titles
(keywordKeys), so localized users can find categories by localized names;
English keywords keep working everywhere.
- Network gets its missing restart flag (the FFmpeg-path row is a
restart-bound env write); a lockstep test keeps RestartBadge usage and
category flags in sync.
- The theme-dot and font-tile radiogroups implement the real WAI-ARIA radio
pattern: roving tabindex plus arrow-key movement with focus following
selection.
- Copy cleanup: sentence-case "UI scale" / "Commercial license"; the review
segmented control moves off the orphaned engines.review_* keys to
settings.review_mode_on/off (renamed across all 21 locales) with plainer
English labels.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
feat(settings): Models & Engines pages — engine identity marks, capability badges, upgrade hints, filter, residency (#1058)
The engine list gains a scannable identity mark per engine (EngineMark),
capability badges (cloning, device routing with reasons, sidecar isolation),
and surfaces available-but-has-advice hints that list_backends previously
dropped (new additive hint field; the ready-with-advice convention). The
model store gains a filter, disk context near downloads, in-memory residency
indicators with safe unload, copyable setup snippets, and actionable
empty/error states. Registry additions are additive only (hint,
supports_cloning with the property-descriptor guard).
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
release: freeze v0.3.16 — version bump, lockfiles, changelog (#1057)
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
fix(models): self-heal HF-cache snapshots with broken file links, retry load once (#1056)
A first-run breaker: all blobs download fine, but the snapshots/<rev>/
entries are dangling symlinks (0 KB) — os.path.isfile() is False on a
dangling link, so transformers reports the weights missing even though
the bytes are on disk, and the existing resume repair can't fix it.
New services/hf_cache_repair.py deletes exactly the broken snapshot
entries (dangling symlinks + zero-byte weight/config stand-ins; never
blobs, never resolving entries) and restores them via snapshot_download,
verifying afterwards — if the restore recreates broken links (hub's
memoized symlink probe passing while real links come out broken), it
forces hub into copy-mode and repairs once more with real files.
model_manager retries the load exactly once per repo per process
(rung 0 of the cache-recovery ladder); dead-end errors now name the
exact models--<org>--<name> folder to delete. failure.py classifies the
class as MODEL_CACHE_CORRUPT so the user-facing error and auto bug
report explain the automatic repair.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
fix(tts): voxcpm2 — version floor, reference-clip prep, trailing-silence guard (#1055)
Three hardenings of the voxcpm2 engine path, all backward-compatible and
platform-identical:
- Version floor: every install hint now says pip install "voxcpm>=2.0.3"
(2.0.3 fixed an Apple-Silicon/MPS audio-quality bug). Floor only — an
already-installed older version stays available and working; it just
surfaces an actionable upgrade hint in the is_available reason and a
load-time warning.
- Reference-clip prep: the voxcpm package no longer trims reference audio
itself, so raw user clips reached the model unconditioned. The clone path
now trims leading/trailing near-silence (-50 dBFS floor, 50 ms edge pad)
and caps the reference at 30 s. Fail-open (any prep problem falls back to
the raw clip) and a strict no-op for short clean clips.
- Trailing-silence guard: generated output is trimmed to the last voiced
sample + ~0.3 s natural tail via the new audio_dsp.trim_trailing_silence.
Silence-trim only, no content analysis; a no-op on outputs without a
silent tail and on all-silent (dead) renders.
22 new fake-module tests in tests/test_voxcpm2_guardrails.py; existing
engine/hint tests strengthened to guard the floor.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
feat(tts): normalization covers the OpenAI-compat API, streaming, and batch paths (#1054)
The engine-agnostic text-normalization pre-pass now runs at the three
remaining text→engine choke points, applied exactly once per request:
/v1/audio/speech (req.language), /ws/tts (whole text, before the sentence
chunker fans it out), and the batch queue's per-segment _gen (target
language) — matching the /generate, dub, and audiobook wiring. Route-level
tests pin exactly-once (spy) + toggle-off-raw for each path.
Also fixes a pre-existing /ws/tts bug the new test exposed: any request
omitting emo_alpha hit a KeyError and got an error frame instead of audio.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
docs(changelog): unreleased entries for #1048-#1052 (#1053)
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
feat(studio): generation takes — star, replay, and restore past takes; capped history retention (#1052)
Every generate already recorded a generation_history row; now that history is
usable: a takes rail in the workspace history lists recent takes with star/
unstar, replay, and one-click restore as the active output. Alembic migration
0009 adds the starred column (the startup schema self-heal covers pre-
migration DBs), a retention cap (setting, default 200) prunes the oldest
UNstarred rows — starred takes are never pruned — and history WAVs are only
deleted when no other row references them.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
feat(dub): predict segment fit before synthesis — tight/impossible badges + opt-in shorter rewrites (#1051)
New pure planning layer (services/duration_planner.py) runs after translation,
before TTS: estimates each translated line's natural speech duration (self-
calibrating from the job's already-synthesized segments, static per-language
rates as cold-start fallback) and classifies it fits/tight/impossible against
slot + capped gap borrow, with thresholds derived from fit_planner's own caps
so "impossible" means "would be trimmed". Verdicts ride the /dub/translate
response and badge the segment table; an opt-in (default OFF) LLM pass attaches
one-click shorter-rewrite suggestions for impossible lines. Never blocks
generation — informs before GPU time is burned.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
feat(dub): LLM translations keep terms consistent and sound spoken — auto-glossary brief + reflect pass (#1050)
One up-front LLM pass over the full transcript extracts a theme summary +
terminology map, merges it under the user's manual glossary (user entries
always win), caches it on the dub job per target language (job_data blob, no
schema change), and injects the brief into every per-segment prompt. A new
reflect pass then critiques each segment's direct translation for wordiness /
stiff register and rewrites it as natural spoken dialogue — any failure or
divergence silently keeps the direct translation. Both stages have Dub-tab
toggles (default ON for the LLM engine, persisted; MT engines unaffected),
with i18n strings across all 21 locales and docs updated.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
feat(tts): numbers, times, and abbreviations are spoken correctly in every engine (#1049)
New conservative, idempotent pre-TTS normalization pass
(services/text_normalization.py): strips zero-width/control junk, caps
pathological repeat runs, expands digits/times/ordinals/currency via
num2words (29 locales) and per-language abbreviation maps (EN/DE/ES/FR).
Wired once at each text-to-engine choke point — /generate, dub segments
(+ preview), and longform chapters — BEFORE the pronunciation dictionary
so user respellings stay the final say. Pref-gated
(text_normalization_enabled, default ON) with OMNIVOICE_TEXT_NORMALIZATION
env override; num2words promoted to a direct dependency.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
feat(longform): segment-level render cache — edit one sentence, re-render one segment (#1048)
Adds a content-addressed segment cache (segment_cache_key + SegmentCache,
cache_dir/segments/) under the existing chapter cache: a changed chapter now
reuses every untouched span's WAV and synthesizes only the edited/missing
ones, and an interrupted chapter render resumes from the segments that already
finished (each persists the moment it renders). The chapter key derivation is
unchanged so on-disk caches from released versions keep hitting, a fully-
unchanged chapter never touches segment files, and prune_cache_dir now walks
both layers so one byte cap bounds the whole cache. Chapter SSE events gain
additive segments/cached_segments counts.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
docs(research): adjacent-projects notes — voicebox, RTVC, VoxCPM, ebook2audiobook, VideoLingo (#1047)
* docs(research): adjacent-projects read — RTVC, VoxCPM upstream, ebook2audiobook, VideoLingo
Owner-requested comparative research tied to the current maturity map:
voxcpm2 upstream sync items (>=2.0.3 MPS fix, ref-trim removal in 2.0.1,
trailing-audio guard), audiobook per-sentence cache playbook, dub
translation reflect-loop + glossary, RTVC migration positioning.
* docs(research): add voicebox (jamiepine) — the direct competitor read
---------
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
fix(watermark): bound AudioSeal memory — long audio embeds/detects in 30s chunks (#1045) (#1046)
A multi-minute generation pushed the whole waveform through the AudioSeal
generator in one call; its activation memory grows linearly with length, and
a reporter's 16 GB Windows box failed a single ~2.2 GB CPU allocation mid-
generate (DefaultCPUAllocator: not enough memory). Embedding and detection
now slice audio into ~30 s chunks (sub-second tails fold into the previous
chunk), so peak memory is flat regardless of audio length. Detection keeps
the best-confidence chunk, which also stops whole-file averaging from
diluting spliced audio.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
fix(clone): ⊕ Insert popover opens below the script input instead of climbing out of the viewport (#1043)
The popover was hard-anchored bottom-[60px] — always growing upward
from the textarea. ScriptPanel's only mount (CloneDesignTab) puts that
input at the very top of the panel, so the tag list (max-h 280px,
including the CMU phoneme chips visible in the owner's screenshot)
extended past the viewport top, unreachable and unscrollable. Anchored
top-[calc(100%+6px)] instead: below the input, where the panel's
topmost placement guarantees room in its one mount.
Regression test locks the placement (top-anchored, bottom-[60px]
banned).
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
feat(ui): global audio mini-player — waveform, seek, and time for every playback that had none (#1042)
playBlobAudio-path audio (generate auto-play, profile/segment previews,
story lines, gallery voices, Projects renders) played through a bare
Audio()/AudioContext with no on-screen player; the #1032 stop pill was a
stop-only band-aid with a fixed-overlay overlap quirk at 1440x900.
- playback.js: claimTrackedPlayback extends the single-playback manager
with label + seek/pause/resume transport and a timeupdate-driven track
snapshot (currentTime/duration/paused/peaks); claimPlayback stays as
the thin wrapper, single-playback invariant unchanged.
- media.js: every playBlobAudio path registers tracked - element paths
get real seek/timeupdate, the Tauri Web Audio path gets offset-based
seek + suspend/resume pause, and peaks are computed once from the
blob/decoded buffer already in hand (never refetched). onDone(reason)
lets callers chain (stories) or reset card state (gallery).
- GlobalAudioPlayer.jsx: persistent bottom bar (only for source
'output' — exact pill exclusion semantics) with peaks canvas,
click/drag/keyboard seek, play/pause, elapsed/total, label, stop.
- Layout: the bar is a real grid row (row 3) above the LogsFooter,
mirroring the footer's in-flow fix — content physically ends at its
top edge, so the pill's overlay-overlap class cannot recur; fixed
overlays anchored above the footer also clear --audio-dock-height.
Verified headless (Chromium 1440x900 + 1000x700, isolated vite, all
:3900 traffic intercepted): bar meets footer edge-to-edge, clears the
nav rail, seek/pause/stop drive the owner callbacks.
- Callers pass labels: "Generated audio" (useTTS/first-sound), profile
name / segment text (useProfiles), story line (StoriesEditor), voice
name (VoiceGallery/CommunityZone/ImportsZone), render title
(Projects). VoiceGallery drops its bespoke copy of the Tauri playback
detour; StoriesEditor line previews now actually play under WebKit
(blob: media URLs never worked there) and are stoppable mid-chain.
- PlaybackStopPill.jsx + its test deleted; intent migrated into
GlobalAudioPlayer.test.jsx (appears on output/hidden when idle/stop
works/excluded sources) plus transport coverage; playback.test.js
covers the tracked API; playBlobAudioTracked.test.js covers the
media wiring incl. onDone reasons; logsFooterInFlow.test.js now
guards both bars' grid rows and the overlay anchor calc.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
release: freeze v0.3.15 — version bump, lockfiles, changelog (#1041)
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
fix(tts): model-load time stops eating the generate timeout budget (#1039)
* fix(tts): model-load time stops eating the generate timeout budget (#1033, #1037)
The generate guard (OMNIVOICE_GENERATE_TIMEOUT_S, 300s) wrapped the
adapter's lazy _ensure_loaded() — weight download included — together
with the synthesis. A cold first request burned the whole window on
the download and died with the VRAM-guidance 503; #1014's T4
verification measured it (0% GPU util for the full 300s), and #1033 +
#1037 match the signature.
New public TTSBackend.ensure_ready() (dispatches to the adapter's
_ensure_loaded when present) runs FIRST under the model-load budget
(OMNIVOICE_MODEL_LOAD_TIMEOUT, 1200s) in both /generate's adapter path
and /v1/audio/speech — the same load/generate split get_model()
already gave the native engine. Warm engines no-op. A load exceeding
its own budget 503s with load-specific text pointing at Settings →
Models, never the misleading 'too heavy for compute' guidance.
Tests: end-to-end class test (load slower than a tiny generate budget
but inside the load budget → succeeds; fail-before verified), the
stalled-load error path, and the base-hook dispatch.
* changelog entry for the load-budget split (#1033, #1037)
* catch the builtin TimeoutError base — reload-proof class identity (CI-only miss)
---------
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
docs: link the community Colab notebook (#1038) (#1040)
@shakib30 built and tested a working Colab notebook for the project
and offered it upstream. Linking it from the README (community-
maintained, credited) makes the no-local-GPU path discoverable without
taking on notebook maintenance in-repo.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
fix(studio): restore Clear History, stoppable auto-play preview, + cached ref transcripts (#1032) (#1036)
Three-part fix for the v0.3.5-comparison report:
1. Perf: since v0.3.6 (#308), a clone reference without a stored
transcript triggered a FULL ASR model load + transcribe on every
/generate — get_active_asr_backend() builds a fresh whisper backend
per call. Measured live: 92.7s wall vs 14.9s of actual TTS. Now the
first auto-transcript is persisted onto the (unlocked, clone-kind)
profile row, and transcribe_reference caches results by audio
content hash (bounded LRU, no model/VRAM held), so the cost is paid
once per clip, not per request. User-typed transcripts are never
overwritten; locked/design profiles are excluded from the persist.
2. Clear History: the workspace UX overhaul (#374) moved history into
the right-side WorkspaceHistory panels and dropped the old Sidebar's
clear-all control (the Sidebar is now hidden in every mode). Both
the Voice and Dub panels get a scoped Clear History button wired to
the existing DELETE /history and /dub/history endpoints, with the
same confirm dialog the Sidebar used.
3. Auto-play: the finished-render playback (playBlobAudio) has no
on-screen player and the only stop lived in the Voice ActionBar's
CTA morph — unstoppable from the Dub workspace, profile pages, or
after navigating away. A global PlaybackStopPill now appears for any
'output' playback on every page. The existing Settings → Appearance
"Auto-play preview" pref (#667) now also gates the generate path,
as its label always promised (default ON — no behavior change).
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
fix(api): /v1/audio/speech honors num_step + guidance_scale instead of silently dropping them (#1014) (#1035)
A contributor's measured Tesla T4 verification (PR #1014) caught that
POST /v1/audio/speech accepted num_step/guidance_scale in the JSON
body with a 200 OK and discarded both (pydantic's default
extra=ignore) — API callers could never reach the model's documented
quality preset (num_step=32) through the OpenAI-compatible surface,
while the native /generate exposes both as form fields.
Both are now declared as validated optional extensions (num_step 1-128,
guidance_scale 0-20) and passed through to the engine's generate()
kwargs — omitted means absent (engines that don't accept the kwargs
never see a stray None), exactly like the existing duration/seed
extensions.
Tests: passthrough reaches the engine kwargs; omitted stays absent.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
docs(agents): add verified Tesla T4 (16GB) inference notes (#1014)
* docs: add AGENTS.md with verified Tesla T4 (16GB) inference notes
Documents two things found while verifying inference on a real T4:
1. Cold-cache first /v1/audio/speech call can hit the 300s
OMNIVOICE_GENERATE_TIMEOUT_S because the checkpoint download happens
inside that budget — workaround via existing POST /models/install or
raising the timeout, no code change needed.
2. The OpenAI-compatible endpoint silently ignores num_step/guidance_scale
(schema doesn't declare them) — use native /generate for those.
Also documents the T4 acceleration checklist (dtype/attention/int8/CUDA
graphs) and measured VRAM (peak 2.05GB). No code changes.
* fix(docs): make /models/install workaround command actually executable
Addresses Greptile review: the instruction omitted the required
repo_id body field (InstallModelRequest rejects an empty body).
* fix(docs): correct port in /models/install example (3900, not 8000)
The app serves on port 3900 (confirmed: /health returns 200 there,
connection refused on 8000). Verified the exact corrected curl command
returns 200 {"status":"install_started",...}.
* move T4 notes to docs/hardware-notes-tesla-t4.md — AGENTS.md is the auto-loaded agent-instructions filename
---------
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
fix(clone): bound the ref-text re-transcribe like every other ASR dispatch (#730) (#1031)
The re-transcribe added for the (ref_audio, ref_text) mismatch fix is
dispatched with a bare run_in_executor(_gpu_pool, ...), and refine_ref_text
calls asr_backend.transcribe() directly. Its try/except catches a raised
error but not a *hang* — a wedged whisperx/CTranslate2 transcribe (#730)
holds the GPU-pool worker forever. On a <=10 GB card the pool is 1 worker,
so that starves every later GPU job into the misleading "can't reach the
local backend", and there's no ping on the await so the EventSource drops.
Route both refine dispatches (per-speaker and per-segment) through the same
run_transcribe_guarded the rest of dub_core.py already uses (the chunk loop
and the whole-file "Dub" transcribe). On timeout it resets the pool and
raises ASRTimeoutError; keep the original clones, matching refine_ref_text's
own "failure is a strict no-op" fallback.
Adds a repro test: refine_ref_texts dispatched raw is unbounded on a hang;
through the guard it times out and falls back to the original ref_text.
Co-authored-by: stronghamjji <289942360+stronghamjji@users.noreply.github.com>
feat(skills): installable Agent Skills — npx skills add debpalash/omnivoice-studio (#1034)
Two skills in the standard skills/<name>/SKILL.md layout (vercel-labs/
skills CLI; listed on skills.sh via install telemetry):
- omnivoice — teaches any agent (Claude Code, Cursor, Codex, …) to
speak and transcribe through the user's LOCAL install via the
OpenAI-compatible API at localhost:3900: health preflight, TTS with
cloned-voice-profile discovery via /v1/audio/voices, STT with
srt/vtt subtitle formats, and the local-first rule (never silently
fall back to a cloud API).
- oss-maintainer — the maintainer methodology this repo is actually
run with, distilled from real sessions: absorbed-or-declined queue
discipline, check-the-PR-queue-before-implementing, root-cause →
fix-the-class → regression-test, structural merge gates with
flaky-vs-real judgment, the release protocol, and
thank-contributors-specifically.
Every endpoint/flag in the omnivoice skill verified against
backend/api/routers/openai_compat.py and the README's API section.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
fix(update): app updates stop uninstalling user-added engines — drift sync goes --inexact (#1029) (#1030)
Every app update whose uv.lock changed ran `uv sync --frozen` to
reconcile the venv (#307 drift path) — and uv sync's exact mode
UNINSTALLS every package not in the lockfile. That silently deleted
user-pip-installed optional engines (voxcpm, kittentts — packages the
app's own Settings → Engines hints tell users to install into this
venv) on every single update. Reported as "VoxCPM2 is automatically
uninstalled after updating Studio."
Fix: the routine drift sync now carries --inexact — locked deps are
still installed/upgraded exactly per the lockfile, but extras the user
added on purpose are left alone. Deliberate asymmetry: the venv-REPAIR
sync stays exact, because repair runs when the venv is broken and a
user-installed extra is a plausible cause — healing must restore the
known-good locked state. First-run syncs are untouched (a fresh venv
has no extras; exact == inexact there).
Both sync arg sets are now named constants with contract tests pinning
the asymmetry, so neither side can silently regress.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
docs(faq): honest ElevenLabs comparison — where each wins, and why dub quality varies (community question) (#1028)
Asked directly on Discord ('how it compares to something like 11 labs
in quality?'). The old answer ('yes, comparable for most use cases')
oversold — the honest version names where ElevenLabs still wins
(out-of-the-box English polish/consistency) and where OmniVoice is
genuinely competitive (cloning from clean references, 646 languages,
structural advantages), plus the dubbing-specific truth another
same-day report surfaced: a dub is a chain, and incoherent output
usually traces to transcription quality on the user's source audio —
with the check-the-original-text-first debugging step that actually
helps.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
release: freeze v0.3.14 — version bump, lockfiles, changelog (#1027)
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
feat(asr): ASR engines get the same Settings picker TTS has (env var still wins) (#1026)
Settings → Engines now stacks one pinned Engine Compatibility Matrix per
family (TTS, ASR, LLM) instead of a single TTS-titled table with the other
families tucked behind a low-discoverability tab. The backend select/prefs
path (family="asr" → prefs.asr_backend, env > prefs > auto-detect) already
worked but was unexercised and undocumented — it's now locked by API and
resolution-order tests, and README + the openai-compat-asr doc stop
promising a picker that didn't exist / denying one that now does.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
docs: CLAUDE.md refresh — de-rot versions, compress shipped stack research, replace the dead GSD gate (#1025)
Three classes of staleness that actively misled agent sessions:
- The Project section hardcoded "latest stable v0.3.5 / main at v0.3.6"
— eight releases behind. Now points at the sources of truth
(frontend/package.json, the Releases page) and documents the current
AUTO_VERSION_BUMP-off holding behavior instead of a version literal
that rots every release.
- ~165 lines of May-2026 stack research for five capabilities that have
ALL since shipped (HF-token panel, prefilled-URL bug reporting, uv
mirror fallback, Supertonic-3, in-repo docs). Compressed to the
durable don'ts it established (no telemetry endpoints, no app-side
GitHub tokens, no setx, no MkDocs, no hf_transfer) plus a pointer to
prefer what's already pinned.
- The GSD Workflow Enforcement gate referenced /gsd-quick//gsd-debug/
/gsd-execute-phase skills that exist nowhere in this environment; the
owner explicitly chose direct edits over restoring them (2026-07-08).
It cost a real mid-task detour when a subagent correctly refused to
work under an unsatisfiable rule. Replaced with the owner decision
and the working conventions that actually bind (merge gating,
check-the-PR-queue-first).
244 → 83 lines. GSD section markers preserved so the generating tool
can still find its blocks.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
fix(linux): AppRun workaround detection reads the BUNDLED WebKitGTK version, not the host's (#961 follow-up) (#1024)
The launcher decided whether to export WEBKIT_DISABLE_COMPOSITING_MODE
by asking the host's pkg-config — but LD_LIBRARY_PATH makes the
BUNDLED libwebkit2gtk the one that actually runs, so on any machine
where the two diverge the detection read the wrong number. This was
the second bug identified during #961's investigation (the reporter
built from source, so their dev packages answered pkg-config with a
healthy version while the shipped bundle ran an older lib) and was
explicitly deferred in #1007 as not-safely-fixable at runtime.
The fix makes it knowable by construction instead: inject-apprun.sh
runs at bundle time ON the build host whose libwebkit2gtk gets
bundled, so it stamps that version into .bundled-webkitgtk-version
inside the AppDir. AppRun reads the stamp first and only falls back to
host pkg-config for bundles predating it. Empty/unreadable stamp fails
safe (workaround on), same philosophy as the missing-pkg-config path.
Tests: 3 new cases in AppRun.test.sh — marker-beats-host in both
directions (broken-marker/healthy-host and the #961 inversion,
healthy-marker/broken-host) plus empty-marker fail-safe. Also wires
AppRun.test.sh into pytest (tests/test_apprun_launcher.py) — it was
previously run by NO CI job, so the launcher could regress silently.
Also documents Windows install-to-another-drive behavior in
docs/install/windows.md (#938): local drives work via the wizard's
directory picker, mapped network drives are a Windows Installer
limitation, and the data directory moves independently of the app.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
release: freeze v0.3.13 — version bump, lockfiles, changelog (#1023)
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
docs: correct troubleshooting §16 — mic bug was a missing entitlement, not an upstream limitation; changelog for #1016/#1020/#1021 (#1022)
troubleshooting.md §16 claimed the macOS microphone-permission bug was
an unresolved upstream Tauri/wry limitation with no available fix.
That was wrong: @MahdiHedhli read the wry/tauri sources more carefully
and found the real cause — Tauri's Hardened Runtime default blocks mic
hardware access without com.apple.security.device.audio-input in the
bundle's entitlements, which also explains why TCC never listed the
app. Their fix (#1016) is merged; §16 now documents the real mechanism,
credits the correction, and keeps the record-elsewhere workaround for
users on ≤0.3.12 builds.
Also brings CHANGELOG [Unreleased] current for the three merges that
lacked entries: #1016 (mic fix), #1020 (shutdown wait 3s→20s), #1021
(CI flaky-trio root cause + guard).
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
test(ci): root-cause and neutralize the flaky trio — a leaked torch fp16 default dtype (#1021)
test_effects_chain / test_generation_audio_guard / test_persona_bundle
failed intermittently on CI (never locally) with identical signatures
across three unrelated PRs today (#1002, #1019, #1016) — costing a
full CI cycle per occurrence and repeatedly muddying merge decisions.
Root cause, confirmed by local reproduction: a leaked
torch.set_default_dtype(torch.float16) from some earlier test in the
CI-Linux ordering. The smoking gun was test_generation_audio_guard's
observed 0.0999755859375 — exactly float16(0.1), i.e.
torch.tensor([0.1, …]) built under a leaked fp16 default. Reproducing
with a simulated polluter locally produced the trio's exact failures:
Pedalboard refuses fp16 audio outright ("only supports 32-bit and
64-bit floating point") and silently returns unmodified audio for
every preset, so test_effects_chain's preset outputs compare
identical; and the fp16 tensor value breaks the sanitize approx-check.
Fix: an autouse conftest guard (same philosophy as the existing
LLM-state isolation guard, #878) that checks torch's default dtype
after every test, resets any leak to float32, and emits a UserWarning
naming the offending test's nodeid — so the actual CI-only polluter
identifies itself in the next CI log instead of being chased blind.
Regression test: a deliberate-leak pair proving reset-between-tests.
Fail-before/pass-after verified: with the guard stashed, a simulated
polluter + the trio reproduced 2/3 failures locally with the exact CI
signatures; with the guard active, 73/73 pass and the warning names
the polluter.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
fix(macos): add microphone/camera entitlements so TCC ever sees a request (#1016)
Root cause of #1013 (macOS: "Microphone access denied" but OmniVoice never
appears in Privacy & Security → Microphone to enable it):
Tauri's macOS bundle config defaults `hardenedRuntime` to true, and Hardened
Runtime blocks camera/microphone hardware access unless the matching
entitlement is present — regardless of Info.plist's NSMicrophoneUsageDescription
(that only supplies the *prompt text*, it isn't itself the grant) and
regardless of wry's own WKUIDelegate already granting the request at the
WebKit/JS layer (WryWebViewUIDelegate::request_media_capture_permission
unconditionally calls WKPermissionDecision::Grant — confirmed by reading
wry 0.55.1's source; that part was never the problem). With Hardened Runtime
on and zero entitlements, TCC never registers a request at all, which is
exactly the reported symptom: nothing to enable because the OS never saw a
legitimately-entitled process ask. This also explains the workaround in
#1013 and its comments (launching the raw binary from Terminal works, but
as Terminal's identity, not the app's) — Terminal is a properly entitled,
hardened-runtime process; the ad-hoc/unentitled app binary isn't.
Adds src-tauri/entitlements.plist (com.apple.security.device.audio-input,
plus com.apple.security.device.camera matching the forward-looking
NSCameraUsageDescription already in Info.plist) and wires it in via
tauri.conf.json's bundle.macOS.entitlements. Also corrects the stale
"nothing to do here" module comment in lib.rs that documented the
incomplete assumption this bug falsified.
Verified: built a debug .app (`tauri build --debug --bundles app`) and
diffed `codesign -dv --entitlements -` before/after this change — the
entitlements dictionary goes from absent to containing exactly the two
keys added here, alongside the runtime (Hardened Runtime) flag that was
already on. `cargo test` — 60 passed, 0 failed.
fix(backend): shutdown wait bound 3s→20s — post-merge review finding on #1002; absorb #1015's design-path test (#1020)
Greptile's review of the merged #1002 flagged a real residual gap: a
cold transformers import alone can exceed the 3s shutdown wait, and
cancelling the asyncio task doesn't stop the underlying OS thread —
so quitting during an unusually slow preload could still let shutdown
report "done" while that thread was alive, the exact #1000 class with
lower odds. Python cannot forcibly kill a running thread, so no finite
bound eliminates this outright; 20s shrinks the window from "any
preload" to "an unusually slow cold-import," the practical ceiling
before a long shutdown becomes its own complaint. New source-level
contract test pins the production bound at ≥15s so a future edit
can't quietly shrink it back without deliberate consideration.
Also absorbs the one test case from community PR #1015 (superseded by
the earlier-merged #1017, which duplicated it — my fault for not
checking the PR queue) that the merged version lacked: the
design/instruct path with no ref kwargs at all stays untouched by the
ref_text forwarding fix.
Co-authored-by: mergetest <test@local>
Co-authored-by: MahdiHedhli <noreply@github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
fix(ui): dub editor play button no longer sticks disabled; remove donate heart from nav rail (#1019)
WaveformTimeline's play button (disabled={!ready}) stayed permanently
disabled whenever the initial WaveSurfer decode failed and the
component fell back to loading pre-computed peaks. The waveform still
rendered fine from those peaks (nothing looked visibly broken), but
`ready` was only ever flipped by the 'ready' event re-firing on that
recovery load — which this component's own error-handling never
actually confirmed, just assumed. Each of the three fallback ws.load()
calls now explicitly confirms readiness once it settles (via .then()/
.catch(), or the existing synchronous-throw catch), instead of hoping
the event fires again.
Regression test: WaveformTimeline.readyFallback.test.js — a
source-level contract guard (driving a real decode-failure/recovery
sequence through jsdom is brittle, same house pattern as the sibling
WaveformTimeline.unlock.test.js) asserting every fallback load in the
error handler is followed by an explicit setReady(true).
Also removes the "Support OmniVoice" heart button from NavRail — the
donate page stays reachable from Settings' footer and the Contact page.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
fix(voice): free-text instruct now filtered before every generate/save call (#1010) (#1018)
buildDesignInstruct() already keeps Studio's design/clone generate
calls (useTTS.js) from round-tripping a 400 "Unsupported instruct
items" — it filters free-text against the active engine's supported
vocabulary client-side, with a toast instead of a failed request. Three
other call sites built their own instruct string directly and skipped
it entirely:
- handleSegmentPreview (Dub tab's per-segment preview) — instruct comes
straight from segment/preset data; a preset's raw attrs merged with a
free-text style field can carry phrases outside the vocabulary.
- handleSaveProfile / handleSaveHistoryAsProfile — both always create a
kind='clone' profile; the backend only sanitizes instruct on save for
kind='design' (see profiles.py's heal_design_instruct branch), so a
clone profile could silently persist an unusable instruct and then
400 every single time it's later used to generate.
All three now filter through the same buildDesignInstruct({}, instruct)
call useTTS.js's own clone path already uses, with the same
unsupported/duplicate-item toasts.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
fix(tts): mlx-audio CSM cloning drops ref_text, breaking every clone attempt (#1012, #1013) (#1017)
MLXAudioBackend.generate() reads voice/ref_audio/language/speed from
its kwargs but never extracted ref_text — it was built, then silently
never passed through to self._model.generate(). CSM (sesame.py) only
builds its cloning context when BOTH ref_audio AND ref_text are
present; with ref_text missing, the context list stays empty and
indexing into it raises "IndexError: list index out of range" deep
inside mlx-audio, instead of the clone ever being attempted. Voice
cloning on the CSM engine could never have worked as shipped.
generation.py already threads ref_text all the way through — even
auto-transcribing it via the GPU pool when the caller supplies
ref_audio without one (~line 780) — so the value was always available
in kwargs; it just never survived the crossing into this specific
backend.
Reported with the precise root cause and a working fix (community
member independently diagnosed and patched it locally, confirmed
working on MPS/0.3.12). Two-line fix: extract ref_text and pass it
through when both ref_audio and ref_text are present (guards against
passing an orphaned ref_text with no accompanying audio to engines
that don't expect it).
Tests: tests/test_engines.py — ref_text is passed through when paired
with ref_audio, omitted when ref_audio is absent.
Also documents the second bug from the same report (#1013): macOS
microphone permission never prompts, so OmniVoice never appears in
System Settings to grant access. Root-caused to an unresolved upstream
Tauri/WebKit limitation (WKWebView's requestMediaCapturePermissionFor
delegate — wry#1195, tauri#11951, fix wry#1196 still open/unmerged, no
released version to bump to) — not something fixable here without an
unverified native Rust/WKWebView hack this session has no way to test.
Documented in docs/install/troubleshooting.md with the confirmed
workaround (record elsewhere, upload the file).
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
fix(gallery): surface the real error instead of a generic guess (#1009)
Found while triaging Discord: a community member (lehoangan227) hit
"Could not create that voice — the engine may be loading" trying to
use an archetype from the Gallery. That message is hardcoded and
shown for ANY failure — the actual cause (a 500, a validation error,
anything) is caught and discarded.
api/client.js's ApiError already builds a clean, user-facing message
for every failure mode (HTTP status + backend detail, a network
failure, or a detected backend crash) — this codebase's own
established convention elsewhere is to interpolate that message via
`{{message}}` (see BatchQueue.jsx, Settings.jsx, ToolsPage.jsx). The
Gallery's own catch blocks just weren't following it.
Fixed the whole class across VoiceGallery.jsx (use/preview),
CommunityZone.jsx (add-to-voices, whose catch clause didn't even bind
the error), and ImportsZone.jsx (search/upload/save/delete/trim —
handleDelete previously failed completely silently, no message at
all). All now interpolate the real error message, matching the
gallery.download_failed key that already did this correctly a few
lines away.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
fix(dub): cross-language dub no longer speaks source-language reference text verbatim (#1004) (#1008)
extract_speaker_clones/extract_segment_refs pair each audio slice (cut
at ASR segment timestamps) with that segment's own `text` field, on
the assumption the two agree. They routinely don't — Whisper (and
friends) frequently drift on segment boundaries: a trailing word
audible in [start, end] but missing from text, or vice versa. When the
(ref_audio, ref_text) pair disagrees, zero-shot TTS prompt-priming
breaks down and the clone can emit the mismatched reference text
itself instead of the target-language line it was asked to speak —
reported with an exceptionally clear root-cause diagnosis and a
working A/B repro (matched pair: clean on the first try; mismatched
pair: wrong language 6/6 times).
Fix (as proposed in the report): re-transcribe each written reference
clip via the already-loaded, already-warm active ASR backend and use
that transcript as ref_text — this guarantees the pair matches by
construction, independent of whether the original segment text was
ever right. Falls back to the original text on any re-transcribe
failure or empty result — never a regression from current behavior,
only ever a fix.
New services.speaker_clone.refine_ref_text (single clip, unit-testable
against a duck-typed fake ASR backend) and refine_ref_texts (batch —
one executor round-trip per whole clones/seg_clones dict rather than
one per reference). Wired into dub_core.py's two clone-extraction call
sites, routed through _gpu_pool to match the established convention
for ASR-backend calls (the model is mid-lifecycle: TTS is offloaded,
ASR is loaded and exclusive, right where the existing per-chunk
transcribe calls already run on this same pool).
Tests: tests/test_speaker_clone_purity.py — 6 new cases covering the
mismatch-correction path, ASR-failure fallback, empty-transcript
fallback, no-backend no-op, and batch behavior (one failing entry
doesn't affect the others). Full backend suite: 2412 passed, 0 failed.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
fix(ci): bump Linux release runner to ubuntu-24.04, fixing stale bundled WebKitGTK (#961) (#1007)
The AppImage bundles whatever libwebkit2gtk-4.1-dev the build runner's
apt repos resolve at build time (see the "Linux system deps" step) —
AppRun's LD_LIBRARY_PATH then makes that bundled copy take priority
over the host's system WebKitGTK at runtime. ubuntu-22.04's version
was stale relative to what current distros (Ubuntu 24.04+, Fedora 44)
ship, which is why a from-source build (linking straight against the
host's healthy system library) worked fine on the exact machine where
the shipped AppImage white-screened — the released binary was running
an older, buggier WebKitGTK under the hood regardless of the host.
Bumped the Linux release matrix entry to ubuntu-24.04, and ci.yml's
Tauri shell-check job to match (its own comment already says "Mirror
release.yml" — now it actually does, so a green PR check accurately
predicts the release build will also succeed).
Raises the AppImage's glibc floor from 2.35 to 2.39 (Ubuntu 24.04+) —
README's system-requirements table corrected from the now-false
"Ubuntu 20.04+" claim. No reports of anyone on a pre-2022 distro.
This does not fix the AppRun launcher's separate, related bug (its
WebKitGTK-version auto-detection reads the *system's* pkg-config
version, not the version actually bundled and running) — that would
need a reliable way to read the bundled .so's version from within the
AppImage, which isn't straightforward (WebKitGTK's soname doesn't map
1:1 to its release version) and isn't verifiable without a real Linux
build environment to test against. Left as a known, separate gap.
Cannot be verified from here on a real Ubuntu 26.04 machine — shipped
on the strength of the root-cause diagnosis, pending the reporter's
confirmation.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
fix(backend): quitting mid-preload no longer reports a clean shutdown while a GPU-pool thread is still importing (#1000) (#1002)
A user-pasted backend log revealed the real cause behind a class of
'can't reach backend' reports: three rapid restart cycles, each ending
with 'Shutdown: done.' immediately followed by a 'Model loading failed:
Could not import module AutoFeatureExtractor' error. That error text is
transformers' own generic lazy-import wrapper (import_utils.py's
_LazyModule.__getattr__), not a real dependency problem — pyproject.toml
already pins transformers/torch/torchaudio/soundfile/librosa as core,
non-optional deps, and the same venv loaded the model successfully 90
seconds later in the same log.
Root cause: preload_task and capture_preload_task were created at
startup but never referenced in the lifespan shutdown block — idle_task
and worker_task got cancelled-and-awaited, the preload tasks were simply
abandoned. Cancelling an asyncio task awaiting run_in_executor() can't
stop the underlying OS thread once it's inside blocking import/load
work, so 'Shutdown: done.' logged while a GPU-pool thread was still
mid-Version: ImageMagick 7.1.2-25 Q16-HDRI aarch64 037e46295:20260604 https://imagemagick.org
Copyright: (C) 1999 ImageMagick Studio LLC
License: https://imagemagick.org/license/
Features: Cipher DPC HDRI Modules
Delegates (built-in): bzlib freetype heic jng jpeg lcms ltdl lzma png tiff webp xml zlib zstd
Compiler: clang (21.0.0)
Usage: import [options ...] [ file ]
Image Settings:
-adjoin join images into a single multi-image file
-border include window border in the output image
-channel type apply option to select image channels
-colorspace type alternate image colorspace
-comment string annotate image with comment
-compress type type of pixel compression when writing the image
-define format:option
define one or more image format options
-density geometry horizontal and vertical density of the image
-depth value image depth
-descend obtain image by descending window hierarchy
-display server X server to contact
-dispose method layer disposal method
-dither method apply error diffusion to image
-delay value display the next image after pausing
-encipher filename convert plain pixels to cipher pixels
-endian type endianness (MSB or LSB) of the image
-encoding type text encoding type
-filter type use this filter when resizing an image
-format "string" output formatted image characteristics
-frame include window manager frame
-gravity direction which direction to gravitate towards
-identify identify the format and characteristics of the image
-interlace type None, Line, Plane, or Partition
-interpolate method pixel color interpolation method
-label string assign a label to an image
-limit type value Area, Disk, Map, or Memory resource limit
-monitor monitor progress
-page geometry size and location of an image canvas
-pause seconds seconds delay between snapshots
-pointsize value font point size
-quality value JPEG/MIFF/PNG compression level
-quiet suppress all warning messages
-regard-warnings pay attention to warning messages
-repage geometry size and location of an image canvas
-respect-parentheses settings remain in effect until parenthesis boundary
-sampling-factor geometry
horizontal and vertical sampling factor
-scene value image scene number
-screen select image from root window
-seed value seed a new sequence of pseudo-random numbers
-set property value set an image property
-silent operate silently, i.e. don't ring any bells
-snaps value number of screen snapshots
-support factor resize support: > 1.0 is blurry, < 1.0 is sharp
-synchronize synchronize image to storage device
-taint declare the image as modified
-transparent-color color
transparent color
-treedepth value color tree depth
-verbose print detailed information about the image
-virtual-pixel method
Constant, Edge, Mirror, or Tile
-window id select window with this id or name
root selects whole screen
Image Operators:
-annotate geometry text
annotate the image with text
-colors value preferred number of colors in the image
-crop geometry preferred size and location of the cropped image
-encipher filename convert plain pixels to cipher pixels
-extent geometry set the image size
-geometry geometry preferred size or location of the image
-help print program options
-monochrome transform image to black and white
-negate replace every pixel with its complementary color
-quantize colorspace reduce colors in this colorspace
-resize geometry resize the image
-rotate degrees apply Paeth rotation to the image
-strip strip image of all profiles and comments
-thumbnail geometry create a thumbnail of the image
-transparent color make this color transparent within the image
-trim trim image edges
-type type image type
Miscellaneous Options:
-debug events display copious debugging information
-help print program options
-list type print a list of supported option arguments
-log format format of debugging information
-version print version information
By default, 'file' is written in the MIFF image format. To
specify a particular image format, precede the filename with an image
format name and a colon (i.e. ps:image) or specify the image type as
the filename suffix (i.e. image.ps). Specify 'file' as '-' for
standard input or output., and interpreter finalization tore down module
state under it — producing exactly this misleading error.
Fix: extract the existing cancel+bounded-await pattern into
_cancel_and_await_tasks() and apply it to all four background tasks, not
just two. An early-stage load (still importing, not yet mid weight-
download) now gets a real chance to finish before shutdown proceeds; a
load genuinely deep in blocking work still times out at the same 3s
bound, and _reset_gpu_pool() abandons it same as before. Also: both
error handlers around this path logged only str(exc), discarding
__cause__ — added exc_info so a future incident (even one this fix
doesn't fully prevent) surfaces the real underlying error instead of the
misleading generic wrapper text.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
feat(asr): generic OpenAI-compatible transcription backend (#877) (#1003)
First slice of the community's two-track proposal for #877: a generic
OpenAI-compatible ASR backend that works TODAY, without waiting on
transformers to ship a direct Qwen3-ASR integration (tracked separately,
still blocked upstream). Points OmniVoice's transcription at any server
exposing POST /v1/audio/transcriptions — a self-hosted Qwen3-ASR/
FunASR/SenseVoice server, or OpenAI's own API.
- New OpenAICompatASRBackend (backend/services/asr_backend.py): a pure
network client, no local model, no install. Prefers
response_format=verbose_json for real per-segment timestamps,
degrades to plain text (matching MoonshineASRBackend's shape) when a
minimal server rejects that format. Never leaks a raw SDK/httpx
exception to the caller (#977 convention) — wraps network/auth
failures in a clean, actionable RuntimeError naming the server.
- Settings persist via the same encrypted-secret convention as
services/llm_providers.py (settings_store.set_secret for the API key
— Fernet-encrypted, never a .env row, never echoed back; get_text/
set_text for base_url/model). New GET/PUT /api/settings/
asr-openai-compat, loopback-gated like every other settings route.
- Frontend: a small settings panel (Settings → Models) mirroring
HFMirrorPanel's exact structure. No ASR engine picker exists yet for
ANY ASR backend (only TTS has one) — activating this engine still
needs OMNIVOICE_ASR_BACKEND=openai-compat-asr; documented plainly
rather than pretending otherwise.
- README's ASR Engines table (9 → 10 engines) and docs/features.yaml's
drift-checker inventory updated; the '9 engines, all fully local'
claim corrected since this one genuinely isn't.
- docs/engines/openai-compatible-asr.md: setup steps + an explicit
privacy note (unlike every other ASR engine, audio leaves the
machine to whatever server is configured).
Regression tests: tests/test_asr_openai_compat_877.py (12 tests) —
is_available() gating, verbose_json + plain-text response adaptation,
network-failure error hygiene, SDK retry disabling, and the settings
endpoints' persist/mask/clear-vs-unchanged semantics.
Fixed two real full-suite-only failures found during verification (not
brushed aside): the API route inventory snapshot needed regenerating
for the two new routes, and this file's own tests had a module-
staleness bug — a collection-time settings_store import went stale
relative to a test-time-fresh fixture when another test elsewhere in
the ~2400-test suite reimports the module — fixed by making
settings_store itself a fixture resolved at test-run time, same
lesson already applied to tests/test_mm2_lifecycle.py earlier this
session.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
release: freeze v0.3.12 — version bump, lockfiles, changelog (#1001)
19-issue GitHub sweep: 16 PRs merged since v0.3.11, most fixing reports
filed same-day or in the days prior. Highlights: MLX-Audio's 6 other
curated models are finally selectable (was silently stuck on Kokoro
regardless of what was downloaded), first-run no longer dead-ends behind
restricted networks or corporate TLS-inspecting proxies, dubbing/batch
TTS honor your active engine selection, and a run of sharp community
diagnoses (ROCm wheel index, Windows dictation focus-steal, a genuine
frontend crash regression) got fixed largely because reporters did the
hard diagnostic work themselves.
Full backend suite: 2390 passed, 0 failed. Full frontend suite: 918
passed, 0 failed. Version lockstep (tests/test_app_version.py): 6/6
passed. Docker frozen-lockfile parity (bun install --frozen-lockfile):
clean, no drift.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
fix(diagnostics): TTS generate timeout message tells you to Flush/Unload (#939) (#999)
The guard itself (#851/#896) is working as designed — this is a message
gap, not a logic bug. The GPU-branch timeout guidance explained VRAM
contention but never mentioned the Flush/Unload action that actually
resolves it, even though: (a) that action already exists (POST
/system/flush-memory, wired to the header's Flush button), and (b) the
sibling ASR-timeout guard's message already recommends it verbatim
(asr_backend.py's _CUDA_VRAM_BUDGET_GB guidance). The maintainer ended up
manually explaining 'Settings → Models → Flush caches / Unload' in an
issue thread reply — information the error message should have carried
itself.
String-only change, no control-flow touched, mirrors the exact precedent
of #896 (a guidance-only change to this same function).
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
fix(diagnostics): filter Tauri's benign IPC-fallback warning from frontend log capture (#975) (#998)
On some Windows configurations, Tauri's custom-protocol IPC probe fails
once at startup and Tauri logs a console.warn before silently — and
successfully — falling back to postMessage + WebSocket. Fully functional,
happens at most once per launch, and not a bug in our code (confirmed:
this is Tauri's own internal fallback mechanism, structurally intentional
across its recent 2.11.x releases, not something being actively patched
upstream — so not bumping the framework speculatively for this).
It IS real noise though: as a captured console.warn it spuriously flips
the Settings > Logs footer's Frontend pill to "1 warning" on every
affected Windows launch. Filtered at the capture source (consoleBuffer.js)
rather than the display layer, so it never enters the ring buffer or a
copied diagnostic dump either — narrowly scoped to this one known message
prefix, not a general warning-suppression mechanism.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
docs(linux): mention yt-dlp as an optional prerequisite (#973) (#997)
The preflight system check already warns in-app when yt-dlp is missing
(Voice Gallery/Dub YouTube downloads fail without it), but the install
docs never mentioned it — a user has to hit the in-app warning first
instead of seeing it up front alongside the other optional prereqs.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
fix(dictation): Windows pill no longer steals foreground focus (#982) (#996)
Root-caused and verified against the actual code (community diagnosis in
#982 was accurate): showing the dictation pill — an always-on-top top-level
WebviewWindow — grants it Win32 foreground activation by default on
Windows, which macOS doesn't do for a shown window. Nothing marked the pill
non-activating, so on Windows the synthesized Ctrl+V from `simulate_paste`
landed back in the pill instead of the app being dictated into. Code review
also found a second, more direct culprit at the same call sites: the
global-shortcut Pressed handler's `win.set_focus()` was only skipped on
macOS (`#[cfg(not(target_os = "macos"))]`), so Windows explicitly focused
the pill on top of the implicit Win32 activation; the tray "dictate" menu
handler called `win.show(); win.set_focus();` unconditionally on every
platform, including Windows.
This is the Windows counterpart of #287 (macOS auto-paste — don't steal
focus): same class of bug, same "pill must stay unfocused so the paste
lands in the target app" intent (already stated in the `grant_webview_
media_permissions` module comment), different OS mechanism.
Fix, mirroring #287's platform-cfg pattern:
1. WS_EX_NOACTIVATE applied to the pill's HWND once, right after creation
(`mark_pill_noactivate`), via the `windows` crate pinned to the exact
0.61.x tauri itself already resolves to — Cargo.lock confirms this
unifies to the same `windows 0.61.3` already in the graph, so
`WebviewWindow::hwnd()`'s HWND and our Win32 calls share one type and
no new crate version was added.
2. `ShowWindow(SW_SHOWNOACTIVATE)` (`show_pill_noactivate`) in place of
`.show()` at the two dictation-trigger call sites (global shortcut +
tray "Start Dictation"), since `.show()` alone still raced the style
bit on some paths.
3. The explicit `set_focus()` calls at those same two sites are now
skipped on Windows too, the same way they already were on macOS.
macOS and Linux are untouched: the macOS cfg branch is unchanged, and the
Linux branch of the `set_focus()` guard still runs exactly as before.
The pill's auto-dismiss (`scheduleDismiss`/`dismiss` in CaptureWidget.jsx)
was checked and is a plain unconditional setTimeout chain — it is not
gated on any native focus-loss/paste-completion signal, so there's no
independent bug to fix there. The "never dismisses" symptom is a
consequence of the focus-steal, not a separate stall: once the pill wrongly
held foreground for the whole session, hiding it later left Windows'
foreground state inconsistent. With the pill never taking focus, the
target app stays foreground throughout and there's nothing to reconcile.
Win32 window-activation syscalls (`#[cfg(target_os = "windows")]`) can't
run under `cargo test`/`cargo build` on this non-Windows CI runner, so the
new `pill_noactivate_tests` module tests the pure flag math
(`with_noactivate_style`) instead — platform-agnostic, runs everywhere,
verified passing here. The actual HWND-touching code is logic-reviewed but
UNVERIFIED on real Windows; the reporter offered to test a patched build,
which is the recommended next step before this ships in a release.
`cargo build` and `cargo test --lib` both pass (60/60 tests, including the
3 new ones); the pre-existing `setup.rs` unreachable_code warning (#286) is
unrelated.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
fix(clone): voice-design panel no longer crashes on a partial vd_states shape (#983) (#995)
Crash: DesignMethodPanel's optLabel() called val.replace(...) on an
undefined category value — a regression from f678e33, which swapped a
safe plain template literal for an i18n lookup that assumes vdStates[key]
is always a string. Both occurrences (the label kicker and the chip
list) are now guarded, falling back to 'Auto' the same way the rest of
the component treats an unset category.
Root cause: vdStates could actually go partial in real usage. Selecting
a design profile (useProfiles.js) or restoring legacy localStorage state
(useAppData.js) applied the backend/stored vd_states object as-is, with
no check that all 6 CATEGORIES keys were present — so an older client,
hand-edited payload, or partial API write reproduced the crash on
selection. Both call sites now run the restored object through
mergeDescribedAttrs() (voiceInstruct.js), the existing completion helper
already used for the "describe your voice" path, which fills any
missing/unknown category with 'Auto'. useAppData.js also gained the
typeof === 'object' guard useProfiles.js already had.
Closes the class at the source: POST /profiles now completes vd_states
against CATEGORY_ORDER (core/describe_voice.py, the same list the
frontend's CATEGORIES mirrors) before persisting, so a design profile
can never be *saved* with an incomplete shape regardless of which
client wrote it — updated two existing tests whose fixtures asserted
the old (partial) persisted shape.
Regression tests: DesignMethodPanel render test with a partial vdStates
input, a mergeDescribedAttrs unit test for the exact partial shape from
the issue, and a backend test asserting POST /profiles fills all 6 keys.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
feat(engines): expose MLX-Audio's curated model picker (#981) (#994)
mlx-audio multiplexes 7+ curated models (Kokoro, CSM, Qwen3-TTS, Dia,
Chatterbox, MeloTTS, OuteTTS) behind a single "mlx-audio" backend id, but
MLXAudioBackend resolved its active model ONLY from the
OMNIVOICE_MLX_AUDIO_MODEL env var — invisible to Settings and unreachable
without restarting the packaged app with that var set. A user who
downloaded e.g. Llama-OuteTTS via Settings → Models had no way anywhere
in the UI or API to actually load it; the backend silently kept using
Kokoro.
Fix:
- MLXAudioBackend.__init__ now resolves its model via
prefs.resolve("mlx_audio_model_id", env=..., default=...), mirroring
active_backend_id()'s env > prefs > default order exactly.
- get_active_tts_backend()'s switch-detection now also tracks the
resolved mlx-audio model key, so a model-only change (same backend id)
invalidates the cached instance and reconstructs it — no app restart
needed to pick up a different curated model.
- POST /engines/select gained an optional model_id field; for
family=tts/backend_id=mlx-audio it validates against
MLXAudioBackend.CURATED_MODELS (or a raw HF repo id, matching the
class's existing tolerance) and persists it via prefs.
- GET /engines now includes a curated_models roster + active_model_id on
the mlx-audio entry only.
- Settings → Engines renders a small model dropdown on the mlx-audio row,
pre-selected to the active model, wired through selectEngine's new
optional modelId argument.
Regression coverage: prefs resolution + env override, cache invalidation
on model-only switch, /engines/select 400s on an unknown model id and
persists a valid one, curated_models present only on mlx-audio, and a
new EngineCompatibilityMatrix vitest suite for the dropdown.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
fix(install): classify SSL handshake failures + trust the OS cert store (#976) (#992)
Windows users behind a corporate/antivirus TLS-inspecting proxy got a raw
`[SSL: SSLV3_ALERT_HANDSHAKE_FAILURE]` on every model install — the TCP
connection reaches the server fine, but the handshake fails because the
OS trusts the proxy's re-signed root CA and Python's bundled certifi CA
list doesn't. A genuinely different failure mode from #984 (that was
TCP-level unreachability to a blocked host, before any TLS negotiation).
- backend/core/failure.py: new SSL_HANDSHAKE_FAILURE classification
(handshake/cert-verify-failed/sslv3_alert/sslcertverificationerror
substring markers) with an actionable hint, added to
_CONTEXT_FREE_HINT_CLASSES so append_hint() (already called by
setup/download.py's install worker) surfaces it without further wiring.
- backend/main.py: truststore.inject_into_ssl() at module level, before
any huggingface_hub/requests/httpx network I/O — patches ssl.SSLContext
to verify against the OS trust store instead of only certifi's bundled
CA list. Not platform-gated (correctness improvement everywhere);
wrapped in try/except so it never blocks startup.
- pyproject.toml/uv.lock: truststore>=0.9 — pure Python, MIT, PyPA-
maintained, zero transitive deps, same class of fix as socksio.
Verified: uv lock --check + uv sync --frozen clean (lockfile diff is
just the one new package); main.py imports cleanly; full backend suite
passes; no hiddenimports entry needed (main.py is PyInstaller's direct
entry script per backend.spec, so a top-level import traces normally —
unlike socksio's case, which was httpx's internal lazy import).
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
fix(engines): mlx-audio no longer crashes on unsupported languages, error messages never leak raw exception internals (#977) (#993)
Root cause: MLXAudioBackend.generate() blindly truncated the full
language display name to two characters (language[:2].lower()),
assuming an ISO code — 'Dutch' -> 'du', which crashed Kokoro's vendored
pipeline's internal assertion (assert lang_code in LANG_CODES, (lang_code,
LANG_CODES)) for any language whose first two letters didn't coincidentally
match one of Kokoro's single-letter codes. The raw AssertionError's
tuple-containing-a-dict args then leaked straight into the user-facing
500 message via two stacked f"...{e}" formatters in generation.py.
- resolve_kokoro_lang_code() resolves against the AUTHORITATIVE
ALIASES/LANG_CODES table read from the installed mlx_audio package
(never a hardcoded guess), and only applies when Kokoro is the actual
active curated model — other curated models (CSM, Dia, Qwen3-TTS,
OuteTTS, ...) either ignore the kwarg or expect a different format, so
Kokoro's strict validation doesn't wrongly reject them. Unsupported
languages now raise a clear ValueError naming what Kokoro supports,
which generation.py already converts to a clean 400.
- _safe_exc_text() hardens both generic exception formatters in
generation.py: if any element of an exception's .args is a container
(dict/list/tuple/set), never interpolate str(e) raw — name the
exception type and point at the log instead. Protects every current
and future engine's generate() from leaking a raw container repr, not
just this one Kokoro assertion.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
fix(engines): nemo-parakeet install hint stops recommending a shared-venv-breaking pip install (#974) (#991)
The Engines page told users to run `pip install nemo_toolkit[asr]` for
the NeMo Parakeet ASR engine. nemo_toolkit[asr]==2.7.3 hard-pins
transformers>=4.57,<4.58, which is unsatisfiable alongside OmniVoice's
own transformers>=5.3 requirement (needed by
omnivoice/models/omnivoice.py for HiggsAudioV2TokenizerModel). A user
who followed the hint ended up with a backend that wouldn't start
(ImportError: cannot import name 'HiggsAudioV2TokenizerModel').
_INSTALL_HINTS["nemo-parakeet"] in backend/services/asr_backend.py now
states plainly that installing into the shared venv will break the
backend, names the transformers conflict, and tells users to use a
separate/dedicated Python environment instead — without implying a
safe one-line fix or an isolated-venv env var exists (unlike
dots-tts/moss-tts-v15/confucius4-tts, nemo-parakeet has no isolated
venv option yet; that's a separate, larger follow-up).
Also adds one sentence to docs/install/troubleshooting.md's existing
"engine venv clash" section (#11) pointing at the same class of issue
on the ASR side, and a regression test asserting the hint never again
contains the literal bare `pip install nemo_toolkit[asr]` string.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
docs(linux): stop advertising a .deb package that isn't published (#990)
README's Quickstart badges linked a 'Download Debian .deb' button
straight to the releases page — but .deb bundling was deliberately
dropped from release.yml (tauri-cli bug, 'Failed to create control
scripts') and no release has ever shipped one. A community member
investigating #961 confirmed this by checking the actual release
assets. Users clicking that badge got a broken promise, not a package.
Removed the badge; docs/install/linux.md's '## Install (.deb)' section
now honestly states it's unavailable pending a tauri-cli fix, points to
the AppImage as the supported path, and keeps the historical pre-v0.3
.deb upgrade note (ffprobe conflict) since that's still relevant to
existing installs.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
test(voice-design): lock in RTL/non-Latin script handling for the instruct field (#980) (#989)
Issue #980 reported a raw 400 for Hebrew text ('שמואל') typed into the
Clone tab's Style field. Investigated: this is the same failure class
as #612 (Vietnamese free-text) and was already fixed when #612 landed
in commit 10b9d69 (first released v0.3.8) — buildDesignInstruct() drops
any unsupported free-text client-side before it ever reaches the
backend's validator, regardless of script. The reporter was on v0.3.7,
which predates that fix.
No behavior change needed — only a regression test, since the existing
Vietnamese test case covered Latin-script-with-diacritics but nothing
exercised a right-to-left / non-Latin script specifically.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
fix(install): AMD ROCm torch reinstall targets rocm6.4, not rocm6.2 (#988)
Community-diagnosed (issue #972, Kaihui-AMD): pyproject.toml pins
torch==2.8.0, but the rocm6.2 wheel index only ever published up to
2.5.1 — the reinstall silently failed to resolve and fell back to the
default CUDA build, which runs on CPU on an AMD GPU. The failure was
correctly logged (bootstrap.rs's emit_log warning), just never actioned
because the index itself couldn't succeed. rocm6.4 carries a matching
torch==2.8.0 build.
Docs updated with the corrected index plus a repo.radeon.com find-links
path for users who want a driver-matched ROCm 7.2.x build the PyTorch
index doesn't carry (OMNIVOICE_TORCH_INDEX only accepts a PEP 503 index,
not find-links, so that's documented as a manual step).
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
fix(engines): dub and batch TTS honor the active-engine selection (#987)
* fix(engines): dub and batch TTS honor the active-engine selection — with a real capability gate, not a silent OmniVoice fallback
Dub generation and batch TTS hardcoded services.model_manager.get_model()
(OmniVoice) regardless of the engine picked in Settings → Engines. A user
selecting VoxCPM2 (or any other engine) still got OmniVoice output with no
error — the silent fallback IS the bug class, not just the one report.
Root-caused and fixed for the whole class:
- New `TTSBackend.supports_cloning` capability flag (default True) marks
engines that can only offer fixed preset voices — kittentts,
supertonic3, sherpa-onnx set it False. MLXAudioBackend exposes it as an
instance property (Kokoro doesn't clone, CSM does) since the adapter
multiplexes multiple models with different capabilities.
- `cloning_capable_engine_ids()` and a shared `resolve_generation_backend()`
helper in services/tts_backend.py centralize engine resolution
(id → is_available() → routing gate → optional cloning gate), mirroring
generation.py's /generate resolution instead of inventing a third
parallel mechanism. Both routers now standardize on the existing
get_active_tts_backend() cache (unload-on-switch already handled).
- dub_generate.py's two TTS-generate call sites (main run + OOM retry) and
the /dub/preview-segment route resolve once, up front, with
require_cloning=True — dub's ref_audio is populated for essentially
every real job, so an engine that can't clone fails the whole job with
one actionable message instead of mis-cloning per segment.
- batch.py resolves once per job, require_cloning only when voice_id is
pinned — an unpinned batch job runs fine on any engine.
- Applied the three pre-existing TODO(#312) comments: mastering now skips
via `applies_own_mastering` for both pipelines, matching generation.py.
Regression tests cover the capability-id list, the fail-fast gate (proving
no OmniVoice fallback), the success path on a selected non-OmniVoice
engine, batch's pinned-vs-unpinned voice_id behavior, and the mastering
skip for both pipelines. Three existing dub tests that mocked get_model()
directly were updated to mock the new resolver instead.
* fix(engines): exclude model-dependent adapters from cloning_capable_engine_ids()
getattr(cls, "supports_cloning", True) at the CLASS level returns a
property descriptor object (always truthy) when the flag is an instance
@property, not a plain attribute — MLXAudioBackend uses exactly this
pattern because its cloning capability depends on which of its 7+ curated
models is loaded (only CSM clones; Kokoro etc. don't). Without this fix,
the dub/batch capability-gate error message would always recommend
'switch to mlx-audio' even when the user's configured MLX model can't
clone, sending them in a circle back to the same error.
isinstance(value, bool) distinguishes a resolved boolean from a
descriptor object, so mlx-audio is excluded from the suggestion list
until its actual per-instance capability can be checked (already handled
correctly by resolve_generation_backend()'s per-call instance check).
* docs(changelog): engine-aware dub/batch entry (#987)
---------
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
fix(ui): loaded-models panel says when a resident model is not the active engine (#985)
* fix(ui): loaded-models panel says when a resident model is not the active engine (#TBD)
A switched-away TTS model stays resident in VRAM until Unload/Flush or the
idle evictor, so users who picked another engine (e.g. voxcpm2) saw
"OmniVoice TTS - cuda:0 - 1937 MB" in the LOADED MODELS flyout and concluded
synthesis was still routing to OmniVoice. It wasn't - the panel just gave no
hint that resident != active.
/model/loaded entries for TTS-family models (in-process OmniVoice +
subprocess sidecars) now carry engine_id + is_active_engine, computed against
active_backend_id(); attribution failure degrades to the old shape
(is_active_engine: null) and non-TTS entries (ASR, diarization) are left
unannotated. The flyout renders a muted "not active - safe to unload" tag
(i18n: header.model_not_active, en + zh-CN) on inactive entries; Unload/Flush
behavior is unchanged. Regression tests cover both attribution states, the
ASR non-label, and the degradation path.
* docs(changelog): loaded-models active-engine hint entry (#985)
* test(mm2): string-target monkeypatch for active_backend_id — immune to sys.modules reimports
The two attribution tests patched the collection-time module alias; other
suites pop+reimport services.* modules mid-run, so in full-suite order the
patch landed on a stale module object while _active_tts_id late-imported the
fresh one (CI-only failure). String targets resolve at patch time.
---------
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
fix(audio): remove hidden reverb from the mastering pre-stage — reverb is preset-declared only (#986)
* fix(audio): remove hidden reverb from the mastering pre-stage — reverb is preset-declared only (#TBD)
Field report (Discord): baked-in echo/reverb on some voices. apply_mastering()
hardcoded a Reverb that ran on every non-raw synthesis before the user's
preset chain — broadcast shipped reverb it never declared, podcast broke its
"no reverb" promise, cinematic/warm got doubled reverb.
The mastering pre-stage is now data-driven (MASTERING_CHAIN: highpass +
compressor, same params as before) and reverb-free; cinematic/warm keep their
user-chosen reverb. Regression tests pin the contract, incl. a burst-then-
silence echo-tail check and pedalboard-missing passthrough.
* docs(changelog): hidden mastering reverb entry (#986)
---------
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
fix(setup): first-run network check is mirror-aware and never hard-blocks (#984)
* fix(setup): first-run network check is mirror-aware and never hard-blocks
Field report (Discord, China): the Launchpad preflight probed hardcoded
huggingface.co:443 and any failure disabled Continue outright — users behind
the GFW were stuck on the very first screen, before Settings (and its
HF mirror quick-pick) was even reachable.
- The probe now targets the HF endpoint actually in effect (HF_ENDPOINT /
hf_endpoint pref via configured_hf_mirror), with the real port.
- An unreachable endpoint is a WARNING, not a blocker: local-first — cached
models work offline, and downloads surface their own actionable errors.
- When huggingface.co is blocked but hf-mirror.com answers, the fix text says
exactly that, and the wizard shows an inline mirror quick-pick (presets +
custom URL) that applies via PUT /hf-mirror — effective immediately for
downloads — then re-checks.
- Docs updated (downloading-models, install troubleshooting); regression
tests cover warn-not-fail, mirror-host probing, and the mirror suggestion.
* docs(changelog): open [Unreleased] with the preflight mirror fix (#984)
---------
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
release: freeze v0.3.11 — version bump, lockfiles, changelog (#970)
package.json + three mirrors -> 0.3.11 in lockstep; Cargo.lock/uv.lock/
bun.lock regenerated; CHANGELOG [Unreleased] -> [0.3.11] — 2026-07-05
with the multi-language-release headline; nine entries since v0.3.10.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
feat(diagnostics): backend crashes become self-documenting — exit code + stderr tail surfaced and attached to bug reports (#969)
* feat(diagnostics): backend crashes become self-documenting — exit code + stderr tail surfaced and attached to bug reports
When the backend PROCESS died (native CUDA abort, OOM kill, DLL crash) the
user saw only "Can't reach the local OmniVoice backend" and the evidence
died with the process — every #941-class report needed a logs-please
round-trip nobody answers. The v0.3.9 guard fixed HANGS; this fixes the
class of invisible DEATHS:
- Rust (crash.rs): every unexpected child exit — detected by the startup
health poll and the post-Ready supervisor — writes a rotating (last 3)
JSON crash marker next to the backend logs: ts, exit code/signal,
backend version, uptime, ~40-line stderr tail. Intentional shutdowns
never forensicate: app-quit raises the quitting flag first (now also on
macOS Cmd+Q via ExitRequested), and retry/clean-retry kills set a
BACKEND_KILL_INTENDED flag cleared when the fresh child is tracked.
- Tauri commands get_last_backend_crash / acknowledge_backend_crash;
ack is a persisted watermark, never a delete — bug reports still get
the evidence after the user viewed it.
- Crash-loop escalation: the supervisor budget goes 5-in-60s → 3-in-10min
so slow crash loops stop respawning and land on the Failed screen with
the last exit code + stderr tail.
- Frontend: apiFetch's transport-failure path swaps the vague message for
"the backend crashed (exit code X) N s ago…" when an unacknowledged
marker exists, and BackendCrashNotice (banner + details dialog,
i18n'd, ack-on-view) surfaces it even with no request in flight.
- Bug-report prefill gains a "Last backend crash" section (exit code +
home-path-scrubbed stderr tail via the existing scrubText), so the next
report arrives WITH the evidence.
Tests: cargo --lib 57 pass (marker rotation write-4-keep-3, ack
semantics, store IO, ExitStatus decomposition, 3-in-10min policy);
vitest 909 pass incl. crash-notice branch, client crash-message branch,
bug-report enrichment; legacy node:test 41 pass.
* docs(changelog): add backend crash forensics under [Unreleased] (#969)
---------
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
fix(ui): timeline box colors pre-blended in JS — visible on any WebView2 (#963) (#968)
* fix(ui): timeline box colors pre-blended in JS — visible on any WebView2, color-mix dependency removed (#963)
#951 moved the segment-box palette to `color-mix(in srgb, tint 45%,
var(--chrome-bg))` strings applied as inline styles. WebView2/Chromium
< 111 has no color-mix, so the CSSOM rejects the whole `background`
assignment — and since .seg-track__box declares no background of its
own, the boxes rendered fully transparent on pinned/enterprise WebView2
runtimes (the Windows installer never enforces a minimum runtime).
Fix the class, not the instance: no engine-dependent CSS may reach this
lane's inline styles. The 0.45·tint + 0.55·bg blend now happens in JS —
timeline.js keeps the tints as numeric [r,g,b], reads --chrome-bg off
the document root (fallback #0f1011), and emits literal `rgb(r, g, b)`
strings every engine parses. Pixel-identical to what color-mix painted.
Theme-awareness is preserved by re-blending when [data-theme] changes
on <html> (the seam App.jsx switches themes through), observed via
MutationObserver; SegmentTrack subscribes with useSyncExternalStore so
mounted boxes recolor live.
Guards updated: palette entries must match plain opaque rgb() (no
color-mix/var()/alpha), the default-theme blend is asserted against
independently computed literals, theme-change re-blend and rgb()/
garbage --chrome-bg parsing are covered, and SegmentTrack's rendered
inline background is asserted to be a literal rgb() — fails on any
reintroduction of engine-dependent CSS in this lane.
* docs(changelog): add WebView2 box-color fix under [Unreleased] (#968)
---------
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
fix(dub): dialogue starts stop snapping to footsteps — sustained-energy onsets, bounded snap (#963) (#967)
* fix(dub): dialogue starts stop snapping to footsteps — sustained-energy onsets, bounded snap distance (#963)
Field report #963 (point 3): dubbed speakers start seconds early or late.
The reporter's own theory was right on the money — 'when a noise is heard
(a sigh or footsteps), it's interpreted as the start of the conversation.'
The #280 onset snapper took the FIRST 20 ms frame above an adaptive RMS
threshold as the speech onset, so any transient qualified; it also had no
snap-distance bound (a wrong onset could move a start by the whole segment
minus 0.3 s) and ran even when Demucs had failed and the 'vocals' track was
really the raw mix, where every ambient sound is a candidate.
Three layered guards, all pure NumPy (no new deps):
- Sustained energy: an onset only counts when >=160 ms of the following
300 ms stays above the threshold. Footsteps/door thuds light up one or
two frames and die; syllables keep the energy up.
- Bounded snap distance: shifts beyond 1.5 s are only trusted when the
skipped span is (near-)silent — that is exactly the genuine #280
whisper start-stretch on the vocals track (Demucs removed the music,
leaving real silence), so long trims over silence still work in full.
Long jumps over audible content (e.g. quiet speech under the relative
threshold) are refused instead of playing the dub seconds late; an
isolated transient in the span (<10% audible frames) doesn't block it.
- Source-aware: snapping now runs only on the separated vocals track.
dub_core detects the Demucs fallback (vocals_path == audio_path, see
dub_pipeline) at both call sites and passes separated_vocals=False on
mixed audio, disabling snapping — whisper's own timestamps beat a
confidently wrong snap when music/ambience is sustained energy too.
Tests (tests/test_onset_align.py, fail-before/pass-after): transient burst
rejected at detect- and snap-level, transient-only window yields no onset,
long jump over audible content refused, bounded shift over audible lead
still allowed, >1.5 s trim over true silence still snaps (#280 regression
guard), mixed-audio mode is a no-op. 28 pass in the file; full dub-adjacent
suites green.
Credit: theory and repro description by the #963 reporter.
* docs(changelog): add onset-snap robustness under [Unreleased] (#967)
---------
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
fix(net): SOCKS-proxy users can synthesize again — ship socksio, cache-first model resolution (#959) (#966)
* fix(net): SOCKS-proxy users can synthesize again — ship socksio, cache-first model resolution, degrade LLM clients (#959)
Under ALL_PROXY/HTTPS_PROXY=socks5:// without socksio installed, httpx
raises ImportError AT CLIENT CONSTRUCTION ("Using SOCKS proxy, but the
'socksio' package is not installed"). huggingface_hub's get_session()
builds exactly that client inside snapshot_download, so POST /generate
500'd with the bare message even for a fully installed model, and
preload_model's model_info probe hit the same error and silently
skipped warm-up. Latent since v0.3.5 — #947's fresh-process engine
spawning unmasked it in v0.3.10 by handing the user's proxy env
directly to a clean backend process.
Three layers, so the class (any session-construction failure) is dead,
not just the reported instance:
* Ship SOCKS support: socksio>=1.0 in [project] dependencies (pure
Python, MIT, zero transitive deps) AND in backend.spec hiddenimports
— httpx imports it lazily in try/except, so PyInstaller's tracer
misses it and the frozen installers would stay broken without the
explicit entry. uv.lock regenerated; `uv lock --check` and
`uv sync --frozen` (the Docker/release bootstrap semantics) verified.
* Cache-first model resolution: from_pretrained's snapshot resolution
extracted into _resolve_snapshot_dir() — local dir, else
snapshot_download(local_files_only=True) (a complete cache resolves
with NO HTTP session constructed), else the original network path.
preload_model's failed network probe now falls back to a cache-only
check and warms up anyway instead of silently skipping (honest log
either way).
* Class guards: resolve_skill_client wraps OpenAI() construction —
env-shaped construction failures degrade to the existing "LLM
unavailable" contract instead of 500ing the calling feature; and
core.failure learns SOCKS_PROXY_SUPPORT_MISSING with an actionable
hint, appended on the raw-string surfaces (global 500 handler,
model-install SSE) via the new append_hint().
Fail-before/pass-after verified by reverting the fix: 11 of the 12 new
tests fail pre-fix (the remaining one is the unchanged network-fallback
contract). 165 tests green across the touched suites.
* docs(changelog): add SOCKS-proxy resilience under [Unreleased] (#966)
---------
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
fix(settings): a saved LLM provider survives restart — explicit save activates, stale TRANSLATE_* prefs stop hijacking (#963) (#965)
* fix(settings): a saved LLM provider survives restart — explicit save activates, stale TRANSLATE_* prefs stop hijacking (#963)
"Ollama works until I restart OmniVoice" had three stacked causes:
1. Only "Save & use for translation" persisted the selection. Plain
"Save" and "Test" sent make_active:false, and on restart
active_provider_id() deliberately excludes local providers
(Ollama/LM Studio) from auto-select — so a saved-and-tested Ollama
was never resolved active again. The PUT handler now also claims the
active slot on an explicit save when the user has never chosen a
provider (new llm_providers.stored_active_provider_id(): the stored
row only — no env pin, no legacy fallback, no auto-detect). An
explicit prior choice is never stolen; an unconfigured provider
can't claim the slot; make_active:true still flips.
2. Users of the retired (≤v0.3.7) Translation-LLM panel had
env.TRANSLATE_* rows in prefs.json, re-imported into os.environ
every launch — and a live TRANSLATE_BASE_URL resolves the active
provider to "custom" ahead of auto-select on every restart. New
startup migration (llm_providers.migrate_legacy_translate_prefs,
run in main.py BEFORE the prefs→env import) moves those values into
the custom provider's own settings-store rows (only where the store
has no value yet) and deletes the prefs rows. Real process env vars
are never touched; a failed store write keeps the prefs row and
retries next boot. The legacy endpoint keeps working — via the
store, without hijacking the active slot.
3. The panel read as "done" after a green Test even when another
provider stayed active. It now shows a notice after save/Test when
the edited provider is not the effective active one (suppressed
while LLM_DEFAULT_PROVIDER pins the choice — the env banner already
covers that).
Tests (fail-before): 7 new backend tests fail on the old code
(save-activates, never-steals, migration semantics, env untouched,
end-to-end ollama-beats-legacy-env) and the new panel test fails
without the notice; all pass after. Full LLM/settings suites, frontend
vitest (890), typecheck:ci, oxlint, oxfmt and vite build are green.
* docs(changelog): add LLM-provider persistence fix under [Unreleased] (#965)
---------
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
docs+scripts: install fixes — desktop-prod tauri resolution, Ubuntu white-screen guidance, honest GPU/prereq docs (#960#961#962) (#964)
* docs+scripts: install fixes — desktop-prod tauri resolution, Ubuntu white-screen guidance, honest GPU/prereq docs (#960#961#962)
* docs(changelog): add the install-fixes batch under [Unreleased] (#964)
---------
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
feat(dub): per-language translations + per-track caches — switching languages stops destroying work (P1) (#958)
* feat(dub): per-language translations + per-track caches — switching languages stops destroying work (P1)
Multi-language dubbing translated per language (#957) but stored everything
in single-slot state, so tracks silently destroyed each other's work:
P1.2 — per-language translation storage (additive):
- Frontend keeps every translation in s.translations[langCode] alongside the
legacy s.text slot (still = the shown language). Translate All writes both;
the new store action switchDubLangCode swaps text through the map on a
user-driven language switch (non-destructive; restore paths keep the plain
setter); manual edits / restore-original update the current language's
entry; merge joins per-language texts, split drops them. Rides project
save/load inside dubSegments — legacy projects behave exactly as before.
- Backend mirrors it as job["segments_i18n"] = {lang: {segKey: text}}
(segKey = stable id, index for id-less legacy rows), written by
_sync_job_segments; job["segments"] stays byte-identical for every existing
consumer. /dub/srt|vtt?lang= and subtitle burn-in now emit THAT language's
text when present — ExportModal's "all dubs" batch stops producing N
identical files. Legacy jobs without the field fall back to today's output.
P1.3 — per-track WAV cache + fingerprints:
- Per-segment WAVs are language-keyed (seg_{lang}_{id}.wav). The partial-regen
read path falls back to legacy seg_{id}.wav ONLY while the job has no
other-language track — single-language jobs keep their whole on-disk cache;
multi-track jobs stop splicing the last-generated language into the current
track. Read-only endpoints (segment preview, clips zip) gained ?lang= with
the permissive legacy fallback they always had.
- Fingerprints include the track language (segment_fingerprint(track_lang=…),
/tools/incremental lang=…) and live in job["seg_hashes_by_lang"]; the flat
job["seg_hashes"] stays as the current track's mirror so the done event,
history restore and older frontends read it unchanged. A legacy flat map is
attributed to the job's last-generated language (dropped when unknown) and
reads stale once — the safe direction. seg_wav_kind is per-track too.
- The frontend stores fingerprints per language and judges "Regen N changed"
against the ACTIVE track; project save/load and dub-history restore carry
all tracks' hashes (segHashesByLang / seg_hashes_by_lang, additive).
Tests: fail-before regression coverage — two-track regen never splices the
other language's audio (sample-level assert on the mixed track), legacy
single-track cache reuse + multi-track gate, per-lang seg_hashes with flat
mirror + migration semantics, /dub/srt|vtt?lang= emitting different text per
track with legacy fallbacks, per-lang burn-in, /tools/incremental lang
scoping, and 14 frontend tests for translations round-trips, per-track
fingerprints and legacy-project behaviour. Full backend + frontend suites,
typecheck, lint and format:check green.
* docs(changelog): add per-language storage + per-track caches under [Unreleased] (#958)
---------
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
feat(dub): multi-language generate translates each language first + picks persist (P1) (#957)
* feat(dub): multi-language generate translates each language first + picks persist with the project
P1.1 — the "Generate N dubs" loop never translated: the backend synthesizes
segment text verbatim, so every multi-language track rendered the same words
and at most one was actually in its language. The loop now runs
translate → generate per pick:
- handleTranslateAll(langOverride?) accepts an optional ISO-code override
(no-arg Translate All behavior unchanged; a click-event first arg is
guarded). It resolves true only when a translation actually landed, and
both it and handleDubGenerate snapshot segments from the store at call
time — the click-time closures went stale the moment the previous pick's
translate pass rewrote the segments.
- A pick whose translate fails (request error or all-segments-errored) is
SKIPPED — never a wrong-language track — the batch continues, and the
skipped languages are reported in a final toast.
- The redundant first translate is skipped only when pick 1 targets the
language the editor text is already translated into; every later pick
always translates.
- Honest progress: the pill shows "Translating → {lang} (i/N)…" before each
generate, and the header CTA is inert while translating so a re-click
can't start a second batch (belt: a ref guard in the loop).
P1.4 — multiLangMode/multiLangs move from DubTab component state into the
dub store slice and ride the project save/load payload (exportTracks too).
Additive and back-compat: legacy payloads default to off/empty and leave the
in-session exportTracks untouched (utils/projectState.js).
Tests (fail-before verified: 9 failures on the pre-fix code):
- handleTranslateAll override targets + return semantics + call-time
segment snapshot (dubTranslateAllOverride.test.jsx)
- per-language translate-before-generate call order, skip-on-failure with
continuation + skip-report toast, first-pick skip heuristic, unchanged
single-language path (dubMultiLangGenerate.test.jsx)
- slice defaults/setters/reset, payload round-trip, legacy-payload defaults,
App.jsx wiring guards (dubMultiLangPersist.test.js)
* docs(changelog): add multi-lang auto-translate under [Unreleased] (#957)
---------
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
fix(ci): oxfmt the two #956 test files (unbreak main format check)
#956 merged with a red Tests gate — my merge script ran unconditionally
instead of aborting on the gate value; the failure was oxfmt-only on the
two new test files. Whitespace-only fix, tests re-verified green.
fix(dub): completed tracks always show their tabs + history keeps its language (P0) (#956)
* fix(dub): completed tracks always show their tabs + history keeps its language (P0)
Root cause chain: the track switcher's visibility expression required
dubLangCode !== 'und' and ended in a tautology (dubTracks?.length > 0 ||
!!dubTracks), so it was effectively keyed to the language dropdown, not
the persisted tracks. History restore always handed the frontend 'und'
because the dub_history language/language_code COLUMNS froze at the
ingest-time "" — the save_job UPSERT never updated them after generation
set them on the job dict (only the job_data JSON carried the real value).
Net effect: a restored project with finished tracks showed no track tabs
until the user re-picked a language.
- DubTab: hasDubbedTrack = done && dubTracks.length > 0 (tracks only;
also stops the tautology from showing a trackless switcher).
- DubTab auto-jump: membership-guarded — the preview only jumps to a
language that has a track, else tracks[0]. Kills the preview-404 class
(restores falling back to 'en' with tracks ['bn'] pointed the player
at /dub/preview-video?lang=en).
- dub_pipeline.save_job UPSERT: language/language_code now update when
non-empty (same CASE guard as content_hash), so new saves heal the
frozen columns and empty re-saves can't clobber them back.
- App.restoreDubHistory: falls back to job_data's language/language_code
so EXISTING rows in users' DBs restore correctly with no migration.
- P0.2 polish: track pills get duration + timing-strategy tooltips,
hydrated lazily and failure-silently from the existing
GET /dub/tracks/{job_id} via new api/dub.dubListTracks; all new
strings through i18n (en.json).
Tests (fail-before/pass-after): DubTab-level visibility + auto-jump
membership-guard tests (3 of 4 fail pre-fix), pill-tooltip hydration
tests, and save_job language heal/no-clobber tests (heal fails pre-fix).
* docs(changelog): open [Unreleased] with the dub track-tabs fix (#956)
---------
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
feat(dub): project title first in the editor header, pipeline stages below (#955)
Header reordered per owner: row 1 = title (+ duration/segments) with the
action buttons, row 2 = the Upload→Export pipeline spine directly beneath
with a tight 2px gap (was: stepper and title side-by-side on one row).
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
release: freeze v0.3.10 — version bump, lockfiles, changelog (#954)
package.json (source of truth) + the three mirrors -> 0.3.10, in lockstep;
Cargo.lock/uv.lock/bun.lock regenerated (one line each; bun --frozen-lockfile
verified). CHANGELOG [Unreleased] -> [0.3.10] — 2026-07-05 with the release
headline; nine fixes since v0.3.9, mostly same-day field-report turnarounds.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
fix(audiobook): chapter render no longer crashes on mixed 1-D/2-D audio chunks (#897) (#953)
* fix(audiobook): chapter render no longer crashes on mixed 1-D/2-D audio chunks (#897)
Root cause: synthesize_chapter (backend/services/audiobook.py) built
inter-span pause silence as bare 1-D torch.zeros(n) while every real
engine's synth returns (1, samples) per the TTSBackend.generate contract
(OmniVoice's model.generate(...)[0] included) — so the chapter's final
hard concat in chunked_tts.concatenate_audio_chunks hit
torch.cat with mixed ranks and died with
'RuntimeError: Tensors must have same number of dimensions: got 1 and 2'.
Any chapter containing a [pause] span (Stories/audiobook longform)
crashed; existing tests missed it because their stub synth returned 1-D.
The crossfade branch had the same latent bug for mixed-rank chunks.
Fix, both layers:
- concatenate_audio_chunks now normalizes chunk shapes before any cat
(_normalize_chunk_shapes): lower-rank chunks gain leading singleton
dims to the highest rank present, then singleton channel dims
broadcast to the widest channel count (mono follows stereo). Covers
both the hard-cut and crossfade branches; homogeneous input passes
through untouched, so all-1-D / all-2-D callers keep their exact
output shapes. No future backend's output rank can re-break the join.
- synthesize_chapter materializes silence AFTER the loop, matching the
rendered audio's channel dims / dtype / device — the same pattern
generation.py's _render_with_pauses already uses for the single-shot
path — so the data is rank-consistent at the source too. A
silence-only chapter stays 1-D float32 as before.
Regression tests: mixed-rank hard-cut (both orders), mixed-rank
crossfade, mono->stereo broadcast, all-1-D/all-2-D shape stability, a
2-D-engine + [pause] chapter through synthesize_chapter (the exact #897
scenario), and a spy asserting the parts reaching the concat are
rank-homogeneous. All fail before the fix with the reported error.
* docs(changelog): add the audiobook pause-span concat fix under [Unreleased] (#953)
---------
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
fix(dub): the speaker-count hint is honored on every diarization path + clone-purity guard (#952)
* fix(dub): the speaker-count hint is honored on every diarization path + clone-purity guard
The dub "Speakers" count reached _diarize() and then died on 3 of its 4
branches, so setting it changed nothing, speakers blended, and auto-clones
were cut from mixed-speaker audio ("made up" voices):
- FunASR inline-turns shortcut returned before the hint was ever consulted
→ now an explicit num_speakers routes the job through pyannote (the one
engine that honors an exact count); turns stay the fast path only when no
hint is set, and remain the fallback (with an honest "hint ignored"
warning) when pyannote can't load or crashes mid-run.
- pyannote-unavailable fallback used a hardcoded 2-speaker silence-gap
heuristic → assign_speakers_heuristic now takes num_speakers and cycles N
labels on gap boundaries (1 → single speaker; None → legacy alternation),
and the existing diarization warning says the hint is only approximately
honored.
- pyannote-crash fallback dropped the hint the same way → same treatment.
No branch drops the hint silently anymore: every degraded path extends the
existing `warning` SSE payload (detail + a machine-readable speaker_hint
field) that the frontend already renders.
Parity + purity:
- POST /dub/transcribe/{job_id} (the CLI's endpoint) gains the same clamped
num_speakers query param, forwarded to pyannote and the heuristic; the
omnivoice-dub CLI gains --speakers N.
- Clone-purity guard: _pick_reference_slices rejects sub-1.5s slices, prefers
slices not temporally adjacent (<0.3s) to another speaker's turn (scoring
preference, not a hard filter), and extract_speaker_clones skips extraction
entirely when labels came from the heuristic (labels_source kwarg threaded
from _diarize; missing kwarg keeps the old behavior) — with a user-facing
warning pointing at Settings → Models → pyannote.
Tests: fail-before/pass-after coverage in tests/test_speaker_hint.py (all
four _diarize branches driven through the real SSE stream), clone-purity
guards in tests/test_speaker_clone_purity.py, heuristic hint semantics in
tests/test_segmentation.py.
* docs(changelog): add the speaker-hint + clone-purity fix under [Unreleased] (#952)
---------
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
fix(ui): dub timeline boxes can no longer flash invisible during playback (#373 class, completes #381) (#951)
* fix(ui): dub timeline boxes can no longer flash invisible during playback (#373 class, completes #381)
Root cause: the segment lane animated an inline
`transform: translateX(-scrollLeft)` on every playback tick, so Chromium
promoted it to a compositor layer — and on some Windows GPU/WebView2
driver combos, composited semi-transparent paints (the 0.45-alpha box
fills) flash invisible/visible while the layer moves, settling only when
paused. PR #381 removed `will-change` and raised the alpha, which only
dampened the symptom; the animated transform kept the lane composited.
Fix the class — no composited translucent paints on the lane, ever:
- Position boxes in pure layout: the lane transform is gone; each box's
`left` is start·pxPerSec − scrollLeft (viewport coordinates). The
virtualization window already derives from the same scrollLeft, so
windowing stays consistent by construction. The selfScroll WebKit
fallback keeps lane coordinates (its viewport is a real scroll
container), unchanged.
- Belt-and-braces: REGION_COLORS are now fully opaque — each entry
pre-blends the old 45% tint against the surface behind the lane
(`--chrome-bg`, the .studio-panel background) via color-mix, which is
pixel-identical to the previous alpha compositing (0.45·tint + 0.55·bg)
in every theme, with zero alpha.
Regression tests (fail on pre-fix code): lane carries no transform at
rest and after a scroll update, box lefts are viewport-relative for a
scrolled view, no double-shift in the selfScroll fallback, and every
REGION_COLORS entry is alpha-free with the 45% ratio preserved.
* docs(changelog): add the timeline-box compositor fix under [Unreleased] (#951)
---------
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
fix(translate): Cinematic/Autofit can no longer invent dialogue — divergence guard + pinned temperature (#950)
Root cause (v0.3.9 field report): the refine paths had no-op output guards.
Cinematic's ADAPT step only checked _looks_like_target_script, which returns
True unconditionally for every Latin-script target (no _SCRIPT_RANGES entry)
— so any non-empty LLM reply (hallucinated dialogue, refusals, commentary,
or the REFLECT critique itself) shipped as the dub line. Autofit's
adjust_for_slot accepted ANY non-empty reply, and its best-candidate tracker
(closest rate_ratio to 1.0) actively selected the most-padded output, while
_EXPAND_PROMPT invited invention with no ceiling. Both call paths also ran
at the provider-default temperature 1.0, unlike the working Fast path which
pins 0.2.
The fix, class-level:
- Shared divergence guard translator.refine_output_ok (length window
0.4–2.5x, env-tunable via OMNIVOICE_REFINE_RATIO_MIN/MAX, with an
absolute cap for short references; target-script check; critique-echo
detection). Rejected ADAPT output degrades to the literal with
error="adapt-diverged" (wrong-script keeps its adapt-wrong-script:<lang>
marker), riding the existing degradation machinery unchanged.
- Autofit validates every reply against the ORIGINAL input text (divergence
compounds across attempts otherwise); rejected candidates are discarded
(attempt burned, graceful degradation to the input preserved) with
error="fit-diverged"; lines under 15% of their slot skip LLM expansion
entirely (fit-skip-short) — they could only "fill" the slot with
fabricated dialogue.
- temperature=0.2 pinned on the cinematic (_chat) and fit (llm.chat) calls;
chat/chat_messages gained an optional temperature param that is only sent
when set, so refinement/director/glossary callers keep provider defaults.
- Prompts hardened: ADAPT forbids introducing facts/names/dialogue not in
the source line; EXPAND forbids inventing information and more than
doubling the line.
- speech_rate strict-mode docstring made honest: strict changes only the
upper tolerance bound; expansion still runs (now guard-bounded).
Fail-before/pass-after regression tests for the reported bugs (10x runaway
ADAPT on an es target, critique echo, hallucinated slot-fill expansion,
refusal replies, tiny-line expansion skip, pinned temperature) plus the
previously-untested wrong-script fallback and legit-output acceptance.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
docs(readme): community table lists the Discord server's real channels (#949)
The table described aspirational channels (#showcase/#help/#feature-requests/#dev)
that don't exist on the server; it now matches reality (#announcements,
#releases+#changelog, #issues and #ideas forums, #discuss-ideas, #general).
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
fix(launcher): replace a stale old-version backend instead of attaching to it (#947)
* fix(launcher): replace a stale old-version backend instead of attaching to it
After an update, an orphaned backend from the PREVIOUS version can survive
holding the port. It still answers /system/info, so both attach paths
(lib.rs launch bootstrap + bootstrap.rs retry) treated it as "already
serving OmniVoice — attaching" and the new UI silently ran OLD backend
code: every fix in the update appeared to change nothing. Reported on
Discord as "a bound port which blocked the newer versions"; the app
already knew how to kill_orphan_on_port on both Unix (lsof) and Windows
(netstat) — it just never applied it to a healthy-but-stale backend.
The attach decision now compares versions: running_backend_version()
reads app_version from /system/info (string-sniff, no new deps), and
same_app_version() compares BASE versions (pre-release -N suffix
stripped, so a preview build 0.3.10-4 still attaches to its 0.3.10
backend). Same version → attach exactly as before. Different or missing
version → the orphan is killed and the bundled backend spawns. Foreign
processes keep the existing port_in_use take-ownership path; the
post-spawn health polls are untouched (we spawned that backend
ourselves).
Rust unit tests cover the /system/info parse shape and the
match/preview/stale/unversioned decisions; 51 pass.
* docs(changelog): add the stale-backend port-reclaim fix under [Unreleased] (#947)
---------
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
docs(readme): charm + organization overhaul (Opal-style) — collapsibles + OpenAI-compatible API section (#945)
* docs(readme): charm + organization overhaul (Opal-style)
* docs(readme): restore inventory-exact feature names (docs-drift guard)
The charm pass sentence-cased five bold leads in the collapsed feature
list; scripts/check-docs-drift.py greps for the inventory's exact
title-case names. Restored: Vocal Isolation, Speaker Diarization,
Batch Queue, AI Watermark, GPU Auto-Detect.
* docs(readme): dubbing screenshot shows a real completed dub (37 segs, EN→BN)
Replaces the empty drop-zone shot with the populated editor — video +
waveform + cast, 37 Bengali segment rows, DUB COMPLETE banner — captured
live from the v0.3.9 app; caption updated to match.
---------
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
fix(ui): relative timestamps no longer render as "20617d ago" (seconds-vs-ms class) (#946)
Backend rows store timestamps as Unix SECONDS (time.time() REAL columns:
generation_history, dub_history, exports, longform jobs, projects), while
frontend-local records carry milliseconds (Date.now() story projects) or ISO
strings (transcriptions). Projects/OmniDrive fed the seconds straight into a
millisecond-based diff (fmtTime), so every generation-history card rendered
as ~1970 ("20617d ago") — and, because the same raw value drove the recency
sort, history items also sank to the bottom of the drive.
Fix the class, not the label: a single shared, unit-tolerant normalizer
(frontend/src/utils/relativeTime.js) now backs every relative-time call site.
- toMillis(ts): numbers < 1e12 are seconds (×1000), >= 1e12 already ms; ISO
and numeric strings parse; Date instances pass through; null/0/undefined/
garbage -> null. Backend storage format is untouched (backward compat).
- timeAgo(ts): "—" for missing stamps (never an epoch age), "just now" for
future stamps within 1 min of clock skew, s/m/h/d buckets, short absolute
date beyond 7 days.
- absoluteTime(ts): unit-safe tooltip text, '' when missing (no more
"Jan 1 1970" titles on null rows).
Converted call sites: pages/Projects.jsx (drop local fmtTime + per-source
*1000 juggling; normalize ts once so sort and label agree), components/
Sidebar.jsx + components/WorkspaceProjects.jsx (drop duplicated local
timeAgo copies and caller-side *1000), pages/BatchQueue.jsx (drop local
formatAge; missing created_at used to render an epoch date), pages/
Transcriptions.jsx + components/TranscriptionPicker.jsx (parse via
toMillis, keep their i18n labels; unparseable stamps no longer render
"Invalid Date").
Tests (fail-before/pass-after): utils/relativeTime.test.js covers seconds/
ms/ISO/numeric-string/Date inputs, null/0 -> "—", clock-skew "just now",
and the 1970 regression (a seconds stamp from today must not render as
thousands of days ago); test/ProjectsRelativeTime.test.jsx guards the
OmniDrive wiring end-to-end (seconds created_at renders "2h ago", null
renders "—", mixed-unit sort orders by real recency). Full frontend suite:
106 files / 843 tests green; oxlint, oxfmt, typecheck:ci, node:test green.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
fix(translate): Dub LLM engine runs on the configured LLM provider (new dub_translation skill) (#944)
* fix(translate): the Dub LLM engine now runs on the configured LLM provider
Picking "LLM (OpenAI-compatible)" in the Dub tab read only the raw
TRANSLATE_* env vars — completely bypassing Settings → LLM Providers, so a
provider the user had configured AND tested in-app silently didn't power
the engine (empty key → raw 401 per segment). The Cinematic refiner was
already rewired through LLM Skills (#910/#912); this closes the gap for
direct LLM translation:
* new "dub_translation" LLM skill (Settings → LLM Skills) — per-skill
provider override → global active provider, same resolution as every
other skill; disabled == unconfigured, no new degradation modes
* the provider=openai branch resolves through resolve_skill_client();
TRANSLATE_BASE_URL/TRANSLATE_API_KEY/TRANSLATE_MODEL stay working as
the power-user override (env-only setups see zero behavior change,
except the stale gpt-3.5-turbo default is now gpt-4o-mini, matching
the cinematic path)
* per-segment calls are now bounded by the LLM timeout (45s default via
OMNIVOICE_LLM_TIMEOUT) instead of the SDK's 600s default
* fully unconfigured → an up-front actionable 400 naming Settings → LLM
Providers / LLM Skills instead of a per-segment 401
* provider-store keys are resolved into the error scrubber so a provider
echoing the key can't leak it (parity with the env-key scrub)
* translation_engines registry: honest notes + a configured/configured_via
stamp on LLM entries so the Engine dropdown can show ready-vs-needs-setup
before the user clicks Translate
Tests: 4 new (skills-resolved client wins with its model+timeout; 400s
name the right settings page for no_provider vs disabled; env fallback
keeps working incl. TRANSLATE_MODEL); skills registry coverage updated;
existing openai-branch tests routed deterministically through the env
branch via the shared fake helper.
* docs(changelog): add the dub-translation provider wiring under [Unreleased] (#944)
---------
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
docs(readme): cross-promote the maker's other local-first projects (Opal, memxt) (#943)
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
docs(readme): use the Launchpad screenshot as the hero image (#942)
Swap the static social-preview banner for the live v0.3.9 Launchpad shot
and drop the now-duplicate Launchpad row from the gallery (shown once).
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
docs(readme): Opal-style restyle + fresh v0.3.9 screenshots (#937)
- Emoji section headers with explicit <a id> anchors. Emoji breaks
GitHub's auto-generated heading slugs, so every in-page nav target keeps
a stable explicit anchor (verified all href="#..." resolve).
- Refresh the screenshot gallery. The prior set was from April, predating
the launchpad / settings / dictation UI overhaul, so it misrepresented
the app. Captured fresh at retina from the live v0.3.9 UI and led the
gallery with the new Launchpad home: launchpad, studio, voice design,
voice gallery, dubbing, engine-compatibility matrix, model store,
embedded API reference (Scalar), and the in-app changelog reader.
- Fix the stale engine count (11 -> 14 TTS engines) in the comparison
table, FAQ, and roadmap to match the engine table + backend registry.
- Use <kbd> keycaps for the dictation shortcut (Opal detail).
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
fix(engines): snapshot lazy registry keys so /engines can't 500 under concurrency (#940)
* fix(engines): snapshot lazy registry keys so /engines can't 500 under concurrency
`list_backends()` runs in a FastAPI threadpool and iterates the lazy TTS/ASR
registries via `items()` → `__iter__`, which held a *live* `dict.__iter__(self)`
open across each engine's slow `is_available()` probe. Meanwhile the lazy
`__getitem__` resolves a deferred entry by mutating the dict (`self[key] = cls`).
A second concurrent `/engines` request (or any ASR op) materializing the lazy
`faster-whisper-isolated` entry therefore changed the dict size mid-iteration:
RuntimeError: dictionary changed size during iteration
asr_backend.py:1729 list_backends → _REGISTRY.items()
asr_backend.py:1665 __iter__ → for k in dict.__iter__(self)
Both `_LazyRegistry` (TTS) and `_LazyASRRegistry` (ASR) now snapshot their live
keys up front with `list(dict.__iter__(self))` — consumed atomically under the
GIL — so a concurrent lazy insert can no longer trip the iteration. The slow
per-engine probes then run over the snapshot, not the live iterator.
Deterministic fail-before/pass-after regression for both registries:
tests/backend/services/test_lazy_registry_concurrency.py.
* docs(changelog): add the /engines concurrency fix under [Unreleased] (#940)
---------
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
fix(dub): classify EINVAL transcribe failures so they stop dead-ending (#763) (#936)
A per-chunk temp-WAV write that fails with OSError EINVAL ("[Errno 22]
Invalid argument") — a missing/read-only/full temp dir, a removed drive,
or antivirus — collapsed into "Transcription produced no segments.
[Errno 22] Invalid argument" with no next step. classify() now names the
class (OS_INVALID_ARGUMENT) so build_failure attaches an actionable
temp-dir/disk/AV hint at the exact surface the streaming dub path already
feeds it (dub_core.py:672) — same treatment the ffmpeg and compute-type
classes get. Fail-before/pass-after regression added; the errno-22 token
keeps it from colliding with the errno-2 transformers-import class.
Also stamps the [0.3.9] CHANGELOG section with today's release date
(2026-07-04) ahead of tagging.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
docs(changelog): add sherpa config-error fix (#919) to [0.3.9] (#935)
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
fix(engines): classify sherpa "model not set" as a config error, gate the engine on its model dir (#919) (#934)
A user selected the sherpa-onnx TTS engine and got a 500 that read "TTS
engine stopped mid-generation. This usually means it ran out of memory.
Try the Flush button…" — when the real cause was a pure setup problem:
"OMNIVOICE_SHERPA_MODEL not set. Point it to a sherpa-onnx TTS model
directory (containing model.onnx + tokens.txt)." Same misclassification
class as #880/#893, which tightened the OOM catch-all on the generation
path — but the engine-not-configured case still fell through to memory.
Two layers, fixing the whole class:
1. Error classification (backend/api/routers/generation.py): a new
`_is_config_failure()` recognizes "required engine model path / env
var not set" over the whole exception chain (OMNIVOICE_* named with
"not set"/"point it to"/"set omnivoice_…", sherpa's "no model.onnx
found in", "not configured", "venv not found. set" for the dedicated-
venv opt-ins). `_oom_friendly_reraise` checks it BEFORE the OOM branch
and re-raises actionable setup guidance that names the variable, points
at Settings → Engines, and never mentions memory or Flush. Generalizes
to sherpa/Confucius4/dots/MOSS and any future env-gated engine.
2. Engine gating (backend/services/tts_backend.py): SherpaOnnxBackend
ships no bundled model, so is_available() now gates on
OMNIVOICE_SHERPA_MODEL (set + contains model.onnx) — like the other
path-configured opt-in engines — returning False with an actionable
reason instead of "ready", so the picker marks it unavailable-with-a-
reason rather than selectable-but-broken. Added the copy-paste setup
snippet for the Compat Matrix. Backward-compatible: a correctly
configured OMNIVOICE_SHERPA_MODEL keeps the engine available.
Tests (fail-before/pass-after): config-classification of the sherpa
"model not set" error and the wider not-configured class (no "out of
memory"/"Flush"); is_available gating on the env var + model.onnx and the
setup-snippet registration.
Fixes#919
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
fix(test): close the reload-induced test-isolation leak at its source (#932 follow-up) (#933)
Two sides of the same class the router-smoke leak (#932) traced to
test_pronunciation_api's importlib.reload teardown:
- test_pronunciation_api now re-runs init_db() on the restored data dir so
the reloaded core.db/main.app is never left on a schema-less DB.
- test_db_migration_safety catches db_module.MigrationError dynamically
instead of the collection-bound name, so a reload that rebinds the class
can't make pytest.raises miss it.
Both orderings (real + reversed) now pass; no product change.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
fix(test): router-smoke tests leak-proof against full-suite order (#932)
tests/test_router_smoke.py showed ~10 `sqlite3.OperationalError: no such
table: jobs` failures in the full suite (and in isolation on a clean data
dir), but passed when a schema-creating module ran first.
Root cause: the `client` fixture builds a bare `TestClient(app)` with no
`with` block, so the FastAPI lifespan never runs — and `init_db()` (the
only place the schema is created) lives in that lifespan (main.py). The
smoke tests therefore free-rode on whatever schema an earlier module left
on the active DB. A module that reloads `core.config`/`core.db` and leaves
`core.db.DB_PATH` pointed at a fresh, schema-less DB (test_pronunciation_api's
`importlib.reload` teardown restores the env var but never re-runs init_db
on the restored data dir) strands router-smoke on a DB with no tables ->
every DB-backed route 500s. Same class as #878 / #917.
Fix (test-only, zero blast radius): the `client` fixture now calls
`core.db.init_db()` against whatever DB is active at run time before serving
requests — the same `init_db()` pattern test_api.py / test_personas_api.py
use. Because it targets the live `core.db.DB_PATH`, it re-creates the schema
regardless of which path any prior module left active, making the suite
self-sufficient and order-independent.
Verify:
- `pytest tests/test_router_smoke.py` alone: 10 failed -> 24 passed
- `pytest tests/test_pronunciation_api.py tests/test_router_smoke.py`
(deterministic reproducer): 10 failed -> 38 passed
- `pytest tests/` full suite: 2215 passed, 20 skipped, 10 xfailed,
4 xpassed, 0 failed / 0 errors
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
docs: backfill [0.3.9] batch bullets + add OSS sponsorship playbook (#931)
Bullets for #922 (release titles), #923+#924 (sponsors), #925 (contact),
#927 (models), #928 (openapi), #930 (engines) — the agents kept off
CHANGELOG.md during the merge chain. Plus a portable how-we-set-up-
sponsorship playbook for reuse on other projects.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
feat(settings): OpenAPI reference page — embedded Scalar (bundled, CDN-free) + footer button (#928)
Add a Settings → OpenAPI page that renders an interactive Scalar API
reference for OmniVoice's own local backend, plus a compact footer button
that opens it.
- New OpenApiPanel fetches the live spec from the resolved backend base
(getApiBase()+"/openapi.json", via apiFetch so it follows remote-backend /
LAN-share overrides), owns loading + unreachable-backend fallback (with
Retry), and hands the parsed spec inline to Scalar.
- Scalar is bundled via the @scalar/api-reference-react npm package — NO CDN
script tag. It is lazy-loaded (ScalarApiReference.jsx) so its ~heavy Vue
bundle stays out of the initial load and only downloads when the page opens.
- CDN-free hardening: withDefaultFonts:false (drops the fonts.scalar.com
@font-face rules), proxyUrl:'' (Test Request client goes direct to the local
backend, not proxy.scalar.com), spec passed as inline content (no external
spec fetch). The Tauri CSP is the hard backstop. Verified the built dist:
external hosts appear only as inert/gated strings inside the on-demand Scalar
chunks and are absent from the initial-load chunks.
- settingsCategories: new 'openapi' category (Braces icon, api/openapi/scalar/
rest/swagger/docs keywords) in the System group; Settings render case wired.
- LogsFooter: compact Braces icon button (openSettingsTab('openapi')) next to
the discord/mail cluster, chrome-muted → accent on hover, uniform 14px icon.
- i18n: all strings via t() with English defaultValue fallbacks (openapi.*,
logs.open_api*, settings.openapi); keys added to en.json.
- Test: OpenApiPanel.test.jsx (mocks the spec fetch + stubs Scalar) — renders
the reference container on success, shows the unreachable fallback on failure,
recovers on Retry.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
feat(engines): real synthesis "Self-test" + copy-paste setup snippet for opt-in engines (#930)
* feat(engines): real synthesis "Self-test" + copy-paste setup snippet for opt-in engines
Builds on #905's Engines-settings fixes (verified still green: license dialog
mounts, matrix reloads on select, cpu_fallback routing toast, cpu-native →
cpu_only). Two enhancements, no #905 behavior touched.
Real "Self-test" for in-process TTS engines
-------------------------------------------
The existing /engines/{id}/health probe only imports the package and reports
"deps OK" for in-process engines — it never proves the engine can emit audio.
New POST /engines/{id}/selftest runs a *tiny real synthesis* from a fixed short
ASCII phrase and reports ok + duration + sample-rate + sample count, proving the
engine actually produces audio. Guardrails keep it cross-platform-identical and
CPU-cheap: TTS + available + in-process only, bounded wall-clock timeout
(OMNIVOICE_SELFTEST_TIMEOUT_S, default 90s) that returns ok=false/timed_out
instead of hanging the panel, a process-wide lock so a click-storm can't stack
model loads, loopback-gated, and only ever on user click (never on load). The
Compat Matrix gains a "Self-test" button (with cooldown) that renders
"0.82s @ 24 kHz in 820 ms". HF tokens in a synth error are redacted like the
health route. Verified end-to-end: kittentts synthesized 89,200 samples @ 24 kHz.
Copy-paste setup snippet for path-gated opt-in engines
------------------------------------------------------
IndexTTS / MOSS-v1.5 / dots.tts / Confucius4 gate on an OMNIVOICE_*_DIR env var.
list_backends() now emits a single-sourced `setup_snippet` (the exact
`export VAR=/path/...` line) surfaced with a Copy button inside the matrix's
"Why unavailable?" disclosure, so users don't reconstruct it from the docs.
Also tightened the incomplete SelectEngineResponse TS type to include the
routing echo (routing_status/effective_device/routing_reason) the post-select
toast already reads at runtime.
Tests: backend selftest success/subprocess-reject/unavailable/unknown/loopback/
exception-capture/timeout/HF-redaction + setup_snippet shape; frontend self-test
render, timeout marker, subprocess+ASR gating, setup-snippet render. New route
added to the API route snapshot. Full vitest (808) + backend engine/routing/asr/
route-inventory/no-CJK green; lint 0 errors; format + typecheck:ci clean.
* fix(test): allow setup_snippet key in list_backends shape assertion
The engine self-test PR added setup_snippet to each backend entry but only
updated the route-shape test; test_list_backends_shape strict-asserts the key
set. Add setup_snippet there too.
---------
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
feat(models): one canonical HF-token path + surface incomplete cache (#927)
Two Model-management enhancements building on #908 (no re-do of its fixes).
Unify the two HF-token entry points. The Model Store toolbar saved the
token via /system/set-env (env var + HF-CLI file) while Settings →
Credentials saves to the encrypted app store — two stores with an
asymmetric clear path, so a toolbar-set token silently outlived the
Credentials "Clear" (a support-ticket generator). The toolbar now POSTs
the SAME canonical endpoint Credentials uses (/api/settings/hf-token →
encrypted store + huggingface_hub.login()), so there is one store with
one clear path. In-process parity is preserved (login() populates the HF
canonical file, so downloads pick it up immediately).
Surface an incomplete/partial cache. A truncated download (config landed,
weight shard didn't) occupies disk but used to read as a plain "not
installed". The backend already flags it as `incomplete`; the row now
shows an "incomplete · N MB" warn badge, relabels the primary action to
"Repair" (re-runs snapshot_download to finish the missing shard), and
offers a Delete to clear the partial bytes.
Tests: modelStoreTokenPath (toolbar hits /api/settings/hf-token, never
/system/set-env) + modelStoreIncomplete (badge, Repair→onInstall, Delete,
no false positives on normal not-installed/installed rows). Full vitest
green; lint + format clean. i18n keys added to en.json (models.incomplete,
incomplete_title, repair_btn, repair_title).
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
feat(contact): make "Get in touch" a guided, well-typeset help page (#925)
* feat(contact): make "Get in touch" a guided, well-typeset help page
Replace the flat 4-row link list (Discord / Email / Issues / Website) with
five guidance cards, each an icon + heading + a "use this when…" sentence so
users pick the right channel instead of guessing:
- Report a bug → reuses ReportBugButton (prefilled GitHub issue + scrubbed
diagnostics; nothing sent until the user reviews & submits)
- Request a feature / ask → GitHub Issues
- Get help & community → Discord (setup help, sharing dubs)
- Support the project → routes to the existing Support page (no Ko-fi
duplication)
- Report a security issue → GitHub Security Advisories (private, per
SECURITY.md)
Bigger, friendlier typography ("We'd love to hear from you" header, roomier
measure and spacing) and a container-reflow card grid (CSS grid auto-fit, no
viewport @media, so it stays correct under --ui-scale zoom). Email + website
kept as quieter direct channels. External CTAs are real <a rel="noreferrer">
links, keyboard-focusable, opened via the shared openExternal helper. All
strings go through i18n under contact.* with English defaultValues for locale
fallback.
Adds a ContactPage render test (sections render, each channel targets the
right URL, bug-report affordance present, Support routes to donate).
* fix(i18n): prune 4 orphaned contact.* keys from 20 locales (Contact-page rewrite)
The Contact page rewrite renamed its i18n keys; the old keys lingered in the
20 non-English locales as orphans, failing the locale_no_orphan_keys probe.
Pruned; new keys fall back to English per i18n config.
---------
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
feat(support): sponsor logo slot + "Become a sponsor" affordance (#923)
Adds a way for companies and people to visibly support OmniVoice.
- config/sponsors.js: single source of truth — an (empty) SPONSORS array with
a documented { name, logoUrl, url, tier } shape + tier order, and a
SPONSOR_CONTACT object whose githubIssue is a prefilled, zero-token
"become a sponsor" issue (same pattern as the bug reporter) plus the Ko-fi
link and a SPONSORS.md docs URL. Logos are added here + in SPONSORS.md.
- LogsFooter: a compact "Sponsors" link next to the donate heart (a link, not
a logo strip in the 28px bar) that opens the in-app Support/Sponsors view.
- SupportPage: a Sponsors section — logo grid grouped by tier when populated,
a tasteful outlined "be the first — your logo here" slot while empty, a
primary "Become a sponsor" button opening the prefilled issue, and a
one-line explainer linking to SPONSORS.md.
- SPONSORS.md: what sponsors get + how to become one, kept in lockstep with
the config.
- All strings via i18n (support.sponsors_* / logs.sponsors) with English
defaultValues so non-English locales fall back cleanly. Logo links are lazy,
max-height capped, aria-labelled, rel="noreferrer", and open in the system
browser via the app's external-open helper.
- Test: SupportPageSponsors renders the empty placeholder + asserts the
become-a-sponsor CTA targets the contact URL, and (with injected sponsors)
that each renders as an external logo link.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
docs(sponsors): add SPONSORS.md, README sponsors section, sponsor issue form + FUNDING link (#924)
Add a sponsorship home (SPONSORS.md) with Backer/Bronze/Silver/Gold tiers —
described as placements/benefits, with $ amounts left as `<!-- OWNER: set
amounts -->` placeholders (no invented prices). Primary "become a sponsor"
path is a prefilled GitHub issue form (.github/ISSUE_TEMPLATE/sponsor.yml:
name/org, logo URL, tier, contact), with Ko-fi/PayPal as direct paths and an
OWNER placeholder for a public contact email.
README gains a Sponsors subsection (logo-slot placeholder + SPONSORS.md link),
a Sponsors nav entry, and a note about GitHub's native Sponsor button.
FUNDING.yml adds the SPONSORS.md link alongside the existing ko_fi/PayPal.
Keeps the honest "agent bills" framing; sponsorship is a thank-you, not a
paywall — OmniVoice stays fully free and AGPL-3.0. Docs-only; no fabricated
sponsors, prices, or testimonials.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
ci(release): version-first release titles so the tag shows in GitHub's truncated release list (#922)
GitHub's release-list sidebar clips the title mid-string, hiding the version
when it trails 'OmniVoice Studio'. Name stable releases 'vX.Y.Z — OmniVoice
Studio' and the preview 'Preview — OmniVoice Studio'. Existing releases were
renamed to match.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
docs(changelog): add Launchpad full-width (#915) + migration-logging fix (#917) to [0.3.9] (#918)
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
fix(test): DB migration-safety tests leak-proof against full-suite order (#909 follow-up) (#917)
The PR #909 data-safe-update tests passed in isolation but failed only in
full-suite CI order. Two independent, order-dependent leaks were at play:
1. Module-identity leak (the #878/#894 class). The `isolated_db`/`fresh_app`/
`fresh_resolver` fixtures in tests/backend/** purge `core.*`/`services.*`
from `sys.modules` and never restore them, so `sys.modules["core.db"]`
afterward is a DIFFERENT object than the one the migration-safety tests
imported at collection. `monkeypatch.setattr("core.db.DB_PATH", ...)`
re-resolved the dotted string to the re-imported module, while
`_run_alembic_upgrade`/`init_db` (bound at collection) kept reading the
ORIGINAL module's globals — so the patch missed and the upgrade ran against
the ambient session DB. Result: no backup at the asserted path, and the
mid-flight-failure injection never hit the expected DB (DID NOT RAISE).
The same divergence hit the lazy `from core import db_backup` inside
`_run_alembic_upgrade`, so patching `MAX_BACKUP_DB_BYTES` was silently lost.
2. Logger-disable leak. Alembic's env.py called `fileConfig(...)` with the
default `disable_existing_loggers=True`, which disabled the already-created
`omnivoice.db.backup` logger the first time any earlier test ran a real
`alembic upgrade` — so the oversized-DB "Skipping pre-migration DB backup"
line was never emitted and the caplog assertion failed. This also silently
mutes the live app's logging after a real startup migration.
Fixes:
- env.py: `fileConfig(..., disable_existing_loggers=False)` so a migration
never mutes the app's (or another test's) loggers.
- core/db.py: import `db_backup`/`APP_VERSION` at module level so
`_run_alembic_upgrade` uses a stable reference immune to a `sys.modules`
purge, matching what tests patch at collection.
- test_db_migration_safety.py: patch DB_PATH on the imported `core.db` module
object rather than the re-resolvable dotted string — the correct,
self-contained seam.
Verified: the four migration-safety tests + the oversized-backup test pass in
full-suite order and in isolation; full `pytest tests/` is green
(2206 passed, 0 failed).
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
fix(launchpad): full-width responsive feature-card grid (retire the fixed ~780px deck) (#915)
PR #904's deck-of-cards fan pinned the seven Launchpad feature cards
(Voice Clone, Voice Design, Video Dubbing, Stories, Audiobook, Voice
Gallery, Transcripts) inside a fixed ~780px box, leaving dead margins on
a maximized display. Replace it with one full-width grid that fills the
content edge-to-edge and reflows its column count from a maximized
~2560px display down to the 900x600 minimum.
- LaunchpadDeck renders a single `.lp-cards` grid at every shell width
(no deck-vs-fallback split): `repeat(auto-fit, minmax(--lp-card-min,
1fr))` derives the column count from the grid's OWN width, so columns
reflow 7->1 with zero viewport @media (which fire at the wrong width
under the shell's `zoom: --ui-scale` model). Every column stretches
(1fr) -> no dead margins, no horizontal scroll.
- The only responsive knob is `--lp-card-min`, set inline from
useShellNarrow (the `.app-container` shell-narrow/shell-mini own-width
classes): 200px wide, 240px narrow -> fewer, comfier columns on narrow
shells. No viewport media queries.
- Cards keep #904's character: animated waveform faces, cursor
spotlight + eternal breath ring (phase-offset per card via --lp-i),
and a hover/focus-forward raise (`lp-action-card--raised`) driven from
React state so pointer and keyboard share one path. Reduced-motion
freezes the waveform. All 7 navigation targets and i18n keys preserved.
- Removed the old `.lp-deck*` fan CSS, the ActionCard narrow fallback,
and the launchpad viewport @media overrides. Rewrote the regression
suite to assert full-width grid layout, the narrow-vs-wide floor, and
the raise interaction for pointer AND focus.
Verified in a real browser (chromium): 7 cards fill the full width in
one row at 2472px content, reflow to 3 columns at 920/876px, and the
grid width equals the container at every size (no overflow).
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
feat(settings): LLM Skills — per-feature enable/route control for every LLM call (#912)
New Settings → System → LLM Skills area: every LLM-powered capability
(Cinematic & Autofit translation, speech-rate slot fitting, glossary
auto-extract, direction parsing, dictation cleanup) becomes a "skill" the
user can toggle or route to a specific provider (local Ollama/LM Studio vs
a remote key) instead of everything riding the one global active provider.
Backend:
- services/llm_skills.py — skill registry + settings_store persistence
(llm_skill.<id>.enabled / .provider), resolution precedence
override > active > none, resolve_skill_client() (OpenAI-compat client
bound to the effective provider; None when disabled/unconfigured) and
skill_backend() (OffBackend when disabled — the exact no-LLM object every
caller already degrades on).
- All five consumption points wired through the registry; a disabled skill
degrades exactly like "no LLM configured" today (Fast translation
fallback, refinement pass-through, heuristic direction parse, no-llm slot
fit, 503 on glossary auto-extract). No new degradation modes; defaults
(enabled + no override) keep existing setups byte-identical.
- OpenAICompatBackend gains an optional bound provider (None = active, the
historical behavior).
- GET /api/settings/llm-skills + PUT /api/settings/llm-skills/{skill_id}
(404 unknown skill/provider); route snapshot updated.
Frontend:
- LLMSkillsPanel (Sparkles, next to LLM Providers): one row per skill —
i18n name/description, enable toggle, provider Select ("Use active
provider" + configured providers, local ones tagged), ready /
needs-setup badge linking to LLM Providers. All strings via t()
(settings.llmskills_*).
Tests: 30 backend (precedence, per-consumption-point disabled semantics,
endpoint round-trips, validation) + 4 panel render/PUT tests. Docs:
translation-engines.md gains an LLM Skills section.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
docs(changelog): backfill the settings/features wave into [0.3.9] (#913)
Nine PRs (#904-912) shipped without their changelog bullets (agents were
kept off CHANGELOG.md to avoid merge conflicts across the wave); this
backfills them per the changelog hard rule.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
fix(translate): run Cinematic/Autofit on every engine (incl. default Argos), bound the fit pass, scrub provider errors (#910)
P0 — Cinematic/Autofit silently no-op'd on argos/nllb/openai. Those three
branches returned BEFORE _maybe_cinematic, so only the deep_translator
fall-through reached the refine/fit pass. A user on the DEFAULT Argos engine
who picked Cinematic/Autofit got plain Fast output with a success toast and
no quality_used/cinematic_skipped/rate_ratio. All three now route through
_maybe_cinematic. provider=openai is already an LLM translation, so it skips
the reflect/adapt re-refine (new already_llm flag) but still stamps
rate-ratio badges and runs the Autofit fit pass; the dialect it baked into
its translate prompt is now reported applied.
P1 — the Autofit fit pass ran one blocking adjust_for_slot per segment in the
merge loop, OUTSIDE any budget (a 50-seg dub vs a slow provider spun
~50×timeout unbounded). New speech_rate.adjust_for_slot_many fans it out
concurrently under a wall-clock deadline SHARED with the cinematic refine;
segments still running at the deadline degrade to their literal with
rate_error='fit-budget'. Also set max_retries=0 on the OpenAI clients used
for translate/refine/fit so a 429 + Retry-After can't sleep through the budget.
P2 — glossary auto-extract's no-LLM message now points at Settings → LLM
Providers (was the stale TRANSLATE_BASE_URL/TRANSLATE_API_KEY). Provider error
bodies on the glossary auto-extract, the OpenAI translate-segment path, and the
DeepL/Microsoft translate-segment path are now scrubbed
(core.scrub.scrub_provider_error) — they could echo the API key / a user_id.
DubTab re-polls LLM availability on window focus / visibility so configuring a
provider in Settings lifts the Cinematic gate without a remount. Documented
LLM_DEFAULT_PROVIDER in docs/dubbing/translation-engines.md.
Tests: fail-before/pass-after for argos+cinematic (refine runs), argos+cinematic
no-LLM (cinematic_skipped), argos Fast (rate_ratio stamped), openai+autofit
budget bound, and provider-error scrubbing on the translate + glossary paths.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
fix(dictation): refinement never stalls a final (~51s→≤4s), REST polish parity, real ASR preload reuse (#911)
P0 — Refinement blocked every dictation final with no timeout. With refinement
auto:true and a slow/dead LLM endpoint, maybe_refine ran unbounded and blocked
the final send in all three capture_ws handlers (~51s measured; the pill hung
"Transcribing…" until the widget's 15s fallback fired). Fix the class: a hard,
env-tunable budget (OMNIVOICE_REFINE_TIMEOUT_S, default 4s) via a new
maybe_refine_async — a slow/dead endpoint now falls back to the unrefined (but
polished) text within the budget and can NEVER delay the final beyond it. The
LLM HTTP call is bounded to the same budget so the orphaned worker unwinds
instead of holding a connection for the client's full 45s. Refinement is now
also fully best-effort in the legacy handler (it can't turn a good final into
an error frame).
P1 — REST /transcribe lacked polish parity. capture.py never applied
polish_text, so REST returned raw "…test" while the WS returned "…test."
Apply text_polish.polish_text to `text` and `refined_text` (segments stay raw),
so the widget POST fallback and MCP/CLI callers match the live socket.
P1 — The #888 "instant first dictation" preload was a no-op. The preload called
warmup() only `if hasattr`, but SherpaDictationBackend had none, and the WS
handlers built a FRESH backend per session so a warm singleton wasn't reused.
Add SherpaDictationBackend.warmup() (builds the recognizer) and share one warm
recognizer per model id across sessions (get_sherpa_dictation_backend, same
invalidation + a shared lock as the capture singleton); each session keeps its
own decode stream. First dictation no longer pays the 1.3–2.5s load.
P1 — llm_ready is a lie (feeds the P0). It only means "an endpoint is
configured", so a placeholder key reads as ready. The P0 timeout makes a dead
endpoint harmless; add last_refine_status so RefinementPanel flags a
configured-but-failing LLM and links to LLM Providers → Test.
Regression tests (fail-before/pass-after): slow-LLM WS final arrives < budget;
maybe_refine_async hard timeout + status; REST polish parity + refined_text
polish; warmup builds the recognizer and a second session reuses it; the panel
honesty note. Backend refinement/capture_ws/capture/sherpa suites, CJK + route
inventory gates, full vitest (733), lint (0 errors) and format all green.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
feat(updates): data-safe updates — pre-migration DB backups, guarded venv heal, release notes + changelog reader (#909)
Backend:
- core/db_backup.py: WAL-safe SQLite snapshot to omnivoice.db.backup-<version>-<n>
before pending alembic migrations run; keep newest 3, prune older; skip >500MB
with a log line. Restore is never automatic.
- core/db.py: _run_alembic_upgrade now plans the run (up_to_date / pending /
unknown_revision), snapshots first when migrations will execute, and raises
MigrationError on a mid-flight failure — startup stops with the backup path
named instead of continuing on a half-migrated DB. The #552/#547
unknown-revision class stays non-fatal (warn + additive reconcile).
- core/changelog.py + GET /api/settings/changelog: parse the shipped
CHANGELOG.md (single-line and wrapped bullet styles) into structured releases.
- GET /api/settings/db-backup: newest pre-migration backup for the panel.
Rust (bootstrap.rs):
- #314 heal guard: an exit-signature match alone can no longer delete the venv —
venv_rebuild_justified requires a structural problem or a failed direct
interpreter probe; a venv that probes healthy is kept and the real error
surfaced. Drift/repair remains in-place `uv sync` (non-destructive).
- CHANGELOG.md now ships as a bundle resource and is copied/refreshed into the
project dir so the changelog endpoint works in packaged installs.
Frontend (Settings → Updates):
- Available update shows its actual release notes (updater metadata body)
through a safe markdown-lite renderer (text nodes only, refs stay plain).
- "Your data is backed up before every update" line with the latest backup
timestamp from the new endpoint.
- "What's new" changelog reader (accordion, newest expanded) over the shipped
CHANGELOG.md; GitHub releases list reuses the same renderer.
- One-time, non-blocking "What's new" footer pill after an update
(persisted last-seen version; fresh installs baseline silently).
- All strings via t() with en keys (other locales fall back to English).
Tests: db backup/rotation/failure-path units, migration-safety units, changelog
parser (both bullet styles + real CHANGELOG.md), endpoint tests, route
inventory regenerated, Rust decision-logic + probe tests, vitest suites for
renderer/viewer/panel/pill logic.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
fix(models-settings): surface async install errors, disk-space guard, cancel wiring, honest restart (#908)
Live-audit fixes for the Models settings surface — the P1s were cases where
the feature silently didn't work for the user.
P1-A — Async install errors were invisible. The `install_error` SSE event
carries excellent mirror-aware text (#890 core/failure.py), but the Model
Store auto-purged the errored row ~800ms later (same as a success) and the
first-run WizardLibrary DELETED the row without ever reading `ev.error`. The
SSE→rowState reduction is now a pure, tested reducer (downloadReducer.js /
reduceWizardDownloadEvent); only SUCCESS terminals auto-purge
(isAutoPurgeTerminal), an error persists on the row with inline text + Retry +
Dismiss (Model Store) / a Retry (wizard).
P1-B — No disk-space check on install. `POST /models/install` now compares the
FDL-05 plan's exact `to_download_bytes` (+ MIN_FREE_GB headroom) against
`shutil.disk_usage(cache).free` BEFORE downloading and emits an actionable
install_error naming the sizes (needs X, headroom Y, have Z) instead of failing
mid-download. `/models` also surfaces `disk_free_gb` in the header. MIN_FREE_GB
+ disk_free_bytes are single-sourced in setup/models.py (wizard delegates).
P2-A — Wired the orphaned cancel. `POST /models/install/cancel` (FDL-11) had
zero frontend refs; the in-progress row now shows a Cancel button that calls it
and transitions the row to install_cancelled.
P2-B — Honest restart_required. The HF-mirror PUT returned restart_required:true
unconditionally; it now returns true only when the persisted value actually
changed, with accurate copy (Model Store downloads use the new mirror
immediately — resolved per-call; only transformers model loads need a restart).
P3 — i18n the un-localized panels (HFMirrorPanel, ApiKeysPanel source
labels/help/status, MODEL_ROLE_LABEL) via new en.json keys; other locales fall
back to en.
Tests: new tests/test_install_disk_space.py (reject-when-over-budget incl. the
worker wiring; allow-when-fits; degrade on unknown size/unprobeable volume),
updated tests/test_hf_mirror_settings.py (change-only restart_required), and new
frontend reducer + column-render tests for install_error persistence, Retry,
Dismiss, and Cancel.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
feat(settings): Storage panel — real disk usage, category breakdown, and low-space warnings (#906)
Settings → Storage now opens with a Disk usage panel backed by a new
loopback-gated GET /api/settings/storage endpoint:
- Per-volume totals (grouped by st_dev) + du-style sizes for everything
the app owns: the HF model cache (with its ~10 largest models), the
app data dir broken into voices/outputs/dub_jobs/batch/preview/
database/logs/other subtotals, engine venvs (backend/engines/*/.venv
+ the app venv), and omnivoice* entries in the OS temp dir.
- Bounded scanning: per-category 10 s deadline → partial totals with an
"unreadable" warning instead of a hung request; results cached
in-process for 5 minutes, ?refresh=1 forces a rescan; the walk runs
in a worker thread so the event loop never blocks.
- Server-side warnings reuse the setup wizard's MIN_FREE_GB: free <
min → critical, free < 2×min → low, volume holding the cache/data
>90% full → volume_pressure, unreadable/timed-out paths → unreadable.
The panel renders severity-colored banners, a data-volume gauge,
proportion bars per category, Open-folder buttons (existing
/export/reveal pattern), a Model Store jump for reclaiming model
space, and the existing clear-logs action on the logs row. A critical
warning is also surfaced outside Settings via the app-wide toast —
once per session. All strings via i18n (en fallback).
Tests: tests/test_storage_report.py (sizes, thresholds, cache/refresh,
timeout partials, endpoint wiring) + StorageUsagePanel.test.jsx
(categories, banners, once-per-session toast, refresh=1, error state);
route added to tests/fixtures/api_routes.txt via the dump script.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
fix(settings): retire legacy LLM endpoint panel, surface env overrides, fix Cloudflare account + fast-fail probes (#907)
Live-audit fixes for Settings → LLM Providers / Translation.
Retire the legacy LLMEndpointPanel from the UI (backend endpoint kept).
TranslationTab no longer embeds the inline endpoint panel — it now points to
Settings → LLM Providers (openSettingsTab('llm-providers')), which fully covers
it via the `custom` provider (a lone TRANSLATE_BASE_URL still resolves to
`custom`). Kills the panel's lying "reachable" badge, its hardcoded-English
strings, and one of three duplicate TRANSLATE_* surfaces. The third duplicate —
TranslationTab's "Provider keys" collapsible — drops the TRANSLATE_* trio
(now owned by LLM Providers) and keeps only the DeepL/Microsoft translator
keys; its toast no longer claims "saved for session" (these are in
PERSISTENT_KEYS, restored at startup). GET/PUT /api/settings/llm-endpoint is
untouched (DubTab gates Cinematic off it; tests + route inventory cover it).
Surface env overrides. describe() now reports base_url_from_env / model_from_env
/ active_from_env (mirroring key_from_env). The panel disables env-pinned
base_url/model/account fields with an explainer, and — when
LLM_DEFAULT_PROVIDER pins the active provider — disables make-active and shows a
banner, instead of silently reverting the user's edit / no-oping the button.
Fix the Cloudflare account-id flow (broken two ways): describe() now returns the
stored account_id (the field no longer resets to empty) and shows the RAW
base_url template ({account_id} kept literal) instead of the substituted value;
save_overrides drops a base_url override equal to the built-in default, so the
UI posting the shown value back can't freeze the URL — later account-id changes
take effect again (also self-heals if a default URL changes in a release).
Fast-fail the Test / Fetch-models probes. Pass max_retries=0 to the probe
OpenAI clients so a 429/timeout returns in seconds instead of ~34s on the SDK's
default retry ladder. /models now returns truncated:true when capped at 200 and
the UI hint reads "first 200 shown".
Tests: registry env-flag + Cloudflare round-trip/no-freeze regressions; router
truncation + max_retries=0 assertions; panel disabled+explained + banner;
new TranslationTab test (pointer wired, legacy panel gone, TRANSLATE_* dropped).
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
fix(engines): revive dead license dialog, refresh matrix on select, surface routing verdict (#905)
Six live-audit fixes for the Engines settings surface:
- P1-A: the Supertonic license dialog was dead since #101 — `useState`
threw away the state value (`const [, setLicenseDialogFor]`) and the
imported dialog was never mounted, so "Accept license" did nothing.
Keep the value and render LICENSE_DIALOGS[selected] with open/onClose/
onAccepted (accept → matrix reload).
- P1-B: the matrix went stale after "Use" — active badge, Use buttons and
family-tab captions stayed old until a manual Refresh. Await onSelect,
then reload() so the picked engine reflects immediately.
- P2-A: consume the /engines/select routing echo. A `cpu_fallback` pick now
shows a warn-tone toast naming the reason ("running on CPU — …"); the
plain success toast stays for accelerated/cpu_only. Shared helper used by
both Settings→Engines and the first-run WizardLibrary.
- P2-B: a CPU-native engine (gpu_compat == ("cpu",)) has nothing to fall
back FROM, yet on a GPU/MPS host it was mis-classed cpu_fallback (warn).
New routing rule classifies ("cpu",) as cpu_only (neutral) on any
accelerator host; multi-target engines that could accelerate elsewhere
are untouched.
- P3-A: the routing reason was only a badge `title` (unreachable on
keyboard/touch) — surface it as small visible text under the badge.
- P3-B: an in-process "Test engine" pass is an import/liveness check, not a
synthesis test — label it "deps OK" instead of a misleading "0 ms"
latency; subprocess rows keep their real ping latency.
Adds RTL + unit regression tests for all six and updates the routing unit
tests to the corrected cpu-native intent. i18n keys added to en.json.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
feat(launchpad): deck-of-cards redesign — fanned feature cards with waveform faces (#904)
The seven launchpad feature cards now render as an overlapping deck fanned
left-to-right: each card sits in a fan slot with a subtle tilt (±4°) and
vertical stagger (≤14px), peeking ~33% out from under its right neighbor.
Every card face carries its lucide icon + name on the always-visible peek
edge, a one-line description, and a decorative CSS-only animated waveform
strip in the card's accent color (stagger-delayed scaleY bars, aria-hidden,
static under prefers-reduced-motion).
Hovering OR keyboard-focusing any card brings it fully forward — it
straightens, scales up and takes the top of the stack while every other
card slides toward it and tucks underneath (dimmed, scaled down, overlap
increased). The raise/tuck classes are React-state-driven so pointer and
focus share one code path and tests can assert it. Fixed deck height —
zero layout jump.
Navigation targets, i18n keys, per-feature accent hues and profile/project
counts are unchanged; both renderings share a single feature list so they
can't drift. On shell-narrow/shell-mini (the app-container's own width
classes — not viewport @media, per the UI-scale rationale in App.jsx) the
deck degrades to the pre-existing flat ActionCard grid, tracked live via
MutationObserver (new useShellNarrow hook), keeping 900×600 usable.
New LaunchpadDeck.test.jsx covers: 7 cards in canonical order, every
navigation target (incl. clone/design → studio + defineMethod), raise/tuck
partitioning for hover and focus, waveform decorativeness, the narrow
fallback under both shell classes, and the runtime class flip.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
release: freeze v0.3.9 — version bump, lockfiles, changelog (#899)
* release: freeze v0.3.9 — version bump + lockfiles + changelog
* release: unwrap the [0.3.9] section — release bodies hard-break single newlines
---------
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
fix(generate): device-aware timeout guidance — stop telling CPU hosts to switch to CPU (#896) (#902)
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
fix(bootstrap): port cuDNN 8 (NVIDIA CUDA GPU) + VC++ redist to packaged installs (#869)
* fix(bootstrap): port cuDNN 8 (NVIDIA CUDA GPU) + VC++ redist install into ensure_venv_ready()
* fix(bootstrap): address #869 review — drop dead VC++ half, cache negative CUDA probe, gate on ROCm, sync docs
Per maintainer review on #869:
1. Drop the VC++ Redistributable half: LoadLibraryA("vcruntime140.dll")
from the running Tauri exe is a tautology (the exe itself links the
MSVC CRT, so the process wouldn't be running without it), and torch's
real failure mode is msvcp140.dll inside the venv python process.
Dead code removed; a comment records why for future readers.
2. Stop taxing every non-CUDA launch: a negative torch probe (CPU /
Intel / AMD — most installs) is now cached in a
.venv/.cudnn8_probe_negative marker, so the synchronous `import
torch` runs at most once per venv lifetime. Invalidated on every
path that can change the torch build (drift sync #307, repair sync,
first-run sync, ROCm reinstall) and implicitly by a venv rebuild.
A probe that fails to run cleanly is skipped WITHOUT caching so a
transient error can't wedge a real CUDA machine.
3. Rewrite docs/install/troubleshooting.md §10 to the actual root
cause: packaged installs never had the cudnn8_compat libs (so
reinstalling never restored them); the bootstrap now installs them
automatically on CUDA machines, with the manual uv pip command as
the offline fallback and PyTorch Whisper as the sidestep.
4. Gate the ~700 MB nvidia-cudnn-cu12 download on the venv torch being
a real CUDA build: the probe now reports 'hip' before checking
cuda.is_available() (which HIP spoofs), so opt-in ROCm installs
(#124) never fetch the CUDA wheel.
Also reflow the CHANGELOG entry to house style (bold one-line lead,
1-3 lines of why, (#827, #869) refs) and extend the bootstrap unit
tests: classify_cuda_probe verdict mapping and the marker
write/invalidate round-trip (6 cuDNN tests total, 43 lib tests green).
---------
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
feat(footer): Logs icon + uniform icon sizes + value-moment donate popover (Clippy-style, strictly throttled) (#898)
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
fix(asr): close the #730 residuals — chunked dub wedge shares the guarded reset; repeated timeouts recommend the crash-isolated engine (#895)
Residual A — the chunked dub-stream had a PARALLEL wedge mechanism (its own
ping-loop timeout, its own _reset_pool_on_wedge, a dead-end "Try restarting
the server" message). A wedged chunk now routes through the SAME
run_transcribe_guarded bound+reset as the whole-file paths (#731/#851): the
guard resets the poisoned pool once per wedged attempt (no double-reset on
retry) and the user sees the actionable ASRTimeoutError. The reset logic is
extracted to asr_backend.reset_pool_after_wedge — one shared mechanism, so
the semantics can't drift again. run_transcribe_guarded also gains a
timeout_env param so chunk errors name OMNIVOICE_TRANSCRIBE_CHUNK_TIMEOUT_S
instead of the whole-file knob.
Residual B — the crash-isolated ASR sidecar (#393, faster-whisper-isolated)
is wired as an explicit ESCAPE HATCH, not a default:
- selectable end-to-end: Settings engine list gets an explanatory
install_hint; honest gpu_compat ("cuda","cpu" — it wraps the same
CTranslate2 engine as faster-whisper); get_active_asr_backend now hands
back a process-wide singleton for subprocess-isolated backends (a fresh
instance per request would leak atexit hooks and respawn the sidecar —
reloading its model — on every transcribe).
- on the SECOND consecutive guarded timeout-with-reset in one session
(resets aren't recovering the hang; the wedged thread keeps its VRAM),
the error the user sees + the log recommend switching to the isolated
engine in Settings → Engines. Never auto-switched (owner rule: no silent
behavior divergence); a completed transcribe resets the streak.
Tests (fail-before/pass-after verified against origin/main): wedged-chunk
SSE integration (reset count + actionable error + recommendation surfaces),
consecutive-timeout streak (fires at 2, resets on success, suppressed when
already on the isolated engine), timeout_env parametrization, shared-reset
helper, isolated backend in list_backends with hint + honest availability,
singleton caching, gpu_compat matrix entry. Docs: troubleshooting §14 gains
the chunk knob + escape-hatch guidance.
Closes the residuals tracked on #730.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
test: make LLM-provider state leaks between tests impossible (#878) (#894)
Root cause: LLM provider selection reads three process-global surfaces —
env vars (LLM_DEFAULT_PROVIDER, per-provider *_API_KEY/*_BASE_URL,
TRANSLATE_*), the SQLite settings store (llm.active_provider & co.), and
prefs.json (llm_backend). Importing `main` (TestClient fixtures do)
dotenv-loads the developer's .env and ~/.config/omnivoice/env straight
into os.environ, and several tests/endpoints mutate these surfaces
without teardown — so whichever test imported the app first flipped what
later tests' active_backend_id()/active_provider_id() resolved to
(order-dependent failures in test_engines.py,
test_llm_endpoint_settings.py, test_llm_providers.py).
Fix the class, not the instances:
- tests/conftest.py: redirect OMNIVOICE_DATA_DIR to a per-session tmp dir
and OMNIVOICE_ENV_FILE into it (before collection freezes
core.config.DATA_DIR), so tests never read or write the developer's
real app state and local runs behave like clean CI.
- tests/conftest.py: autouse `_isolate_llm_provider_state` fixture
snapshots env (derived from llm_providers._PROVIDERS, so new providers
are guarded automatically), llm.* / secret.llm_key.* settings rows, and
the prefs llm_backend/env.TRANSLATE* keys before every test and
restores them exactly afterwards.
- shared `clean_llm_env` fixture clears the FULL provider env surface;
the four LLM test modules' hand-picked partial delenv lists (which left
e.g. LLM_DEFAULT_PROVIDER / OPENROUTER_API_KEY standing) now use it.
- tests/test_llm_state_isolation.py: deterministic fail-before/pass-after
regression pair — pollutes all three surfaces without cleanup, then
asserts the guard restored them.
Verified: the issue's two-test repro passes; the five LLM-related test
files pass in order; full suite green (2046 passed, 20 skipped,
10 xfailed, 4 xpassed).
Fixes#878
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
fix(splash): IPC-independent watchdog + recovery panel for dead Tauri IPC (#879) (#892)
After an unclean shutdown (Windows BSOD), the WebView2 profile cache
(%LOCALAPPDATA%\com.debpalash.omnivoice-studio\EBWebView) can corrupt:
Tauri's IPC custom protocol fails AND the postMessage fallback breaks,
so invoke() hangs forever. useBootstrapStage's poll loop rode entirely
on that IPC — a hung bootstrap_status call silently killed the loop and
the splash sat at "preparing" forever, even with a fully healthy
backend answering over plain HTTP.
Class fix, three parts:
- splashWatchdog.js: IPC-independent escape hatch. If no IPC signal
arrives within 10s, poll GET /health over plain HTTP; healthy →
proceed to the app as if 'ready' was received (console.warn
breadcrumb so diagnostic bundles carry it). Any successful IPC
response disarms it for good.
- Recovery panel (stage 'ipc_lost'): if neither IPC nor HTTP succeed
within 45s, show an actionable panel instead of the infinite
spinner — "Open logs" (with an inline path fallback when IPC is
dead) and, Windows-only and only in this error state, "Repair and
restart". Health polling continues behind the panel so a slow
first-run install with broken IPC still reaches the app.
- clear_webview_cache_and_relaunch (Rust): writes a marker and
relaunches; the fresh process deletes EBWebView at the top of run()
before any webview exists (WebView2 holds locks while running),
with a bounded retry while the old instance exits. Runtime cfg!
guards keep the whole path compiling on every platform.
Tauri 2 exposes no reliable flag for the postMessage-fallback mode
(closure-local in its injected ipc.js), so the logged detector is the
observable combination: zero IPC signals + working plain HTTP.
Fail-before/pass-after regression tests: hung invoke + healthy HTTP →
ready; hung invoke + dead backend → recovery panel, then auto-continue;
working IPC → normal path untouched, zero HTTP polling. Plus watchdog
state-machine unit tests and recovery-panel render/interaction tests
(6/7 fail on the pre-fix component). Troubleshooting doc gains the
matching section (docs-sync).
Fixes#879
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
fix(generation): classify network/download failures — stop mislabeling every unknown error as OOM (#880) (#893)
A kittentts first-use HuggingFace download died with httpx's "Cannot send
a request, as the client has been closed", and the generation error
classifier's catch-all fallback told the user (CPU-only ~80 MB ONNX engine,
12 GB-VRAM box) they were OUT OF MEMORY and to press Flush — the wrong
remedy for a network failure.
Three-part class fix:
- generation.py: new #880 branch (before the OOM hint) classifies
httpx/requests transport failures — matched over the whole exception
chain (type names like ConnectError/ReadTimeout plus stringified
signatures like "client has been closed") — as a download/network
problem with a retry/check-connection remedy.
- generation.py (the real class bug): the OOM hint is no longer the
catch-all. It now requires an actual OOM signature (typed
OutOfMemoryError/MemoryError anywhere in the chain, or CUDA/MPS/CPU
allocator wording); genuinely unknown errors surface as unrecognized
with the underlying detail instead of a false "ran out of memory".
- tts_backend.py: KittenTTS's first-use load retries exactly once with a
fresh HF Hub client (huggingface_hub.utils.close_session()) on the
specific closed-client failure — hub ≥1.x shares one global httpx
client, and a closed one is recoverable, so the download self-heals
instead of failing the generation.
Fail-before/pass-after tests: classifier (closed-client message, wrapped
httpx type names, unknown error, real OOM signatures incl. typed
OutOfMemoryError, WinError 1455) + the retry helper (recovers once,
walks the chain, no retry on unrelated errors, single-shot).
Fixes#880
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
fix(errors): name the configured HF mirror when a model download fails (#874) (#890)
When a non-default HF_ENDPOINT (Settings → Models → Hugging Face mirror,
e.g. hf-mirror.com) is configured and a model load/download fails with a
connectivity error, the raw transformers message ("We couldn't connect to
'https://hf-mirror.com' to load the files…") leaked to the UI as a bare 500
with no next step.
Class fix — one shared classifier in core/failure.py covers every surface:
- classify()/build_failure(): new HF_MIRROR_UNREACHABLE class with a dynamic
hint that names the configured mirror, says it may be down, points at
Settings → Models → Hugging Face mirror, suggests the official endpoint
when the model isn't cached, and notes the restart requirement (HF reads
HF_ENDPOINT at backend start). Checked before the video-download network
class so a model download's "timed out" no longer gets the "video server"
hint. Feeds /model/status and every build_failure event (dub, tasks).
- main.py global 500 handler: appends the hint to the surfaced detail, so
ALL routes that can leak a model-load error benefit (generate, dub,
archetypes, …), not just TTS generate.
- setup/download.py install SSE: the install_error event gets the same hint.
- error_journal: "couldn't connect to" / "max retries exceeded" now classify
as NETWORK_ERROR (was UNKNOWN) for auto-attached bug reports.
- model_manager (#886 family): the "cache incomplete and could not be
auto-repaired" message now names WHY the auto-repair failed (mirror
outage, offline mode, full disk no longer read identically), which also
lets the mirror hint fire on that surface when applicable.
Fail-before/pass-after regression tests in tests/test_hf_mirror_error_class.py
(12 of 13 fail on main).
Fixes#874
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
fix(platform): declare Intel-Mac local backend unsupported — honest first-run gate + docs (#889); Windows portable-install docs (#766 follow-up) (#891)
torch >=2.3 ships no macOS x86_64 wheels (transformers 5.x needs torch >=2.6),
so `uv sync` can never resolve on an Intel Mac — per the platform-parity rule
the honest option is declaring the platform unsupported, not letting first
launch die in a raw resolver error:
- bootstrap.rs: pre-check on macOS x86_64 before any venv create / uv sync
(first-run AND repair paths) fails fast with an actionable message
(remote-backend escape hatch + docs link); healthy pre-torch-bump venvs are
deliberately untouched. Unit test pins the message's load-bearing phrases.
- BootstrapSplash: routes the failure to a dedicated localized hint
(bootstrap.hint_intel_mac, all 21 locales) and suppresses the useless
Retry-oriented hints for it.
- README + docs/install/macos.md (+ troubleshooting #9): every Intel-Mac
support claim now says UI-installs-but-backend-cannot-run, including the
from-source path (also broken); remote backend documented as the only use.
- release.yml: #889 note on the macos-15-intel leg — artifact is UI-only;
keep-or-drop is an owner call, deliberately not changed here.
- docs/install/windows.md: new "Portable install (Windows)" section promised
in #766 — custom MSI wizard folder / msiexec INSTALLDIR=..., what lives in
OmniVoiceStudio-Data next to the exe, and the Program-Files-greyed-out why.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
feat(dictation): rebuild to Wispr-Flow quality — live waveform, streaming commits, honest insertion, polished text (#888)
* feat(dictation): rebuild to instant-feedback quality — waveform, streaming commits, honest insertion, text polish
* docs(changelog): dictation rebuild entry
* fix(lint): Array.from over new Array(n) — oxlint no-array-constructor
---------
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
feat(settings): LLM provider testing pass — latency + classified errors, model discovery, full i18n, router tests (#887)
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
fix(desktop): enforce maximize() at startup — macOS can ignore the conf flag with Overlay title bar (#881 follow-up) (#884)
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
fix(shell): LogsFooter becomes a real grid row — bottom buttons can't clip under it at small window sizes (#882)
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
fix(desktop): always open maximized (not fullscreen) — stop window-state restoring stale geometry (#881)
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
fix(engines): Confucius4-TTS validated E2E — clone sys.path import, 22.05 kHz, real install docs (#590) (#872)
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
fix(asr): un-gate Parakeet TDT from CUDA-only — measured ~10× realtime on CPU (#871)
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
fix(asr): VRAM preflight before whisperx load — no more native OOM abort on 8 GB cards (#723) (#870)
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
fix(translate): bound the whole cinematic/autofit pass so a slow LLM can't hang "Translating…" (#868)
The per-segment call already has a 45s timeout and concurrency is capped, but a
slow or rate-limited provider on a large dub (hundreds of segments) can still keep
the "Translating…" spinner spinning for minutes as segments queue through the
bounded pool. There was no ceiling on the *whole* pass.
Add an overall wall-clock budget, OMNIVOICE_CINEMATIC_BUDGET_S (default 180s,
<=0 disables). Segments that finish in time keep their cinematic refine; any still
in-flight when the budget hits is cancelled and degrades to its literal (Fast)
translation with error="cinematic-budget", so the translate ALWAYS returns instead
of hanging. Order and length of the result are preserved. Abandoned executor
threads follow the same fire-and-forget pattern as the GPU-pool wedge guard (#730).
Regression tests: a 3s-per-segment refine under a 0.3s budget returns in <2s with
literal fallbacks; budget<=0 runs every segment to completion.
Co-authored-by: mergetest <test@local>
fix(translate+dub): wire Cinematic/Autofit to the LLM Providers registry; retry a wedged transcribe chunk instead of dropping it (#867)
Two bugs from real reports:
1. LLM not wired — translator._llm_client()/_llm_model() read TRANSLATE_*/OPENAI_*
directly, bypassing the LLM Providers registry (#854). So a provider set up
in Settings → LLM Providers never powered Cinematic/Autofit. Now resolves the
ACTIVE provider (base_url/key/model) via llm_providers; the 'custom' provider
still maps TRANSLATE_* so legacy env setups keep working.
2. Transcription 'missing the beginning' — the chunked dub transcribe dropped a
whole chunk's window on failure/timeout (returned empty segments, no retry).
A transient wedge on the FIRST chunk (whisperx cold-loads its model there, the
#730 hang) therefore lost the start and left only middle+end. Now retries a
failed/timed-out chunk once on a fresh pool (OMNIVOICE_TRANSCRIBE_CHUNK_ATTEMPTS,
default 2) so the recovered chunk fills the hole.
Imports + dub_transcribe/translator/llm_providers tests green.
Co-authored-by: mergetest <test@local>
refactor(gallery): cleaner, elegant voice cards — tokens over hardcoded surfaces, borderless state (#866)
Redesign ArchetypeCard for a calmer visual hierarchy and design-token
surfaces, no behavior change (all props/handlers/loading states identical).
- Replace hardcoded surfaces with tokens: chips/wand/preview bg → tokens
(bg-white/[0.05] → --color-bg-elev-2, bg-white/[0.03] hover → --chrome-hover-bg),
text → --color-fg / --color-fg-muted / --color-fg-subtle, and the literal
#1d2021 hover text on Use voice → --color-fg-inverse.
- Borderless by direction: drop the hover/state border classes on the action
buttons and the card; convey hover via background tint + text color and the
playing state via an accent box-shadow ring (no literal/token borders).
- Hierarchy: name is the focal point (semibold, --color-fg); metadata line is
smaller/muted (--color-fg-muted) so it recedes.
- Chip row renders only when there are chips (no empty min-h reserve); the grid
stretches rows so mt-auto still bottom-aligns actions.
- Accent used tastefully: tinted Use voice → solid accent on hover/focus with
inverse text; favorite star stays subtle until hover/active. Focus-visible
rings intact.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(dub): compact + responsive header — tighter stepper/title/actions, drop hardcoded borders (#865)
- Stepper (inline): smaller step gap/font (0.66rem), 19px icons, 10px connectors
→ the 6 stages take far less width so title + actions fit before wrapping.
- Title: lighter weight (medium/0.78rem), normal-case, min-w-0 truncation; meta
0.68rem; project name truncates too. Tighter header padding + gaps.
- Removed the hardcoded header border + border-left divider (borderless) and the
rgba bg → token --color-bg-elev-1.
Co-authored-by: mergetest <test@local>
feat(engines): Confucius4-TTS — finalized (API-validated + unit-tested; opt-in, GPU run pending) (#590) (#637)
* feat(engines): Confucius4-TTS scaffold (opt-in, needs hardware validation) (#590)
Plumbing for netease-youdao's Confucius4-TTS — LLM-based 14-language
cross-lingual zero-shot voice cloning, Apache-2.0 — mirroring the opt-in
subprocess-venv pattern of dots.tts / MOSS-TTS-v1.5:
- engines/confucius4/__init__.py: Confucius4Backend(SubprocessBackend), CUDA-only
(gpu_compat=("cuda",)), language passthrough, ref_audio→prompt_wav. is_available
reports a clear reason and stays unavailable without a clone.
- bootstrap.py: dedicated Python 3.10 venv resolution (user clone-level venv →
package venv → uv bootstrap), import-probed on `confuciustts`.
- main.py: sidecar speaking the same length-prefixed JSON-over-stdio protocol as
the other engines, calling ConfuciusTTS(config_path, device).generate(text,
lang, prompt_wav).
- Registered lazily in _LAZY_REGISTRY; docs/engines/confucius4-tts.md.
Gated behind OMNIVOICE_CONFUCIUS4_TTS_DIR — inert on every default install, never
imports the upstream package unless opted in. The sidecar's synthesis API is
derived from the upstream README and is NOT yet validated on a CUDA box; the
module, docs, and CHANGELOG all flag this. 4 tests pin registration +
inert-by-default. No version bump.
* fix(#590): register Confucius4 in install-hints + docs inventory (CI gates)
Registering the engine tripped two completeness gates: every backend needs an
install_hint (test_issue_fixes) and every registry engine must appear in the
tts_engines docs inventory + README (check-docs-drift). Add the install_hint,
the docs/features.yaml entry, and the README engine-table row (with the scaffold
caveat). Docs-drift clean; gates pass. No version bump.
* feat(confucius4): finalize — validate API vs upstream, add 22 sidecar unit tests, document external deps (Amphion/w2v-bert/weights)
The synthesis API (ConfuciusTTS(config_path, device) → generate(text, lang,
prompt_wav) → tensor, model.sample_rate) is confirmed against the
netease-youdao/Confucius4-TTS repo. Added runnable unit tests for the sidecar's
pure logic (language norm, tensor→PCM mono/stereo/clip, config resolution, wire
framing, synthesize dispatch with the model mocked) — 22 cases, all green.
Docs now list the external deps (Amphion/MaskGCT codec, facebook/w2v-bert-2.0,
~2-4GB HF checkpoint) and CUDA 12.6. Softened the scaffold warnings to reflect
API-validated + unit-tested status; a one-time CUDA GPU run is still needed to
confirm live inference + true sample rate.
---------
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(theme): remove stray token-border frames + make accent family theme-track (#864)
Task 1 — physically remove the token-based structural border utilities that
kept rendering stray frames (history panels, cards, rows, settings) whenever a
`--*-border` token didn't resolve transparent (theme re-declare, or bare
`border` = currentColor under Tailwind v4). Converted every
`border[-trbl]-[var(--chrome-border…)]` / `[var(--color-border…)]` (83
occurrences across 32 components/pages) to `border-transparent` — keeps the 1px
box (no layout shift, matches the badge.tsx convention), drops the frame, and
active/selected state stays visible via the existing bg-tint/text cues. Also
converted button.tsx's `border-border`/`border-input` variants and Panel's
header divider. Kept: focus-visible rings, aria-invalid, dashed drop-zones, and
the waveform/segment editor. Strengthened tests/test_no_literal_borders.py with
`test_no_token_border_utilities_in_jsx` so a reintroduced token border fails CI
(allowlists the editor + shadcn form-control primitives).
Task 2 — aliased the accent family in the base :root to the themed brand token
(`--chrome-accent: var(--color-brand)`, `-bg`/`-border` via color-mix), so
donate/support/commercial CTAs, active tabs, .btn-primary, status pills and
GoalBar/Pip track the active theme instead of the fixed pink. Replaced the
hardcoded `#d3869b`/`#f3a5b6`/`rgba(243,165,182,…)` pinks and the DONATE_HUE
constant in SupportPage.jsx with `var(--color-brand)` tints.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(ui): remove the SECOND history aside's border (missed by #860's replace_all — different indentation) + filter-chip borders (#862)
Co-authored-by: mergetest <test@local>
fix(theme): theme-aware native selects — color-scheme per theme + token-driven caret/options/focus ring (#861)
Native <select> chrome (option popups, scrollbars, form UA elements) rendered
in the OS light scheme on dark themes because `color-scheme` was never set as a
property (only a `prefers-color-scheme: light` media query existed, which is
not the same thing). The dropdown caret was also a hardcoded gray SVG that
ignored theme + accent. Owner report: gallery/install/language selects looked
wrong for accent + dark/light.
- Declare `color-scheme: dark` on :root (default Gruvbox Dark) and re-assert it
on every [data-theme] block. All six shipped themes are dark (verified by
their real --color-bg lightness: midnight #0f172a, nord #2e3440, solarized
#002b36, rose-pine #191724, catppuccin #1e1e2e), so all get `dark`. The empty
auto/light scaffold is left at dark (no light theme ships yet; a light value
there would mismatch the still-dark surface) with a note for when one lands.
- Replace the hardcoded %23a1a1aa caret in select.input-base and .ui-select with
a single --select-caret token, overridden per theme to that theme's muted
foreground (a background-image SVG can't read a CSS var, so the color is baked
per theme). Both selects consume the one token (DRY).
- Paint <option>/<optgroup> from --color-bg-elev-1 / --chrome-fg so Chromium
(Windows/Linux) popups match; macOS WebKit popups follow color-scheme.
- Give selects a themed focus-visible ring (--color-ring → --color-brand) to
match the buttons/checkboxes tokenized last phase, instead of the
non-theme-tracking --chrome-accent.
Borderless guardrail and :focus-visible rings intact. Covers every named
native select (DubTab/AudiobookTab language, DubLeftColumn engine, gallery,
ui/Input.jsx Select, VoicePreview, StoriesEditor, ExportModal, DubSegmentRow)
via the shared input-base/ui-select rules — no per-call-site edits needed.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(ui): physically remove the panel-frame border utilities on history + active-voice panels (#860)
#857 zeroed the border TOKENS but left token-based border utilities
(border-t-[var(--chrome-border-strong,…)], border-b-[var(--chrome-border)]) in
the JSX — a fragile indirection that still renders a line if the token doesn't
resolve transparent (stale HMR / the pre-zero rgba base value). Per 'no borders
whatsoever', remove the utilities outright from the WorkspaceHistory (dub +
regular history) and WorkspaceVoices (active-voice) panel frames; the
active-voice card keeps its background tint as the selection cue.
Co-authored-by: mergetest <test@local>
style(controls): unify buttons/inputs/selects/checkboxes/toggles onto design tokens (Phase 2) (#859)
* style(controls): Phase 2 — tokenize + unify buttons/inputs/checkboxes/toggles onto design tokens
Phase 2 of the borderless styling pass. Converts interactive controls to
design tokens with a cohesive, theme-tracking active/checked affordance,
building on Phase 1's borderless base. No behavior changes — visual/token only.
Shared primitives (highest leverage):
- ui/button.tsx: replace literal `hover:bg-white/[0.04]` (subtle/softGhost/
chip/preset/iconBtn) with `hover:bg-[var(--chrome-hover-bg)]`.
- ui/toggle.tsx (seg): drop hardcoded `text-[#fff9ef]` active text and hover
white literal for `text-fg` + `--chrome-hover-bg`.
- ui/Segmented.jsx: recessed track `bg-black/[0.28]` -> `bg-bg-elev-2`.
- index.css: native checkbox `accent-color` and range-input thumb/track/active
moved off non-themed `--chrome-accent` / legacy `--text-primary`/`--primary`/
raw rgba onto themed `--color-brand` / `--color-fg` / `--color-bg-elev-2` +
radius/shadow/duration tokens. Checked state is now brand-tinted and recolors
per [data-theme], matching sliders/segmented/primary buttons.
- SettingsToggle: on-state -> `--color-brand`, focus ring -> `--color-ring`,
knob shadow -> `--shadow-sm`, radius -> `--radius-pill`.
Control call sites (exact-token swaps, remove hardcoded hex/rgba):
- Unified every checkbox `accent`/`accentColor` override onto `--color-brand`
(DubbingDemo, DubRightColumn, DubLeftColumn, IdleSkeleton, DubFooter,
DubSegmentRow, AppearancePanel range).
- FooterBtn blue/orange tones -> --color-info/--color-warn.
- MicButton danger tint/neutral fill -> tokens.
- DubLeftColumn install CTAs + engine chip -> brand tokens + --radius-pill.
- NetworkToggle: neutral fg/hover tokens; removed stray `#504945` fallback border.
Focus rings and the borderless guardrail (tests/test_no_literal_borders.py)
intact. build + format:check + lint (0 errors) + guardrail all green.
* test(dub): assert the tokenized brand-accent install button (bg-[var(--color-brand)]) after Phase 2
Phase 2 tokenized the highlighted Install CTA from the hardcoded #d3869b to
var(--color-brand); update the two assertions to match.
---------
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
chore(version): pin main to 0.3.8 (revert the post-release auto-bump) (#858)
* Revert "chore(version): main -> 0.3.9 after v0.3.8 release"
This reverts commit 7489bef085.
* chore(release): gate the post-release version-bump behind AUTO_VERSION_BUMP (owner controls bumps)
Owner decision (2026-07-01): keep main pinned to the released version and bump
only on explicit request. The version-bump job now runs only when the repo
variable AUTO_VERSION_BUMP == 'true' (default off), so releasing no longer
auto-rolls main to +1. Documented the override in CLAUDE.md's versioning rule.
---------
Co-authored-by: mergetest <test@local>
feat(ui): app-wide decorative border/divider removal (keep focus rings, bg cues) (#857)
Remove decorative borders, hairlines, dividers, and panel frames across the
frontend for a flat, frameless look. Selection/active state and input fields
stay perceivable via background/elevation cues; keyboard :focus-visible focus
rings are preserved.
index.css:
- Append a final `:root, [data-theme]` block zeroing every border token
(--color-border[-strong|-warm], --chrome-border[-strong], --chrome-accent-
border, --glass-border) → transparent. Kept last so it wins over the default
root and all [data-theme] overrides. --color-ring / --focus-ring untouched.
- .glass-panel::before decorative top-highlight → display:none.
- Zero 22 neutral (white/black rgba) literal hairline borders (history divider,
segment table, override toggles, etc.).
- Selection cue: .project-active border → transparent, stronger bg tint.
- Inputs: .input-base / textarea.input-base get a recessed --color-bg-elev-2
fill (was --chrome-hover-bg / --chrome-bg which equalled the panel bg) so
fields stay visible without a border; subtle elevation shift on focus.
- .history-kind--audio colored pill border → bg tint.
JSX/TSX:
- 79 literal-color border utilities (border-white/black, border-[#|rgba|
color-mix]) → border-transparent (width kept: app ships without Preflight,
so a bare button keeps a UA border).
- badge.tsx / button.tsx colored tone/active variants → border-transparent
(bg fill + text color carry the tone); outline badge gains a bg.
- Bare `border` on shadcn card/dialog/select/dropdown content + ui/Tabs →
border-transparent (no-Preflight currentColor line).
- Active/selected chips (StoriesEditor track, HfTokenCard, FirstRunSetup
option, WorkspaceHistory/Sidebar/WorkspaceVoices kind pills) → background
tint instead of accent border.
- 9 inline style borderColor → removed or swapped to a background tint
(selection/error stay perceivable).
Kept intentionally: :focus-visible / border-ring focus rings, aria-invalid
error borders, the waveform segment editor (SegmentTrack) functional
boundaries/handles/selection, drag-active dropzone accent, and severity-token
state cues — these are functional affordances, not decorative chrome.
Guardrail: tests/test_no_literal_borders.py fails if the regression class
reappears (neutral literal borders in index.css, literal-color border
utilities / inline borderColor in jsx/tsx) and asserts focus tokens survive.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
chore(version): main -> 0.3.9 after v0.3.8 release
fix(diagnostics): harden the bug-report scrubber (5 audited leak/correctness gaps) (#856)
* fix(diagnostics): harden the bug-report scrubber against 5 audited leak/correctness gaps
Audit of the (already-on-main) diagnostics/bug-report feature found the opt-in/
no-telemetry contract clean but 5 real gaps in the redaction + URL assembly.
Fixed in both scrub twins (backend/core/scrub.py + frontend utils/bugReport.js):
- Windows home paths with lowercase 'users' now redact (case-insensitive) — a
spec-level PII leak: c:\users\john\… kept the username verbatim.
- Broadened credential shapes (JWT/Bearer, Google AIza, Slack xox, AWS AKIA) +
a URL query-secret pass (?token=/?api_key=… → value redacted, name kept) so a
secret propagated from a backend error into error.message/.stack can't reach a
public issue. The webview has no env backstop, so these shapes are its only
defense.
- Boundary-safe $HOME replace: a home of /Users/john no longer rewrites
/Users/johnny to '~ny' (fragment leak + path mangling).
- Bug-report URL now bounds the URL-ENCODED body length (~7k), not the raw
length — a dense 6k markdown body encoded to ~9k and blew past GitHub's ceiling
(silent truncation / failed open). Message body is capped too.
- 9 new scrub regressions (backend) + 9 (frontend); all green. No API/behavior
change beyond stricter redaction.
* docs(changelog): note the bug-report scrubber hardening in [0.3.8] (#856)
---------
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat(dub): consolidate pipeline stepper and title/meta into one header row (#855)
Merge the two stacked dub-editor header rows into a single line to save
vertical space. The pipeline stepper (Upload → … → Export) is now inlined
onto the DubHeader row alongside the title, duration · N segs metadata, and
the primary action buttons (Generate Dub / QC / Export). All step
active/complete styling, data bindings, and button onClick/disabled/loading
props carry over unchanged.
- DubPipelineStepper gains an `inline` prop → `dub-stepper--inline` variant
(drops the standalone border-bottom/padding, tighter connectors).
- DubHeader renders the inline stepper as the leftmost element; the row is
flex-wrap so it wraps gracefully on narrow windows.
- DubTab only renders the standalone spine before the editor exists, so the
stepper is never duplicated once the editor (and inline spine) is shown.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat: LLM Providers page + Autofit translation quality (fit-to-segment-time) (#838) (#854)
* feat(llm): multi-provider LLM registry + encrypted key storage + settings API (v0.3.8, phase 1)
Foundation for the LLM Providers settings page and timing-aware (Autofit)
translation. Every provider in the shipped .env is OpenAI-compatible, so one
client drives all of them via a registry instead of a class-per-provider.
- llm_providers.py: registry of 16 providers (OpenAI, OpenRouter, Groq,
Cerebras, Google AI, Mistral, Cohere, NVIDIA, GitHub Models, Cloudflare,
HuggingFace, SambaNova, SiliconFlow, + local Ollama/LM Studio + Custom).
Field resolution precedence env → encrypted store → default; active-provider
selection (LLM_DEFAULT_PROVIDER → stored → first keyed remote; local requires
explicit pick so we never assume a local server is up). Legacy TRANSLATE_*
maps to the Custom provider (keyless-with-base_url preserved).
- settings_store.py: generic ENCRYPTED secrets (get/set/clear_secret,
list_secret_names) reusing the HF-token Fernet path; get_text/set_text now
refuse the secret namespace (no ciphertext leak).
- llm_backend.py: OpenAICompatBackend resolves the active provider's
base_url/key/model from the registry. Backward-compatible.
- settings API: GET /llm-providers, PUT /llm-providers/{id} (encrypted key +
overrides), POST /llm-providers/active, POST /llm-providers/{id}/test.
Loopback-gated; never returns key material.
- 11 registry tests; existing llm-endpoint/openai-available tests still green.
* feat(settings): LLM Providers page — configure any provider's key/URL/model + Test + set active (v0.3.8, phase 2)
New Settings → System → LLM Providers pane (Brain icon, searchable). Lists all
16 registry providers; pick one to configure its encrypted API key, base URL,
model (and Cloudflare account id), Test the connection with one round-trip, and
'Save & use for translation' to make it the active provider for Cinematic/
Autofit. Keys are write-only from the UI (masked placeholder, never echoed);
env-set keys show as read-only. Local providers (Ollama/LM Studio) need no key.
- LLMProvidersPanel.jsx: provider selector + per-provider config + Test/activate,
following the LLMEndpointPanel pattern (apiJson/apiFetch/apiPost, SettingsSection
primitives).
- settingsCategories.jsx: new 'llm-providers' category under System + Brain icon.
- Settings.jsx: route the category to the panel.
- en.json: settings.llm_providers label.
- Frontend build passes.
* feat(translate): Autofit quality style + one-click LLM setup from the dub menu (v0.3.8, phases 3-4)
Autofit = Cinematic + a strict 'never exceed the segment time' fit. The LLM
rewrites each translated line so its target-language reading time fits within
the slot, preserving the video timing without harsh audio time-stretch.
Backend:
- speech_rate.adjust_for_slot(strict=): strict caps the accepted upper ratio at
1.0 (fit within slot) vs Cinematic's 1.08; best-effort, degrades gracefully
with no LLM.
- dub_translate: quality='autofit' takes the LLM refine path and runs the fit
pass with strict=True; reports quality_used accurately.
- TranslateRequest.quality doc note.
Frontend:
- 'autofit' added to the quality control (Settings Translation + dub menu) and
the TranslateQuality type.
- Dub menu: picking Cinematic/Autofit with no LLM no longer dead-ends on a toast
— it offers a one-click 'Set up' that routes to Settings → LLM Providers,
with copy about fitting translations to segment time (#838).
- 4 strict-fit tests; frontend build green; i18n keys added.
* docs(translate): document Autofit quality + the LLM Providers page (v0.3.8, phase 5)
- CHANGELOG [0.3.8] Added: Autofit style + LLM Providers page.
- docs/dubbing/translation-engines.md: Fast/Autofit/Cinematic quality section
and an LLM Providers setup section (16 providers, encrypted keys, offline
Ollama/LM Studio, env overrides).
* test(api): add /api/settings/llm-providers routes to the route-inventory snapshot
Regenerated tests/fixtures/api_routes.txt for the 4 new LLM-providers endpoints
so test_route_inventory_matches_snapshot passes (keep-main-green).
* style(frontend): oxfmt the LLM Providers panel + dub quality control (format:check green)
---------
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(device): fall back to CPU when the GPU arch is unsupported, not 500 every generate (#756) (#757)
* fix(settings): contain + tighten the whole Settings surface (measure cap, container-query stacking, wrap the shared rows)
Two systemic issues drove 'too spread out' + 'elements go out of view' across
many Settings pages:
1. Spread — .settings-content capped at 1280px, so on wide windows every
label-left/control-right row left a huge void. Introduce a --settings-measure
token (720px, macOS-like) + --settings-rail, and cap the content to it,
left-aligned under the nav. One token now controls the reading width.
2. Overflow + bad responsiveness — the row stack break was a *viewport* media
query (560px), but the 168px nav rail means a 760px-viewport window only has
~530px of content, so rows went side-by-side in a cramped box. Make
.settings-content a container (container-type: inline-size) and stack on the
CONTENT width via @container, keeping the viewport @media as a fallback for
the .st-row instances used outside Settings (Splash/FirstRun/Dub/SetupWizard).
3. The shared .perfpanel__row (button/badge row reused by 6+ panels:
RemoteBackend, HFMirror, LLMEndpoint, Pronunciation, MCPBindings, …) was an
inline-flex with no wrap and no max-width, so it ran off the right edge —
add flex-wrap + max-width:100% + min-width:0. Plus two rigid-width fixes that
escaped the row cap: ApiKeys input min-width:220→0, Appearance scale floor.
Frontend builds clean; tokens, @container query, and the wrap all verified in the
emitted CSS bundle.
* fix(settings): center the settings block + tighten measure (kill the lopsided right void)
The capped content was left-aligned, so on a wide window everything jammed to the
left with a dead empty third on the right (screenshot). Center the whole settings
block (nav rail + content) as a unit via max-width + margin-inline:auto, and drop
the measure 720→660 so label→control rows read denser. The cap is computed from
the tokens (rail + gap + measure + page padding) so the content track lands
exactly at --settings-measure.
* fix(device): fall back to CPU when the GPU arch is unsupported, instead of 500-ing every generate (#756)
get_best_device() called check_device_compatibility() and, on an unsupported
compute capability, only LOGGED a warning then still returned 'cuda' — so the
model loaded on a GPU whose kernels can't launch and every generate 500'd with
'CUDA error: no kernel image is available for execution'. Both a too-old card
(Pascal sm_61, GTX 10-series) and a too-new one (Blackwell sm_120 on pre-cu128
wheels) hit this.
Now an unsupported arch falls back to CPU (works, just slower) with a clear
warning; OMNIVOICE_FORCE_CUDA=1 overrides. Belt-and-suspenders: _oom_friendly_reraise
classifies a raw 'no kernel image is available' as an unsupported-GPU error
(switch to CPU / install matching torch) rather than the OOM/Flush message.
Tests: get_best_device → cpu on incompatible, stays cuda on compatible, honors
the force override; reraise gives the actionable GPU message, not OOM.
* test(device): patch detect_host_caps via string path so the #756 fallback test is full-suite robust
The first version aliased the import + inserted backend on sys.path, which patched
a module copy get_best_device's local 'from core.device_caps import detect_host_caps'
didn't resolve in the full suite (passed alone, failed in CI). Use the string-form
monkeypatch target; verified passing alongside the other device/model tests.
* docs(changelog): fold #757 device-fallback entry into [0.3.8]; drop the merge's stale [Unreleased] dupe
---------
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
docs(changelog): cut v0.3.8 — fold Unreleased into the 0.3.8 release section (2026-07-01) (#852)
Renames [Unreleased] to [0.3.8] — 2026-07-01 and merges the settings-hub
redesign, translation/network/factory-reset panes, the GPU-pool generate-hang
fix (#851), and the translation-banner fix into the release section so
release.yml extracts a complete, house-style body at tag time.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(tts): bound + reset the GPU pool on a hung generate so it can't brick the backend (#730 class) (#851)
* fix(tts): bound + reset the GPU pool on a hung generate so it can't brick the backend (#730 class)
A GPU job that wedges on some Windows+CUDA setups occupies its worker
forever — run_in_executor can't cancel the thread — so on the 1–2 worker
pools we ship, one stuck job starves every other request and the next
action surfaces as the misleading "Can't reach the local backend" even
though the process is alive.
ASR/dub/model-load already bound+reset the pool on hang (#730). The TTS
**generate** paths (generation.py, tts_stream.py) were the last unguarded
GPU dispatch — and the residual on-main reports (#850#802#755#723#721,
plus the 0.3.7 generate cohort) all fail on generate:start (audio).
- model_manager: add run_on_gpu_pool_guarded() + GpuJobTimeoutError, a
generalized version of the ASR guard so every GPU dispatch shares one
bound+reset recovery path. Env-tunable via OMNIVOICE_GENERATE_TIMEOUT_S
(default 300s).
- generation.py: route both inference branches + the reference-clip
transcribe through the guard; map a timeout to an actionable 503.
- tts_stream.py: same guard on the streaming path (timeout → error frame).
- test_generate_timeout_730: fail-before/pass-after regression (timeout
resets pool + restores capacity, happy path, env override, no-reset exec).
- docs + CHANGELOG: extend troubleshooting §14 to cover generate; document
the new env var.
* fix(tts): extend the GPU-pool hang guard to batch/dub/archetype/openai-compat generate (#730 class)
The generate-hang class wasn't only in Studio + streaming: batch generate,
the dub per-segment + preview generate, archetype preview render, and the
OpenAI-compat /v1/audio/speech path all dispatched the TTS model to the GPU
pool with no wall-clock bound either. Any one of them wedging on a
Windows+CUDA hang starves the pool and bricks the backend the same way.
Route all of them through run_on_gpu_pool_guarded so the whole class is
closed — a hung generate anywhere resets the pool and returns an actionable
timeout instead of a dead backend. Batch/dub recover per-segment on a fresh
worker; drop the now-dead loop/_gpu_pool/asyncio locals ruff flagged.
---------
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(theme): restore per-theme chrome recoloring — default :root was clobbering [data-theme] overrides (P5 consolidation regression) (#849)
The color themes (Midnight, Catppuccin, Nord, Solarized, Rose Pine) stopped
recoloring the app chrome — the Settings hub, header, footer and everything
else that reads var(--chrome-*) stayed the default dark on the real app.
Root cause: in the real app `data-theme` is set on <html>, and <html> IS
`:root` (documentElement === :root). The P5 tokens consolidation inlined the
default legacy/chrome `:root` block (--chrome-bg:#0f1011, …) AFTER all the
[data-theme] blocks. A plain `:root {…}` and a `[data-theme="x"] {…}` both
match that same element at EQUAL specificity (0,1,0), so source order is the
only tiebreaker — the later default `:root` won and clobbered every theme's
--chrome-*/--color-* overrides. The visual-regression suite kept passing
because its harness applies `data-theme` to a WRAPPER div (a closer ancestor
that wins by proximity, not source order), so it never exercised the <html>
path where the bug lives.
Fix (source order, not specificity): reorder index.css so every default
`:root` block precedes all `[data-theme]` blocks. The [data-theme] blocks
(+ the @media prefers-color-scheme:light theme block) now sit LAST, after the
default legacy/chrome `:root`. The `[data-theme="x"]` selectors are unchanged
(bumping to `:root[data-theme="x"]` would stop matching the wrapper-based
harness and break the 48 snapshots).
Crucially the Tailwind v4 region is left byte-for-byte intact: the @theme base
and the adjacent `@theme inline` shadcn bridge keep their exact positions.
Moving a `:root` between/across them changes the GENERATED CSS (`@theme inline`
stops inlining, so shadcn utilities lose their brand color) — so instead of
lifting the default :root above @theme, the [data-theme] blocks are lowered
below it. Verified: the compiled CSS is byte-identical to before (260686 B),
and every token value is preserved byte-for-byte (pure reordering).
Regression test: src/test/themeCascade.test.js replays the documentElement
cascade from index.css source order and asserts each theme's --chrome-bg/-fg
wins over the default :root. Fails-before / passes-after. Verified live in
Chromium too: getComputedStyle(documentElement)['--chrome-bg'] now resolves to
#0f1011 (default) / #1e293b (midnight) / #313244 (catppuccin) / #3b4252 (nord).
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(settings): convert un-migrated panel controls to design-system primitives (theme-consistent inputs/buttons/selects) (#848)
Several Settings panels were re-hosted in the redesign without converting
their raw <input>/<select>/<button> to the design system, so they rendered
as native UA controls (white input fields, light-gray buttons, system
fonts) that ignored the theme tokens — jarring on the dark chrome. Convert
every native control in the affected panels to the shared primitives
(SettingsInput / ui Button / ui Select / ui Badge) so all of Settings
themes coherently in every palette.
Panels fixed:
- LLMEndpointPanel: Ollama/LM Studio/vLLM/OpenAI preset chips -> Button
(preset); Base URL / Model / API key -> SettingsInput (mono); Save ->
Button (subtle/sm, loading); reachable/not-configured status -> Badge
(success/warn, dot).
- HFMirrorPanel: mirror preset chips -> Button (preset); Save -> Button
(subtle/sm, loading). (HF_ENDPOINT was already SettingsInput.)
- PronunciationPanel: add-entry term/replacement/language + test inputs ->
SettingsInput; type selector -> ui Select; per-row enable checkbox ->
SettingsToggle; type/scope pills -> Badge; Add + per-row delete ->
Button (subtle/sm, danger/sm).
- RemoteBackendPanel: Test connection + Save & reload -> Button
(subtle/sm, loading); probe result -> Badge (success/danger, dot).
- MCPBindingsPanel: client-id input -> SettingsInput; voice select ->
ui Select; Bind -> Button (subtle/sm); per-binding profile pill ->
Badge; delete -> Button (danger/sm).
Also dropped the perfpanel__row / perfpanel__badge / perfpanel__checkbox
class usages from these panels (replaced by primitives + token flex
utilities). The perfpanel CSS block lives in src/index.css (owned by an
in-flight theme-cascade change), so it was left in place; the remaining
perfpanel__error / perfpanel__help references are token-based themed
banners, not native controls.
Behavior, handlers, state, endpoints, and all data-testids are preserved.
No new user-facing strings (styling-only). Gates: vite build, oxlint (0 on
touched files), oxfmt --check clean, vitest 645 pass, 48 visual snapshots
unchanged, bun install --frozen-lockfile clean. Live eyeball across all 5
categories in default + catppuccin themes confirms no white fields / no
native buttons.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat(translate): highlighted Install affordance for uninstalled engines + dismissable/auto-clearing error banner (#847)
Two related Dub-tab translation-flow fixes, one PR.
TASK 1 — proactive, highlighted Install affordance in the translate engine
selector (replaces "find out only via a translate-time 400"):
- FROM-SOURCE lane (activeEngineUnavailable && !enginesSandboxed): the muted
install chip is promoted to a HIGHLIGHTED brand-accent Install button, still
wired to handleInstallEngine(translateProvider) with the installing/disabled
state. Selecting any uninstalled engine surfaces it immediately.
- FROZEN lane (enginesSandboxed): pip install is impossible in the read-only,
signed packaged env, so the disabled "needs dev install" span becomes an
equally highlighted button opening a popover with (1) the exact install
command + copy-to-clipboard, (2) one-click "Switch to Argos (bundled,
offline)" — the guaranteed importable escape hatch, and (3) a Docs link via
the existing Tauri shell.open path. Gated on the existing `sandboxed` flag,
not platform.
- Single-source install command: new translation_engines.install_command()
is the one source of truth; list_engines() stamps `install_command` per
engine and BOTH the argos + deep_translator translate-time 400 messages build
their command from it, so the proactive button and the 400 can't drift.
engines.ts gains `install_command: string | null`.
TASK 2 — the translation error banner now dismisses and clears (class fix):
- Root cause: handleTranslateAll never cleared dubError, so a stale 400
survived even a successful retry. It now clears at the start of every
attempt.
- Corrective-action clears (whole class): changing the engine and installing
the package both clear dubError (wrapped setTranslateProvider +
handleInstallEngine in DubTab).
- DubFooter's banner gains a × dismiss and a guarded auto-timeout (skipped
while generating so live per-segment errors persist).
i18n: 8 new dub.* keys translated across all 21 locales. Docs: new
docs/dubbing/translation-engines.md (from-source vs packaged build) linked from
the popover Docs button + a troubleshooting cross-reference. Tests: FE
regression for both lanes + never-installs-when-sandboxed + banner
dismiss/auto-clear; BE regression that list_engines() install_command is
embedded verbatim in the dub_translate 400s.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(dub): show transcribing/progress view instead of idle dropzone while the pipeline runs (#846)
The Dub stepper could show Upload ✓ → Prepare ✓ → Transcribe (active) while the
main content pane still rendered the IDLE upload dropzone ("Drop video or audio
here" + paste-URL input + "Pull YouTube captions"). Contradictory: if the
pipeline is transcribing, the pane must reflect that stage, not the landing.
Root cause (frontend/src/components/dub/IdleSkeleton.jsx): the main-view branch
keys off the non-serialisable local File `dubVideoFile`. That File is only set
on the drag/drop + file-input path — never on the URL-ingest path (and not on a
restored job). The `dubVideoFile ?` branch correctly renders both the prepare
(PrepOverlay) and transcribe (TranscribeOverlay) overlays via the WaveformTimeline,
but the no-file branch only handled `dubStep === 'uploading'` (PrepOverlay large)
and otherwise fell straight through to the idle dropzone. So a URL-ingested job
in `dubStep === 'transcribing'` (no File) rendered the dropzone — the exact
desync in the screenshot.
Not a #818 regression: the no-file branch never handled `transcribing`. It was
identical before #818 (verified against 9d79bb8) — a pre-existing gap that only
bites the URL-ingest / restored-job paths.
Fix (whole class, recurrence-proof):
- Add a `dubStep === 'transcribing'` case to the no-file path that renders
TranscribeOverlay, symmetric to the existing `uploading` → PrepOverlay case.
This covers URL-ingest AND restored/resumed jobs that lack a local File.
- Gate the idle dropzone on `dubStep === 'idle'` so it can render ONLY when
genuinely idle; any other non-idle no-file step (e.g. `stopping`) shows a
neutral working indicator instead of falling back to the dropzone. This makes
it structurally impossible to show the dropzone during an active pipeline.
All existing behavior/handlers preserved (failure banner + retry still show in
the idle-after-failure state, since that sets dubStep back to 'idle').
Regression test: frontend/src/test/DubIdleSkeleton.test.jsx — asserts the
dropzone renders only when truly idle, is hidden (and the transcribe overlay
shown) while transcribing a URL-ingested job, is hidden while preparing, and
never falls back to the dropzone for a non-idle no-file step. Fails before /
passes after.
Verified live (Playwright, real backend): before → transcribe stage shows the
dropzone (transcribingHasDrop=1, overlay=0); after → shows the transcribe
overlay (transcribingHasDrop=0, overlay=1), idle still shows the dropzone,
reset returns to idle.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(settings): right-anchored controls fill leftward on the full-width hub (#845)
The full-width Settings hub (#843) left every right-aligned SettingRow
control capped at `max-w-[60%]`, so wide fields (text/URL/key inputs,
selects, textareas) sat cramped against the right edge with a big empty
gap to the label. Read-only mono values ("0.3.8", version strings) also
wrapped character-by-character because `[overflow-wrap:anywhere]` collapsed
the auto grid cell to a 1-char min-content, and removing the content
measure spread rows edge-to-edge on wide/ultrawide screens.
SettingRow.jsx:
- Widen the control grid track to `minmax(0,1fr) minmax(0,1.9fr)` only
when the row contains a real field (`has-[input:not(checkbox/radio/range)]`,
`has-[select]`, `has-[textarea]`), gated to `@min-[601px]/settings` so the
narrow-container stacking is untouched. Toggles (checkbox), Segmented /
Slider (Radix), and buttons don't match, so short controls keep the `auto`
track and stay compact, right-pinned.
- Lift the `max-w-[60%]` cap to `max-w-[85%]`; make the control cell `w-full`
(has-gated) so wide fields fill the widened track leftward to a clean right
edge. Existing `w-full` fields fill; short controls unaffected.
- Fix mono/read-only wrapping: `[overflow-wrap:anywhere]` -> `break-word` and
the percentage `max-w-[75%]` -> length-based `max-w-[42ch]`, so short values
render on one line (the percentage cap forced the auto track to min-content)
while long paths still wrap on boundaries.
Settings.jsx:
- Re-introduce a generous, centered content measure (`w-full max-w-[1100px]
mx-auto`) on the content column so rows fill from the middle instead of
spreading to the screen edges on wide/ultrawide displays; the rail stays
fixed. Wider than the old cramped 660px measure, capped for readability.
Verified visually with Playwright at 1400px (General, Translation, Network,
Credentials, Appearance, Dictation, About) and 700px (stacking intact). All
gates pass: vite build, oxlint, oxfmt, vitest (641), visual (48, no baseline
change needed), frozen lockfile.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(settings): sidebar items show UA button-gray in dark themes (#844)
The sidebar nav items are native <button>s and the non-active state set no
background, so with Tailwind preflight disabled they fell back to the browser's
default `ButtonFace` (light gray) — washed-out pills in the dark themes, and the
active item paradoxically looked darker (it got the subtle --chrome-hover-bg
overlay while inactive items showed UA gray). Add explicit `bg-transparent` +
`appearance-none` so items are theme-adaptive; active/hover keep the overlay.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat(settings): make the Settings hub full-width (#843)
Dropped the root max-width cap + mx-auto centering and the content pane's
reading-measure cap so Settings spans the full content area (rail + fluid
content) instead of sitting in a centered column with side gutters. The
`container-name:settings` inline-size container is preserved, so SettingRow's
narrow-width stacking still fires on the real content width.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
docs(contributing): reconcile file-size/co-location rules with the one-stylesheet end-state (#842)
The CSS consolidation (#837) collapsed all component CSS into src/index.css, so
the "hard 500 lines per .css" cap and "co-locate Foo.css" rule no longer apply.
index.css is the single intentional styling foundation (exempt from the cap);
styling is utilities + shadcn, not per-component files.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
docs(contributing): CSS guidance for the one-stylesheet end-state (#841)
The CSS consolidation (#837) folded every per-component stylesheet into
src/index.css — the note still implied component-level .css files exist for
keyframes/glass/hooks. Now: all styling lives in src/index.css; don't create
new component .css files.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
refactor(ui): consolidate component CSS into index.css — collapse to ~one stylesheet (#837)
Fold every remaining per-component stylesheet into src/index.css so the frontend
ships essentially ONE CSS file. index.css keeps its Tailwind v4 token foundation
(@layer order + @theme + [data-theme] + shadcn bridge) and now also carries, in a
clearly-commented "CONSOLIDATED COMPONENT STYLES" section, the former residual.css
plus all 28 component .css files — verbatim, unlayered, appended AFTER index.css's
own rules so the previous cross-file load order (index.css → residual.css →
component css) is preserved exactly. @keyframes move by name (all globally unique);
glass/backdrop-filter, cascade-override hooks, and library-DOM hooks (WaveSurfer
wfm-*, virtualized rows) keep winning over @layer utilities because they stay
unlayered. Zero visual/behaviour change — proven by the 48-snapshot visual suite
passing with no PNG diffs.
CSS files: 31 → 2 (src/index.css + src/test/visual/harness.css, test-only).
Deleted (29), each import removed from its component:
styles/residual.css, components/{Misc,firstrun,Sidebar,LogsFooter,CaptureWidget,
CompareModal,FloatingPill,DubSegmentRow,DubSegmentTable,SegmentTrack,VoicePreview,
WaveformErrorBoundary,WorkspaceHistory,WorkspaceVoices}.css,
components/dub/dub.css, components/donate/{DonateGoal,Postcard}.css,
components/settings/{AppearancePanel,PerformancePanel,VoicePanel}.css,
pages/{AudiobookTab,BatchQueue,Settings,VoiceGallery}.css,
ui/{Dialog,Menu,Table,Tooltip}.css
Kept: src/test/visual/harness.css (test-only harness chrome; not shipped).
Guard update: test/workspaceHistoryReflow.test.js now slices the WorkspaceHistory
block out of index.css by its provenance markers, so the #476 CTA-clipping
regression guard (no @media max-width, shell-class reflow, sticky action bar) still
holds on the relocated rules.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat(settings): rebuild Settings as a sidebar-nav hub with full app-level IA (#835)
* wip(settings): partial sidebar-hub redesign (recovered from killed agent)
Shell (sidebar/search/categories/restart-badge) + new panes (Network/Translation/Storage/PerformanceDevice) + partial panel rewiring. Not yet verified.
* feat(settings): finish + verify sidebar-hub Settings rebuild; changelog
Completes the partial sidebar-nav Settings redesign: confirmed all 16
categories are wired in Settings.jsx's renderCategory and render their real
panels with every store/pref/API binding preserved (theme→Appearance,
review-mode→General, proxy/ffmpeg→Network, provider keys→Translation — all
relocated, none dropped or duplicated). Verified search filtering, restart
badges, factory-reset dialog, narrow-width dropdown, and i18n key coverage.
Gates: vite build, oxlint (0), oxfmt --check, vitest (641 pass),
bun install --frozen-lockfile — all green. Adds the user-facing CHANGELOG
[Unreleased] entry required by the changelog hard rule.
* test(visual): refresh GeneralTab/AppearancePanel/StoragePanel baselines for the Settings redesign
The sidebar-hub rebuild changed three snapshotted panels: GeneralTab (lost
proxy/ffmpeg + theme, gained review mode), AppearancePanel (gained the
header-live-stats toggle), and StoragePanel (gained a RestartBadge header). The
recovery commit shipped stale baselines; regenerate all three across the default/
midnight/catppuccin themes so `bun run test:visual` is green against the new UI.
* i18n: backfill all 20 locales for the Settings redesign (and pre-existing drift)
The Settings rebuild added ~42 new keys to en.json; ran scripts/translate_all.py
to translate them into all 20 non-English locales (masking {{vars}}/<n> tags),
which also caught up pre-existing key drift — every locale is now at full parity
with en.json (0 missing keys). Satisfies the all-21-locales hard rule.
---------
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
docs(contributing): update CSS guidance for the shadcn/Tailwind end-state (#834)
The CSS→Tailwind/shadcn migration is largely complete: every screen is on
shadcn/ui primitives + Tailwind utilities, and the design tokens were
consolidated into a single foundation file (`tokens.css`/`themes.css` folded
into `src/index.css`'s @theme/[data-theme]). The old note still pointed at the
deleted `src/ui/tokens.css` and said "migration in progress".
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat(ui): rewrite demo/transcription/queue components on clean shadcn, delete their CSS (fast mode) (#833)
FAST-mode shadcn migration of the tail components — the demos, the
transcriptions history, the batch queue, and the audiobook tab — onto the
shared src/ui primitives (Button/Panel/Badge/Tabs) + Tailwind token utilities
(bg-card/text-fg/border-border + standard spacing), dropping each component's
stylesheet where the residual rules reduce cleanly to utilities.
Fully deleted (residuals inlined as utilities):
- DictationDemo.css — status pills, scripts grid, result boxes (gruvbox
hues preserved as arbitrary utilities; em → not-italic;
.dictation-demo/.__scripts class hooks kept for tests)
- DubbingDemo.css — container/loading shell, 720px collapse → max-[720px]:,
checkbox accent, pane-label/video, active chip
- Transcriptions.css — search input (placeholder:/focus:), item hover/active,
seg-title h4 → div (escapes the unlayered global h1-h4
rule); list scrollbar dropped as redundant with the
global ::-webkit-scrollbar
Trimmed to genuinely-irreducible only (import kept):
- BatchQueue.css — only the progress-fill gradient + ::after shimmer +
@keyframes remain; the bar heading (h1 → div role=
heading) and per-status card borders are now utilities
- AudiobookTab.css — only the <textarea> override (beats the unlayered
textarea.input-base + custom 900px floor) remains;
title (h2 → div role=heading), field labels (utility
const), body/side collapse (max-[900px]:), and the
redundant select width are now utilities
Behavior preserved: test class hooks intact, headings keep heading semantics
via role/aria-level. Verified: vite build, oxlint (0), oxfmt clean, vitest
641/641, visual 48/48, bun install --frozen-lockfile clean. Eyeballed all three
pages + states in the dev app.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat(ui): rewrite workspace/voice tail components on clean shadcn, trim their CSS (fast mode) (#832)
FAST-mode shadcn/Tailwind migration of the workspace + voice "tail"
components: the cleanly JSX-controlled chrome moves onto the JSX as
Tailwind v4 utilities (token utilities + arbitrary var()/px to preserve
exact pixels/colors), with irreducible CSS kept co-located.
- WaveformPlayer: all three render branches (player, native fallback,
missing notice) converted to Tailwind; WaveformPlayer.css deleted
(-87). The `wf-player__btn` class is retained as the focus-visible
hook for the shared a11y ring in index.css; the dead `wf-player__spin`
rule + `wf-spin` keyframe + its reduced-motion block were removed.
- VoicePreview: popover container/header/title/close/body/foot/hint
converted to Tailwind; VoicePreview.css trimmed 87->23 lines. Kept the
`voice-preview-in` entrance @keyframes (referenced via animate-[…]) and
the `.voice-preview__select`/`__text` rules — they layer on top of the
*unlayered* shared `.input-base`, and Tailwind utilities (in
@layer utilities) would lose that cascade, so they stay unlayered.
- WorkspaceHistory: finished the voice variant, which #781 left on the
now-deleted `.wh`/`.wh__head`/`.wh__title`/`.wh__scroll`/`.wh__empty`
classes (rendering unstyled). Converted them to the same Tailwind
utilities the dub variant already uses. Kept the studio-with-history/
studio-right/shell-narrow layout + the `.studio-action-bar` sticky
override (#476, guarded by workspaceHistoryReflow.test.js).
- WorkspaceVoices: already fully converted by #781; its `.wv*` chrome is
shared with the out-of-scope WorkspaceProjects.jsx, so the CSS stays.
Verified: vite build OK, oxlint exit 0, oxfmt --check clean, 641 vitest
pass (incl. workspaceHistoryReflow + waveform), 48 visual pass, bun
install --frozen-lockfile clean. Eyeballed the Voice workspace (history
rows + waveform players) and the VoicePreview popover in a live dev run.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat(ui): rewrite dialog/panel tail components on clean shadcn, delete their CSS (fast mode) (#831)
Migrate the tail dialog/panel components onto the shadcn-backed `src/ui`
primitives + Tailwind utilities, removing their bespoke stylesheets.
- BatchAddDialog: rebuilt on the `Dialog` primitive (header/body/footer +
Radix overlay/animation/focus-trap replace the hand-rolled overlay/card),
drop zone + toggle + select moved to Tailwind / the `Select` primitive.
BatchAddDialog.css deleted.
- KeyboardCheatsheet: rebuilt on the `Dialog` primitive; kbd pills, section
grid, rows and footer are now Tailwind utilities. KeyboardCheatsheet.css
deleted.
- CompareModal: kept as the deliberate non-modal bottom drawer (preserves the
"app stays interactive behind" behavior — a shadcn modal Dialog would
regress it). Inner content already rode the shadcn primitives; migrated the
two remaining CSS-class deps (`.compare-textarea--noresize` -> `resize-none`,
`.ui-compare__grid` base -> Tailwind `grid grid-cols-2`). CompareModal.css
slimmed to just the irreducible drawer chrome + slide-up keyframe; the
responsive one-column collapse stays owned by index.css via the retained
`ui-compare__grid` class hook.
- GlossaryPanel: table styling moved to Tailwind (`[&_th]`/`[&_td]`
descendant utilities); the `.glossary-panel .ui-panel__body` max-height
override replaced by a `max-h-[35vh] overflow-y-auto` wrapper.
GlossaryPanel.css deleted.
- Misc.css: removed only the CompareModal-owned `.compare-textarea--noresize`
rule; the rest is shared by out-of-scope components (CheckpointBanner,
DirectionDialog, App startup/wizard, AudioTrimmer) and is kept intact.
Behavior preserved exactly (batch add flow, cheatsheet overlay, compare
A/B, glossary add/edit). Verified: vite build, oxlint (0), oxfmt --check
clean, vitest (641 pass), visual (48 pass), bun install --frozen-lockfile.
Eyeballed all four via a temporary Playwright harness.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat(ui): migrate Settings models/reco/engines styling to shadcn, trim Settings.css (fast mode) (#830)
Migrate the last reducible CSS chunk in Settings.css — the recommendation
banner, the models/engines toolbar chrome, and the role-tab/search controls —
to Tailwind utilities (chrome tokens kept) at the JSX, following the established
shadcn fast-mode convention. Behavior and palette unchanged.
What moved to Tailwind:
- RecoBanner (.reco-banner* → utilities on models/RecoBanner.jsx)
- Models/Engines toolbar (.models-toolbar* → ModelStoreTab.jsx + EnginesTab.jsx),
including the previously-unstyled HF-token inline chrome
- Role tabs + search (.models-controls/.models-search/.models-roletabs)
What was deleted as dead CSS (zero consumers, grep-verified):
- the entire .engines-* block (EnginesTab already on shadcn; no consumer)
- the .models-table__body > .models-row override (selector no longer matches
the body > virtual > row DOM the table renders)
What was KEPT as irreducible styling hooks (cannot be utilities):
- .models-table* + .models-row* — the virtualized table geometry. Rows are
absolutely positioned with an inline translateY from the virtualizer; the
table body/virtual spacer and per-cell hooks must stay class-based.
Settings.css: 386 → 225 lines (−161). Not deleted (virtualized hooks remain).
Verified: vite build ✓, oxlint 0, oxfmt clean, vitest 641/641, visual 48/48,
bun install --frozen-lockfile ✓. Live-eyeballed Settings → Models (store +
17-row virtualized table + reco banner) and Engines (matrix + toolbar) against
the live backend; rows render correctly and chrome is coherent.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
refactor(ui): consolidate tokens.css + themes.css into index.css (P5, single foundation file) (#829)
Fold src/ui/tokens.css (134 lines) and src/ui/themes.css (195 lines) into
src/index.css so the design-token foundation lives in ONE file, then delete
the two source files and repoint every import. Pure consolidation — zero
behavior/visual change.
Cascade is preserved EXACTLY. The previous cross-file load order was
tokens.css -> themes.css -> index.css (ui/index.js imported the first two,
main-app.jsx imported index.css after). The inlined content reproduces that
order inside index.css: the token :root first, then the [data-theme] blocks,
then index.css's @theme bridge + its own legacy/chrome :root + rules. The
[data-theme] blocks intentionally sit AFTER the token :root but BEFORE the
legacy/chrome :root so the --chrome-* tokens (declared in both a plain :root
and the [data-theme] blocks at equal specificity) keep resolving by source
order exactly as before.
Imports updated:
- src/ui/index.js: the two token-CSS side-effect imports -> import '../index.css'
(preserves "import a primitive, get the full token scale" for every consumer).
- src/test/visual/harness.jsx: drop the tokens/themes imports, keep index.css.
- src/test/tokenParity.test.js: read the token :root from index.css (located by
its --color-muted-mono signature) instead of the deleted ui/tokens.css.
Verified: vite build OK; oxlint 0; oxfmt --check clean; vitest 641 pass
(incl. tokenParity); visual suite 48 pass with NO baseline changes (default/
midnight/catppuccin render pixel-identical); bun install --frozen-lockfile
clean. Live full-app check (data-theme on <html>) confirms semantic tokens
recolor per theme while chrome tokens hold the :root value — identical to
pre-consolidation behavior.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat(ui): migrate settings primitives + panels to shadcn, delete primitives/Settings CSS (fast mode) (#828)
FAST-mode shadcn migration of the shared Settings primitives and their ~8
consuming panels onto Tailwind utilities layered on the OmniVoice
`--chrome-*` / `--space-*` token bridge — palette and behavior preserved
exactly (every migrated snapshot is pixel-identical to its old-CSS baseline).
Primitives migrated off the `.st-*` CSS class family (all in JSX now):
- SettingsSection → token-bridge Card surface (exported SETTINGS_SECTION_SURFACE
+ `data-slot="settings-section"` so the raw EnginesTab / ModelStoreTab sections
and the Settings.css table hooks stay coupled without `.st-section`).
- SettingRow → Tailwind grid; new `stack` prop replaces the `st-row--stack`
className; control slot carries `data-slot="setting-row-control"`. Row-stacking
reproduced with the Tailwind v4 named-container variant `@max-[600px]/settings:`
plus the legacy `max-[560px]:` viewport fallback.
- SettingsToggle, SettingsInput, InfoHint, Collapsible → Tailwind utilities.
Consumers updated to the new API:
- GeneralTab, StoragePanel, CredentialsTab, AppearancePanel, HFMirrorPanel,
RemoteBackendPanel: `st-row--stack` → `stack` prop; raw `.st-input` inputs →
SettingsInput; raw `.st-section` (EnginesTab, ModelStoreTab) → token surface +
data-slot.
- AppearancePanel.css / VoicePanel.css `.st-row__control` hooks →
`[data-slot=setting-row-control]`; Settings.css `.st-section` hooks →
`[data-slot=settings-section]`; `.models-search.st-input` → `.models-search`.
Deleted primitives.css (368 lines) and removed its imports (primitives barrel +
visual harness). The `.models-*` / `.reco-*` / `.engines-*` table families in
Settings.css are intentionally LEFT intact (out of `.st-*` scope).
Verified: vite build ✓, oxlint 0, oxfmt clean, vitest 641 ✓, visual 48 ✓
(baselines pixel-identical — only the harness CSS import changed),
bun install --frozen-lockfile ✓, and a live Playwright eyeball of Settings →
General / Appearance / Models / Engines (incl. embedded Storage / Performance /
HF-mirror panels) confirms every tab is coherent on-palette.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat(ui): rewrite misc components on clean shadcn, delete their CSS (fast mode) (#826)
Migrate a batch of MISC components to clean shadcn primitives + Tailwind
token utilities, deleting per-component CSS where the styling is fully
expressible as utilities. Palette and behavior are preserved exactly.
Fully migrated (CSS deleted):
- VoiceProfile (+ ProfileHeader / ProfileDetails / ProfileActivity): all
voice-profile__* layout/spacing classes → token utilities; the hero panel
body becomes an explicit flex wrapper inside <Panel> (drops the external
.ui-panel__body override). Deletes VoiceProfile.css (217 lines).
- Projects (OmniDrive): title / search input / view-toggle / filter rail /
card grid+list variants → utilities (list/grid driven by a `view` prop
instead of descendant-combinator CSS; per-card --card-accent kept via inline
style + arbitrary utilities for border-left and the color-mix hover).
Deletes Projects.css (181 lines).
- NotificationPanel: the .notif-* dropdown rules were already dead (the JSX
migrated to utilities in a prior wave; the dropdown now lives in LogsFooter).
Drops the dead import + deletes NotificationPanel.css (201 lines).
Trimmed (irreducible CSS kept):
- CaptureWidget: content / label / timer / dismiss / spinner moved to
utilities (spinner uses motion-safe:animate-spin). Kept the irreducible
glass always-on-top window shell, state borders, slide-in/dot-pulse
keyframes, reduced-motion, and the `body:has(.capture-pill)` standalone-
window transparency rule.
Kept as-is (with reason):
- FloatingPill: its remaining CSS is all irreducible — fixed+glass shell,
enter/exit + dot-pulse + indeterminate-sweep keyframes, and unlayered
--done/--error border/label overrides that must out-rank @layer utilities
(the file's own comments document this). Content/meta/progress/dismiss were
already utilities.
- PerformancePanel: already built on the shared SettingsSection/SettingRow
primitives; its CSS (.perfpanel__error/__row/__badge/__help) is a SHARED
stylesheet consumed by 7+ settings panels (MCPBindings, RemoteBackend,
Refinement, LLMEndpoint, Pronunciation, HFMirror, …), so it can't be deleted
without migrating out-of-scope panels.
Verify: vite build ✓, oxlint (0), oxfmt --check clean, vitest 641 pass,
visual 48 pass, bun install --frozen-lockfile ✓. Eyeballed Projects +
VoiceProfile + header bell via Playwright (real backend proxied through route
interception) — coherent, zero console errors.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat(ui): rewrite modals + segment/matrix components on clean shadcn, delete their CSS (fast mode) (#825)
Migrate the independent modal + segment/matrix components onto the
shadcn-backed `src/ui` primitive surface + Tailwind utilities, deleting
their bespoke component CSS. Palette kept, behaviour intact.
- SupertonicLicenseDialog: rebuilt on the shadcn Dialog primitive
(Radix focus-trap / scroll-lock / ESC); non-dismissable while the
license POST is in flight. Accept/Cancel via shadcn Button. Deletes
SupertonicLicenseDialog.css.
- ExportModal: kept as the non-blocking bottom drawer (background stays
interactive — Radix Dialog would break that), but folded the track
chips / tab strip / toggles / drawer shell into Tailwind utilities and
swapped the slide-up keyframe for tw-animate-css. Deletes
ExportModal.css.
- ErrorBoundary (WaveformErrorBoundary.css): fallback UI rebuilt on
Tailwind + shadcn Button. Removed the `errbnd-*` block from the shared
CSS; the `wfm-*` WaveformTimeline rules stay (file still imported by
WaveformTimeline).
- EngineCompatibilityMatrix: folded the GPU-chip color system,
`is-effective` highlight, `Why unavailable?` disclosure triangle, and
the horizontal-scroll table min-width into Tailwind. Kept the
`is-effective` marker class (matrix test asserts it), roles, testids,
and aria-labels. Deletes EngineCompatibilityMatrix.css.
DubSegmentRow / SegmentTrack were already migrated in a prior wave and
already use the shadcn-backed Button/Badge/Menu; their remaining CSS is
the deliberate irreducible remainder (cascade-fighting `!important` state
rules that must stay unlayered to beat index.css, `font:inherit` focus
rings, `input-base`/range overrides), so it stays co-located. The shared
`segment-*` contract in index.css is left untouched.
Verified: vite build, oxlint (0), oxfmt --check clean, full vitest
(641 pass incl. ExportModal/SegmentTrack/EngineCompatibilityMatrix/
ErrorBoundary), visual suite (48 pass), and a real-browser eyeball of all
four rewritten components via the visual harness.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat(ui): migrate app-container shell + Sidebar to utilities, trim index.css (fast mode) (#824)
Shell (app-container grid) — KEPT as-is, by design. The outer `.app-container`
grid family is the canonical cross-cutting positioning hook and is deliberately
left in index.css:
- `appShellScale.test.js` parses the literal `.app-container { … }` block and
asserts the `zoom`/`calc(100vw/--ui-scale)` scale pattern + the
`[data-zoom-layout=off]` 100vw/100vh fallback — migrating the base rule away
would break that regression guard.
- `LogsFooter.css` hooks `.app-container .logs-footer` and
`.app-container.rail-right .logs-footer` (+ a ≤600px media query) via ancestor
combinators that Tailwind utilities can't express.
- Child placement (nav-rail / history-panel / main-content) comes from
`.app-container > .child` descendant combinators that reflow `grid-column`
across six dynamic state classes (sidebar-collapsed / sidebar-hidden /
rail-right / shell-narrow / shell-mini); reproducing that as utilities would
require editing out-of-scope child components. Net index.css delta: 0.
Sidebar.css — safe, contained migrations + dead-rule removal:
- Moved the two collapsed combinators whose base is already utilities to
conditional utilities in Sidebar.jsx: `.sidebar.is-collapsed .sidebar__tabs`
and `.sidebar__scroll.is-collapsed` (mutually-exclusive conditional classes,
so no Tailwind same-property ordering trap).
- Removed dead/redundant rules: `.sidebar.is-collapsed .sidebar__tab svg`
(icon size already set by the JSX `size` prop) and the
`.sidebar.is-collapsed .sidebar__subtitle` / `__search` hides (both blocks are
already gated out of the JSX when collapsed).
Kept (reported): `.sidebar__tab` base + :hover/.is-active/:focus-visible
(is-active must beat :hover via source order — not reproducible cleanly in
layered utilities), `.sidebar.is-collapsed .sidebar__tab` (its base is still
unlayered CSS, so the override must stay unlayered too), `.sidebar__search-input`
(overrides the unlayered `.input-base` primitive), `.sidebar__search-clear`
(!important Button overrides), `.sidebar__save-btn*` (consumed by out-of-scope
WorkspaceProjects.jsx), `.sidebar__section-title:hover` + `.sidebar__icon-tile`
states (prior-wave unlayered-by-design), `.sidebar.is-collapsed .sidebar__empty`
(shared EmptyState has no collapsed prop), and all `history-*` rules (consumed by
the out-of-scope Workspace* feature panels).
Verified: vite build, oxlint (0), oxfmt --check clean, vitest 641/641 (incl.
appShellScale guard), visual 48/48, bun install --frozen-lockfile. Eyeballed
Launchpad + responsive widths (1280/1000/560/1366) + a forced-render of the
collapsed Sidebar: rail/header/main/footer placement intact, footer reclaims
full width at ≤600px, 0 console errors.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat(ui): rewrite marketing/donate pages on clean shadcn, delete their CSS (fast mode) (#823)
Rewrites the static marketing/info surfaces on shadcn primitives (Card / Button /
Badge) + Tailwind token utilities, dropping the three legacy page stylesheets.
FAST mode: clean shadcn + Tailwind defaults, palette kept (via the existing
color-mix + var(--chrome-*) arbitrary utilities), behavior intact, not
pixel-perfect.
- SupportPage.jsx (SupportView=donate + LicenseView=enterprise): hero, segmented
Support/License toggle, Fund-Claude-Max goal Card, amount picker, Ko-fi/PayPal
link cards, benefit Cards, and the per-deployment quote panel — all on
Card/Button/Badge + Tailwind. All i18n keys, URLs, openExternal, amount state,
and view toggling preserved.
- ContactPage.jsx: hero + Discord/Email/Issues/Website channel cards rebuilt as
hue-tinted Tailwind link rows.
- Deleted DonatePage.css (282), EnterprisePage.css (233), SupportPage.css (140)
= 655 lines removed; no JS imports them anymore.
Kept (shared, untouched): index.css `.lp-aurora*` + `.lp-hero__sweep` (also used
by Launchpad). Left the donate widgets (GoalBar/Pip/Postcard) and their
DonateGoal.css/Postcard.css in place — already Tailwind-based with genuinely
irreducible keyframes (goal-fill grow, Pip bob/wave, postcard stamp/perforation),
the sanctioned "small co-located keyframe CSS" exception. The dead no-op
`lp-glow-card` class (never defined in CSS) was dropped.
Verified: vite build ✓, oxlint 0, oxfmt clean, vitest 641 pass, visual 48 pass,
bun install --frozen-lockfile ✓. Eyeballed Donate/Enterprise/Support + Contact in
chromium against a stubbed backend — all coherent and on-palette.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat(ui): rewrite clone/design on clean shadcn, delete CloneDesign CSS + studio shell (fast mode) (#819)
Migrate the Clone / Voice-Design feature area to clean shadcn primitives +
Tailwind utilities, deleting the 338-line CloneDesignTab.css and trimming the
`studio-*` shell from index.css. Fast mode: palette kept, behavior intact, no
pixel-perfect reproduction.
Components rewritten on utilities (token utilities bg/border/text + standard
spacing), behavior preserved exactly:
- MicButton: mic-btn idle/recording/cleaning → utilities; pulse/spin reuse the
global keyframes via `animate-[…]`.
- ScriptPanel: studio-column/studio-panel, the ⊕ Insert button + popover,
coachmark close, and the script textarea → utilities.
- AudioMethodPanel: drop zone (clone-drop-zone padding override folded in),
design-seed input, save-as-profile row → utilities.
- DesignMethodPanel: describe textarea, Starting-points scroll lane (mask edge
fade), identity recipe line, category chip/select grid → utilities.
- ActionBar: production-override sliders row, language/steps controls, overrides
disclosure, footer CTA → utilities.
- CloneDesignTab: clone-split-grid + voice column/panel → utilities; CSS import
removed.
studio-* shell decision (grep-verified cross-file):
- `.studio-panel` — KEPT (dub: DubLeftColumn/RightColumn/Footer, IdleSkeleton).
Clone usages migrated to inline utilities.
- `.studio-action-bar` — KEPT, relocated from the deleted CSS into index.css.
WorkspaceHistory.css adds its `position: sticky` narrow-shell override (#476
CTA-clip fix, guarded by workspaceHistoryReflow.test.js), so it stays a class.
Its __row/__lang/__steps/__overrides children migrated to utilities.
- `.studio-column` — DELETED (only consumers were clone; now utilities).
Removed `.identity-line`/`.clone-insert-btn`/`.studio-action-bar__overrides`
from the shared focus-visible rule; the accent ring is now inline on each.
Verify: vite build ✓, oxlint 0, oxfmt clean, vitest 641 pass, bun
--frozen-lockfile ✓. Eyeballed both From-audio and By-design sub-views (incl.
production overrides) in chromium — coherent.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat(ui): rewrite Gallery/Stories/Logs on clean shadcn, delete their CSS (fast mode) (#822)
FAST-mode shadcn migration of three independent areas — Voice Gallery,
Stories editor, and the logs/status footer — onto shadcn primitives
(src/ui barrel over src/components/ui/*) + Tailwind token utilities. Palette
kept; behavior preserved; ~1000 lines of bespoke CSS removed.
Voice Gallery (VoiceGallery.jsx + gallery/{ArchetypeCard,ArchetypesZone,
CommunityZone,ImportsZone}.jsx):
- Zone toggle → <Segmented>; category chips → Button variant="chip"; facet
dropdowns → <Select>; grid/list view toggle → <Segmented>; cards/chips/
buttons/empty/loading → Tailwind token utilities.
- VoiceGallery.css 427 → 70 lines: kept only the now-playing equalizer
@keyframes, the .arch-avatar/.accent-flag/.flag-globe classes rendered by
the out-of-scope utils/archetypeIcons.jsx, and the app-wide .spin helper
(it lived here, NOT in index.css — kept to avoid breaking ~30 consumers).
Stories editor (StoriesEditor.jsx):
- Track grid, chapter bar, cast/projects/split panels, tone/speed drawer,
and native textarea/select/range chrome → Tailwind utilities; reusable
class-string consts hoisted. Drag-reorder, preview chain, generate/stems,
global speed, refs, i18n keys and aria-labels all unchanged.
- StoriesEditor.css 349 → 0 lines (file deleted; import removed). The dead
.stories-track__voice-dot[data-char] palette (no data-char ever set) and
cosmetic webkit scrollbars were dropped.
- Native <select>s get [color-scheme:dark] so the cast/voice pickers render
on dark chrome across WebKit/WebView2/WebKitGTK (matches the old
.facet-select intent; the original cast select was unstyled/light).
Logs/status footer (LogsFooter.jsx):
- Icon buttons, source pills + severity badges, version badge + pulse dot,
discord/contact/donate, log lines and notification severity → Tailwind
utilities. Spinner → motion-safe:animate-spin; reduced-motion via
motion-reduce: variants.
- LogsFooter.css 376 → 78 lines: kept the position:fixed shell + the
.app-container/.rail-right/≤600px ancestor-combinator insets (can't be
element-local), the body ::-webkit-scrollbar, the collapsed/open heights,
and the version-dot-pulse/heart-glow @keyframes.
- The shared --chrome-* token vars and index.css are untouched; Header is
unaffected (no logs-footer__* class is referenced outside this component).
Verified: vite build, oxlint (0), oxfmt --check (clean), vitest (641
passed), bun run test:visual (48 passed), bun install --frozen-lockfile.
Eyeballed all three areas in a stubbed dev build — coherent and on-palette.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat(ui): move Settings page chrome to Tailwind, drop page-specific CSS (fast mode) (#820)
FAST-mode shadcn pass over the Settings *page chrome*. The settings tab
components already render on shadcn — the live `src/ui/*` primitives
(Button/Badge/Tabs/Segmented/Slider/Input) are thin wrappers over the
`src/components/ui/*` shadcn primitives via the index.css token bridge — so
the only non-shadcn layer left here that is *safe to migrate* is the page
layout itself.
What changed:
- Settings.jsx: `.settings-page` / `.settings-content` are now Tailwind on the
token bridge — the centered, scrollable column that becomes a
[rail | content] grid at ≥760px, and the content column that establishes the
`settings` container query primitives.css relies on. No behavior change: tab
nav, deep-link tab, and every panel render exactly as before.
- Settings.css: removed the page-chrome rules now living in Tailwind
(`.settings-page` + grid, `.settings-content`) and the dead ones
(`.settings-row__mono`, `.settings-section__head-*`). Kept what can't migrate:
the tab-rail look (must stay UNLAYERED to win over the shared shadcn Tabs
primitive), `.settings-prose strong`, and the Models/Engines/recommendation
rules consumed by their sub-components.
- index.css: removed the duplicate base `.settings-page` block.
Deliberately NOT deleted (verified by cross-file grep, per "delete once
unused"): primitives.css + the `.st-*` class contract (out-of-scope StoragePanel
passes `st-row--stack`; AppearancePanel.css/VoicePanel.css/PronunciationPanel
test reach into `.st-row__control`), and Settings.css's `.models-*`/`.engines-*`/
`.reco-*` (consumed by out-of-scope ModelsTable / RecoBanner /
EngineCompatibilityMatrix). Deleting either would break out-of-scope code and
main CI.
Net: 3 files, ~51 fewer CSS lines. Verified: vite build, oxlint (0), oxfmt,
vitest (641), visual (48, no baseline change — snapshotted components untouched),
bun install --frozen-lockfile. Eyeballed Settings (General + Logs) via the visual
harness: rail + content grid + centered max-width + active-tab accent + tab
switching all coherent.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat(ui): rewrite dub studio on clean shadcn, delete dub CSS (fast mode) (#818)
FAST-mode shadcn migration of the Dub Studio feature area. The dub
components now style with Tailwind utilities on the OmniVoice palette
tokens (bg/text/border via chrome-* + space/text vars) plus the src/ui
shadcn primitives (Button/Badge/Progress/Segmented/Table), and the
800-line page stylesheet is gone.
What changed
- Deleted frontend/src/pages/DubTab.css (800 lines). The irreducible
pieces that can't be utilities — keyframe motion (stepper spin,
idle-drop pulse, skeleton shimmer), ::before stepper connectors, and a
handful of rules that must override other *global* design-system
classes (.studio-panel / .label-row / .override-toggle / .segment-del)
— moved to a small co-located frontend/src/components/dub/dub.css.
- Converted the dub-* presentational classes to inline utilities across
DubFooter (footer panel, export-track chips, compression warn),
DubLeftColumn (generating overlay, cast strip, the whole translation
settings bar + fields), DubRightColumn (output-options rows, transcript
body, glossary chip, bulk-select row), IdleSkeleton (speakers input,
ingest opt-in, landing advanced, ghost footer + buttons), and
TranscribeOverlay (stats row).
- Rewrote FooterBtn off the global .btn-primary / .dub-footer-btn
subsystem onto a Tailwind tone map (idle/danger/green/pink/amber/…),
preserving the flat tinted-outline look.
- Removed the dub-* fragments from src/index.css (tabular-nums group,
focus-visible group, and the dub-split-grid / dub-settings-bar /
dub-footer-btns responsive media queries — now inline max-[…] utils).
Kept .btn-primary base (still used by ErrorBoundary) and all shared
design-system classes.
Left intact (reported): the segment-* subsystem (DubSegmentTable.jsx/css,
DubSegmentRow.jsx/css, segment-* in index.css). It's the lowest-risk
option for the core, most test-covered segment table; ModelsTable and
EngineCompatibilityMatrix were verified NOT to consume segment-* (they
use models-*/engine-matrix-*), so nothing else breaks.
Behavior preserved exactly — every onClick/state/prop/hook untouched.
Verified: vite build ✓, oxlint 0 ✓, oxfmt --check clean ✓,
vitest 641/641 ✓, bun install --frozen-lockfile ✓. Eyeballed the idle
dropzone and the loaded skeleton (stepper, settings bar, skeleton
segment table, footer) in Chromium — palette + layout coherent.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat(ui): rewrite first-run/setup on clean shadcn, delete frs CSS (fast mode) (#817)
Rebuild the first-run / setup feature area on standard shadcn primitives
(Button/Input/Select/Progress/Badge from src/ui) + Tailwind utility classes
themed by the OmniVoice palette tokens, replacing the 954-line bespoke
"studio console" stylesheet wholesale (FAST mode: clean shadcn look, not a
per-pixel reproduction of the old design).
Components rewritten:
- FirstRunSetup.jsx (install-plan screen: mode/storage/compute/channel,
live disk gate, mirrors, Start)
- BootstrapSplash.jsx (install progress, steps, activity log, failure
hints + retry, awaiting_setup → FirstRunSetup handoff)
- WizardLibrary.jsx (unified model/engine list + SSE download progress)
- HfTokenCard.jsx (inline HF token bar)
- SetupWizard.jsx (preflight + models + dictation acts, stepper nav)
All behavior preserved: every onClick/state/prop, the radio-group keyboard
nav, the disk-space blocker logic, the SSE progress aggregation, retry /
clean-retry, the launch flow, and all exported pure helpers (kept the
unit-tested fmtBytes/fmtRate/isPlatformPick/aggregate/progressFromAgg/
radioGroupNav exports).
CSS deleted: FirstRunSetup.css (954) + SetupWizard.css (184) + the dead
swiz-check* block in Misc.css (~21). The only bespoke CSS kept is a new
63-line firstrun.css holding the three irreducible keyframes (breathing
waveform, rise-in stagger, active-step LED pulse) that Tailwind utilities
can't express — net ~1075 lines of bespoke CSS removed. index.css had no
frs-* rules (0 line delta there).
Verified: vite build, oxlint (0), oxfmt clean, vitest (641 pass),
bun install --frozen-lockfile. Live-eyeballed all four screens
(FirstRunSetup, install splash, failed state, wizard) via Playwright —
palette correct, layout coherent, no UA button-chrome leaks.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat(ui): migrate waveform-* to utilities, trim index.css (P4) (#816)
Move the waveform-* global class family off index.css onto Tailwind v4
utilities on WaveformTimeline.jsx, then delete the now-dead rules.
Migrated to utilities (rules deleted): waveform-timeline (mb), waveform-controls
+ -left/-right (flex/items/justify/gap), waveform-btn + :hover/:disabled and
waveform-btn-play + :hover (shared WF_BTN/WF_BTN_PLAY consts; UA <button>
padding/font preserved since the app ships no preflight), waveform-time
(text/border/bg/mono/tabular-nums), waveform-zoom-slider (important w/h/mt).
States -> hover:/disabled: variants; no-preflight borders -> explicit
[border:1px_solid_...]; exact px via arbitrary values.
Deleted as dead (zero usages anywhere): waveform-video-preview,
waveform-track-bg (+ nth-child + the 800px media-query track rows).
Kept (irreducible): .waveform-container and its
.waveform-container [data-id^="wavesurfer-region"] descendant rules (+ the
800px container/region media query) — those style WaveSurfer-generated DOM
we don't render in JSX, so they can't be utilities. The class stays as a hook.
index.css net -55 lines (+7/-62).
Cascade-correctness verified live (Playwright getComputedStyle, both
stylesheets loaded): new utilities reproduce the pre-migration computed styles
exactly. Caught two subtleties: (1) controls margin-top is 3px (unlayered
wfm-controls already wins over the old 4px), so no mt utility is added;
(2) referencing var(--chrome-font-mono) in a class string tripped the global
[class*="chrome-font-mono"] selector (adds slashed-zero + ss02) — switched the
time font to var(--font-mono) (identical stack) to avoid the substring match.
Screenshot pixel-diff old vs new = 0 (AE). Updated record_promo.js's fallback
selector (.waveform-controls -> [aria-label="Playback controls"]).
Gates: oxlint 0, oxfmt clean, vite build, vitest 641 pass, test:visual 48 pass,
bun install --frozen-lockfile no change.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat(ui): migrate ss-*/file-drag to utilities, trim index.css (P4) (#815)
Move the searchable-select (`ss-*`) and file-dropzone (`file-drag`) global
class families out of `src/index.css` into inline Tailwind utilities, then
delete the now-dead rules (-131 lines net in index.css).
- SearchableSelect.jsx: trigger/label/chevron/popup/search/list/group-label/
option (incl. the highlight + selected + selected-highlighted cascade)/
kind-icon/check/empty/more all rendered with token utilities + arbitrary
var()/px values; no-preflight borders made explicit; `:focus`/`:hover` and
the `::-webkit-scrollbar` pseudo-elements moved to Tailwind variants. The
`.ss-sm/.ss-md .ss-trigger` descendant rules collapse to a size-conditional
class on the trigger. `ss-wrap` keeps its class *name* only (its style is now
utilities) because residual.css targets `.voice-selector > .ss-wrap` via a
cross-file child combinator — deleting the name would break VoiceSelector
layout.
- AudioMethodPanel.jsx: `.file-drag` (+ `:hover`/`.is-dragging`/`p`) → utilities;
`is-dragging` stays a JS-toggled marker matched via `[&.is-dragging]:`. The
out-of-scope, unlayered `.clone-drop-zone` padding override still wins.
- index.css: removed the `.ss-*` block, the dead `.ss-popover/.ss-menu/
.ss-dropdown/.ss-item/.ss-highlighted` rules (zero JSX usages), and both
`.file-drag` blocks, leaving migration breadcrumbs.
Verified live (Vite + Playwright/chromium): the Clone screen's dropzone and an
open SearchableSelect popup (search box, POPULAR group label, highlighted
option) render coherently. Gates: oxlint 0, oxfmt clean, vite build OK,
vitest 641 pass, visual suite 48 pass, `bun install --frozen-lockfile` no-op.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat(ui): migrate history-* to utilities, trim index.css (P4) (#814)
P4 of the shadcn/Tailwind migration for the `history-*` global class
family. The family is a shared, cross-file-composed component system
used across WorkspaceHistory, Sidebar, WorkspaceProjects and
WorkspaceVoices, so most of it is irreducible to per-usage utilities.
Migrated the one cleanly-isolable class:
- `.history-row-head` -> `flex items-center justify-between gap-2 min-w-0`
(pure flex layout; no variants, pseudo-elements, descendant selectors,
or cross-file/selector coupling). Converted all 9 usages, deleted the
index.css rule (now zero usages). Verified in the running app that the
utilities compute byte-for-byte identically to the old rule
(display:flex / center / space-between / gap 8px / min-width 0).
Kept (composed cross-file / irreducible) and documented for later:
- `.history-item` (::before accent bar, descendant hover-reveal,
`.project-active` compound, `--row-accent` set inline + `--dub`
variant in Sidebar.css, duplicate !important defs)
- `.history-panel` (selector target of out-of-scope
`.app-container > .history-panel` / `.glass-panel.history-panel`)
- `.history-kind` / `.history-meta` / `.history-title` / `.history-subtitle`
(each has `--audio` / `--locked` / `--clamp`/`--expanded` / `--italic`/`--seed`
variants defined in Sidebar.css)
- `.history-actions` (revealed via `.history-item:hover/:focus-within`
descendant selector)
- `.history-action-btn` / `.history-action-icon` (compound `.accent`/`.danger`
hover modifiers; ~30 usages; kept whole as a cohesive subsystem)
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat(ui): migrate chip/preset/tag classes to Button variants/utilities, trim index.css (P4) (#813)
P4 shadcn/Tailwind migration of the chip/preset/tag global class families out
of src/index.css and onto their components as Tailwind utilities.
- personality-chip (+ __icon, + .active): -> token utilities inline in
clone/DesignMethodPanel.jsx (PCHIP_* consts). Active stays chrome-accent
(pink); icon span -> inline-flex items-center. The cross-file
`.starting-points__strip .personality-chip { flex:0 0 auto }` in
CloneDesignTab.css moved onto the chip as the `flex-none` utility and the
dead rule was removed.
- chip-group .chip (+ :hover/.active) and the chip-group container: chips ->
token utilities (CHIP_* consts) in DesignMethodPanel.jsx; the container's
flex layout -> `flex flex-wrap gap-1` utilities. The `chip-group` class name
is KEPT on the container purely as a JS hook (CloneDesignTab's roving-tabindex
keyboard nav does `closest('.chip-group')`).
- tag-btn (Insert-menu token chips): -> token utilities in clone/ScriptPanel.jsx
(TAG_BTN const), preserving the mono face. Removing tag-btn's `!important`
un-masks the intended `.clone-auto-extract-btn` green on the [CMU] button
(author intent restored; palette-coherent).
- preset-btn: had ZERO usages -> both rule blocks deleted.
- The shared 10x a11y focus ring is reproduced on the migrated chips via a
`focus-visible:[outline:2px_solid_var(--chrome-accent)]` utility, on top of
the app's global `:focus-visible` ring.
Kept (irreducible): the shared `.personality-chip:focus-visible, .chip:focus-visible,
...` a11y rule (groups out-of-scope selectors); `.chip-auto`, `.preset-grid`,
`.tags-container`, `.personality-strip` (out of scope, still used).
index.css: +13 / -126 (net -113). Verified live (Clone "By design": personality
chips, identity chip-groups, Insert tag popover) before/after — pixel-coherent.
Gates: oxlint 0, oxfmt clean, vite build, vitest 641 pass, test:visual 48 pass,
bun --frozen-lockfile no change.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat(ui): migrate hq-* classes to utilities, trim index.css (P4) (#812)
Move the header "quick" chrome (hq-*) global class families out of
src/index.css onto Tailwind utilities on their sole consumer, Header.jsx,
then delete the now-dead rules. No visual change (verified live below).
Migrated families: hq-col-* (layout columns), hq-logo-*, hq-breadcrumb-sep,
hq-view-* (breadcrumb title/dot/kicker/label/project + icon), hq-stats*
(readout + status badge override), hq-flush-btn/reload-btn, hq-flush-dropdown*
(portalled memory dropdown), hq-wave/hq-wave-bar (mini waveform). The three
@keyframes (flush-slide, hqPulse, hqBounce) are kept in index.css and driven
via [animation:...] arbitrary utilities.
- no-preflight: borders set explicitly with [border:...] arbitrary props.
- Badge override (hq-stats__status-badge) uses important modifiers (foo!) to
beat the primitive's own utilities.
- @media responsive rules become max-[Npx]: variants on the elements. Tailwind
v4's max-[N] compiles to `not all and (width>=N)` = strictly `< N`, whereas
the original `@media (max-width: N)` is `<= N`; bumped each breakpoint +1px
(e.g. 820 -> max-[821px]) so the boundary pixel matches exactly.
- The dead `.hq-scale` rule (zero usages) is dropped; the surviving non-hq
@media rules (.header-area reload/wordmark hide) stay in index.css.
index.css: 224 lines removed, 2 added (net -220).
Verified live (vite :3922, Playwright chromium, backend :3900 stubbed):
header at 1600/1000/820px + flush dropdown open, before vs after pixel-diff —
820px identical (0px); residual sub-1% diffs at other widths are purely the
live pulse-dot / wave-bar animation phase (the only red regions in the diff).
Gates green: oxlint 0, oxfmt clean, vite build, vitest 641 passed,
bun install --frozen-lockfile no change, test:visual 48 passed.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat(ui): migrate nav-rail/rail-btn to utilities, trim index.css (P4) (#811)
Move the `nav-rail` + `rail-btn` global class families out of
src/index.css into Tailwind utilities on NavRail.jsx, deleting the
entire 120-line nav-rail CSS block.
- `.rail-btn` / `:hover` / `.active` (+ accent `::before` indicator bar)
→ utilities on the shared RailBtn button; active state and the
edge-indicator side are driven by props (`active`, `side`) instead of
the `.nav-rail.rail-right` descendant selectors.
- `.rail-label` tooltip → group-hover utilities; flips edge by `side`.
- `.rail-flip` and `.donate-pill` (+ `donate-pill__heart`, reduced-motion)
→ utilities, incl. `motion-reduce:` for the heart.
- `.nav-rail .rail-top` / `.rail-bottom` → flex utilities.
The `nav-rail` CLASS is retained on the <aside> purely as the layout
hook the out-of-scope `.app-container > .nav-rail` grid rules position
by (those selectors are unlayered, so they still win over the layered
utilities); only its visual rules are deleted.
No-preflight safe: borders use explicit per-side `[border-*:1px_solid_…]`
shorthands (the flip button uses four independent side shorthands so the
top hairline can't be reset by a `border` shorthand override).
Verified: live before/after pixel-diff of the rail on Launchpad +
Gallery is pixel-identical (AE=0). oxlint/oxfmt/vite build clean,
vitest 641 passed, frozen lockfile unchanged.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat(ui): migrate Launchpad lp-* classes to utilities, trim index.css (P4) (#810)
Part 4 of the shadcn/Tailwind migration. Moves the Launchpad's static
layout/typography lp-* global classes from src/index.css onto the
component as Tailwind utilities (token-referencing arbitrary var()
values to preserve exact spacing/colour, explicit border shorthand for
the no-preflight setup, max-[900px]/max-[640px] variants for the former
@media rules), then deletes the now-unused rules from index.css.
Migrated + deleted: lp-hero (+__row/__col/__kicker-row/__wave-group),
lp-kicker, lp-hero__title (+em), lp-hero p / .lp-pill, the dead
.lp-underline rule, lp-actions (grid container), lp-section,
lp-section-title (+::after divider via after:), lp-section__grid,
lp-col, lp-proj-icon--* tints, lp-proj-meta--italic, lp-files__head/
__grid + lp-view-all + lp-file-card, lp-locked-badge, lp-empty (+__inner/
__bars/__hint), lp-dub-thumb, lp-demo-callout (+__icon/__btn),
lp-project-card (+ .proj-icon/info/name/meta/action), lp-ab-compare, and
the unused lp-action-card__emoji.
Kept (reported, not forced):
- Cross-file shared, reused by ContactPage/SupportPage/DonatePage.css/
EnterprisePage.css: .lp-aurora, .lp-aurora__blob(+--pink/green/amber),
.lp-hero__sweep (+ their @keyframes).
- ::pseudo / structural-selector / cursor-tracking component that can't be
flat utilities: the .lp-action-card family + .lp-glow-layer
(::before spotlight, ::after breath ring, nth-child stagger), .lp-animate.
- @keyframes-driven: .lp-wave-bar, .lp-hero__halo, and all @keyframes
(lpDrift1-3, lpHeroHalo, lpHeroSweep, lpBreath, lpFadeUp, lpWaveBeat) +
the prefers-reduced-motion block.
The bare `h1,h2,h3,h4` rule is unlayered, so the hero title's serif
font-family + letter-spacing utilities use `!` to win the cascade over it.
Verified: Launchpad landing screenshot is pixel-identical before/after
(Playwright chromium, animations disabled). oxlint 0, oxfmt clean, vite
build, vitest 641 passed, test:visual 48 passed, frozen lockfile unchanged.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat(ui): migrate settings/form/row global classes to utilities, trim index.css (P4) (#808)
P4 of the shadcn/Tailwind migration. Targets the settings/form/row LAYOUT
globals in src/index.css:
- .settings-log: converted its sole usage (LogsTab.jsx) to Tailwind utilities
(bg/border/rounded/padding/max-h/overflow/font-mono/whitespace), then deleted
the rule. --chrome-font-mono is an alias of --font-mono, so `font-mono` is
exact parity; no visual change (live-verified on the Logs tab).
- .settings-section + .settings-section h2 and .settings-row(.label/.value/
:last-child): zero remaining usages — superseded by the st-section primitive
(components/settings/primitives/SettingsSection.jsx) in an earlier wave.
Deleted as dead code.
Left BLOCKED (cross-file/cross-wave contracts, not forced):
- .settings-page / .settings-page h1 / .settings-page .settings-subtitle —
extended by pages/Settings.css via descendant selectors and a media-query
grid override that depend on the class living in the DOM.
- .label-row / .label-icon — owned by the clone/dub workspaces (out of scope),
extended in CloneDesignTab.css and DubTab.css.
Verified: oxlint (0), oxfmt clean, vite build, vitest (641 passed),
bun install --frozen-lockfile (no change), test:visual (48 passed), and live
Settings screenshots (General/Appearance/Models/Engines/Credentials/Logs)
before-vs-after coherent.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat(ui): migrate misc global helper classes to utilities, trim index.css (P4) (#807)
P4 of the shadcn/Tailwind migration — eliminate small, self-contained MISC
global helper classes from src/index.css by converting their raw-className
usages to Tailwind utilities, then deleting the dead rules.
Migrated + deleted:
- .grid-2 (1 usage, AudioMethodPanel.jsx) → grid grid-cols-2 gap-[6px]
max-[700px]:grid-cols-1, preserving the responsive single-column collapse.
- .grid-4 (1 usage, clone/ActionBar.jsx) → grid + arbitrary
[grid-template-columns:repeat(auto-fit,minmax(120px,1fr))] gap-[6px]
max-[500px]:grid-cols-2, preserving the responsive collapse.
- .val-bubble (7 usages, clone/ActionBar.jsx) → text-[0.65rem] bg-black/35
px-[5px] py-px rounded-[3px] explicit border (preflight is disabled) +
[font-variant-numeric:tabular-nums].
- .grid-3 was already dead (no base rule, no usages — only stray media-query
overrides) and is dropped alongside the grid-2/grid-4 collapse block.
index.css net -10 lines. The other class families in this file are
component-scoped (hq-*, lp-*, ss-*, segment-*, waveform-*, settings-*, etc.)
or owned by other waves/agents, so they were left untouched.
Verified: Clone tab (base + Production Overrides expanded) screenshots are
pixel-identical before/after. Gates: oxlint 0, oxfmt clean, vite build,
vitest 641 pass, visual suite 48 pass, bun install --frozen-lockfile no-change.
Part of a HELD batch — do not merge standalone.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat(ui): migrate global .ui-btn-* classes to shadcn Button, delete ui/Button.css (P4) (#805)
P4 of the shadcn migration. Removes the global `.ui-btn*` button
design-system family (the last raw-className button class set still
applied directly in JSX) by routing every consumer through the
shadcn-backed Button component / `buttonVariants()` helper, then deletes
the now-dead stylesheet.
Migrated — AudiobookTab.jsx (9 raw `.ui-btn*` sites):
- `<button>` actions (Preview plan / Create / cover-remove / lex-remove /
Add word / chapter-preview) → `<Button variant={subtle|primary|icon}>`.
- non-<button> elements that can't be the component (file-picker `<label>`s,
the download `<a>`) → shadcn `buttonVariants({ variant:'subtle' })`
className, preserving label/anchor semantics + href/download/file input.
- onClick / disabled / aria-label / inline style all preserved verbatim.
Deleted:
- `src/ui/Button.css` (177 lines) — the entire `.ui-btn*` family; it had no
remaining consumers (the Button component stopped emitting these classes
in the earlier shadcn wrap). Dropped its import from `ui/Button.jsx` and
refreshed the stale comment in `index.css` that referenced it.
Left for a later wave (blocked — see step 4):
- `.btn-primary` (index.css) — composed/extended by DubTab.css
(`.dub-footer-btn` tone family, `.dub-change-row__cta`, `.dub-skel-gen-btn`
all "sit on .btn-primary") + index.css media queries; deleting needs a
refactor of the whole dub footer button subsystem. Risky, left intact.
- `.frs-btn` (FirstRunSetup.css) — custom LED indicator (`.frs-btn__led`) +
`.is-armed` animated state with no Button-variant equivalent, spanning the
entire first-run/setup flow (the project's Core Value). Left intact.
Part of a held batch — do not merge standalone.
Verified: live screenshots of Launchpad + AudiobookTab before/after (buttons
render on-palette — brand-pink primary, bordered subtle pills, correct
sizes); oxlint 0; oxfmt clean; vite build ok; vitest 641 pass; test:visual
48 pass; bun install --frozen-lockfile no change.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat(ui): back Dialog/Tooltip/Tabs/Menu/Panel with shadcn (prop APIs preserved) (#803)
Migrate the five overlay/nav primitives in src/ui to compose the shadcn/ui
layer in src/components/ui, while keeping their existing prop surfaces and
exports byte-for-byte so no call site changes.
shadcn wraps the SAME @radix-ui primitives these already used (dialog, tooltip,
tabs, dropdown-menu) plus Card for Panel, so the swap is structural, not a
behavior change. No new dependencies — every required @radix package was
already pinned; package.json and bun.lock are unchanged.
- Added src/components/ui/{dialog,tooltip,tabs,dropdown-menu,card}.tsx
(new-york style, themed through the existing index.css token bridge;
DialogContent gains showCloseButton, TooltipContent gains showArrow, Card
gains asChild so the wrappers can preserve their exact look/markup).
- Wrappers now delegate positioning + open/close animation to shadcn
(Radix data-[state]/data-[side] + tw-animate-css animate-in/out). The GLASS
look that utilities can't express in this Tailwind v4 build (backdrop-filter +
layered gradients) stays in CSS, now keyed off shadcn data-slots / passed via
the .ui-* classes — unlayered, so it wins over shadcn's bg-popover/bg-card.
- Dialog.css/Menu.css/Tooltip.css trimmed to surface-only (obsolete position +
@keyframes removed); residual.css .ui-panel--glass unchanged.
- Tabs active/inactive state moved to data-[state] variants so it has the right
specificity to override shadcn's TabsTrigger defaults; .ui-tabs/.is-active and
all other cross-file hooks preserved.
Verified: oxlint (0), oxfmt clean, vite build, vitest (641 pass), bun
install --frozen-lockfile clean, and the visual suite (48 pass) — Panel + Tabs
render pixel-identical to existing baselines, so no baseline updates were
needed. Dialog/Menu/Tooltip are Radix-portal and not snapshot-harness-coverable;
verified via build + vitest + review.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat(ui): back Button/Badge/Progress/Segmented with shadcn (prop APIs preserved) (#799)
Migrate four OmniVoice UI primitives onto shadcn/ui foundations while keeping
their exact legacy prop APIs, so no call site changes.
- Button: thin wrapper over src/components/ui/button.tsx. Extends the shadcn
CVA with the OmniVoice variants (primary/subtle/softGhost/danger/chip[+Active]/
preset[+Active]/iconBtn[+Active]) + sizes (omniSm/omniMd/chip/preset/iconSm/
iconMd), styled via palette token utilities. Maps variant/size/iconSize/active/
loading/leading/trailing/block/ref. Each variant sets an explicit border
(transparent where needed) since the app ships Tailwind without Preflight.
- Badge: new src/components/ui/badge.tsx; CVA carries the tones (neutral/brand/
success/warn/danger/info/violet) + xs/sm sizes. Wrapper maps tone->variant and
keeps the ui-badge / ui-badge__dot hooks so the Header --pulse animation works.
- Progress: new src/components/ui/progress.tsx (on @radix-ui/react-progress) with
indicatorClassName + indeterminate support. Wrapper keeps per-tone gradients,
sizes, shimmer overlay, and the ui-progress / has-shimmer / is-indeterminate
hooks (residual.css keyframes unchanged).
- Segmented: new toggle.tsx + toggle-group.tsx (adds @radix-ui/react-toggle). The
`seg` toggle variant reproduces the segmented look; wrapper preserves the
items/value/onChange/size API.
residual.css: drop the obsolete .ui-seg__opt:focus-visible rule (focus now falls
through to the global ring). Badge pulse + Progress shimmer/indeterminate rules
kept (still needed).
Visual baselines: Badge/Progress/Segmented render byte-identical to before;
only Button baselines updated (shadcn markup differs in padding/radius, palette-
coherent across default/midnight/catppuccin). All gates pass: oxlint, oxfmt, tsc,
vite build, vitest (641), test:visual (48), bun install --frozen-lockfile.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
chore(dev): bun install before dev/desktop so pulled deps are present (#800)
`bun desktop`/`bun dev` assumed node_modules was current, so after pulling a
branch that adds a frontend dep (e.g. the shadcn migration's tw-animate-css /
@radix-* packages) vite failed with "Can't resolve '<pkg>'" until the user
manually ran bun install. CI never caught it (CI does a frozen install).
predev/predesktop now run `bun install` first (a no-op ~25ms when up-to-date),
so a fresh pull just works.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat(ui): back Input/Select/Textarea/Slider with shadcn (prop APIs preserved) (#798)
P1 of the shadcn/ui primitive migration (docs/shadcn-migration.md): route the
OmniVoice form/data primitives through the shadcn components in
src/components/ui/* while keeping their exact exports and prop APIs, so no call
site changes.
- input.tsx: export `inputBaseClass` (the shell) with no behaviour change —
ShadcnInput baseline stays byte-identical.
- New shadcn components: textarea.tsx, select.tsx (+@radix-ui/react-select),
slider.tsx, table.tsx.
- src/ui/Input.jsx (Input/Textarea/Select/Field): Input/Textarea now render the
shadcn components; a small `fieldSizeVariants` cva (named palette utilities,
tailwind-merge-clean) restores the OmniVoice padding-based sm/md/lg scale +
filled bg-bg-elev-2 over the shell. Select stays a NATIVE <select> wearing the
same shell — DubSegmentTable/CompareModal/GeneralTab depend on
onChange={(e) => …e.target.value}, which Radix's value-only Select would break;
the Radix select.tsx is added for new call sites only.
- src/ui/Slider.jsx: wraps the shadcn Slider, keeping the number-based onChange +
label/value-bubble chrome; track/thumb sized via the data-slot selectors.
- Table deliberately NOT rerouted: ui/Table.jsx is a flex-<div> chrome wrapper
whose .ui-table*/.segment-table global classes are a SHARED CONTRACT used
directly by ModelsTable/DubSegmentTable/EngineCompatibilityMatrix (virtualised
lists needing the div/flex layout, not a semantic <table>). table.tsx is
provided for new tabular data; Table.jsx + its globals are untouched. Its
toolbar inherits the shadcn-backed Input/Button for free.
Verification: only the 3 Input-* visual baselines moved (palette-coherent across
default/midnight/catppuccin); Slider/Table stayed within tolerance. vitest 641
green, oxlint 0 errors, oxfmt --check clean, vite build green, root bun.lock
regenerated and bun install --frozen-lockfile in sync (Docker).
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat(ui): shadcn/ui foundation + OmniVoice palette token bridge (Button/Input proof) (#797)
Lay the foundation for migrating OmniVoice's UI to clean Tailwind v4 + shadcn/ui
WITHOUT changing the look: shadcn primitives inherit the existing OmniVoice
palette (Gruvbox-pink default + every [data-theme] variant) through a semantic
token bridge. Foundation only — no existing component is replaced.
What landed:
- shadcn init for Tailwind v4 + Vite + React 19: frontend/components.json
(new-york, rsc:false, tsx:true), src/lib/utils.ts (cn = clsx + tailwind-merge),
and a @/* -> src/* alias in vite.config.js + tsconfig.json so future
`npx shadcn add` resolves.
- Token bridge in src/index.css: a single `@theme inline` block maps shadcn's
semantic vocab (--color-background/-foreground/-card/-popover/-primary/
-secondary/-muted/-muted-foreground/-accent-foreground/-destructive/-input/
-ring + --radius) onto the existing OmniVoice --color-* tokens. Because those
tokens are re-declared per theme in ui/themes.css, theme switching recolors
shadcn components automatically — no per-theme shadcn block. Existing
--color-accent/--color-border and the --radius-* scale are left intact.
- Two proof components: src/components/ui/button.tsx + input.tsx (verbatim
shadcn new-york), rendered across default/midnight/catppuccin in the visual
harness with committed baselines (brand-pink / purple / lavender confirmed).
- New deps: class-variance-authority, clsx, tailwind-merge, tw-animate-css,
@radix-ui/react-slot. Root bun.lock regenerated; `bun install
--frozen-lockfile` verified in sync (Docker-green).
- Migration plan at docs/shadcn-migration.md (bridge table, primitive->shadcn
mapping, prop-compat wrapper strategy, staged waves, honest risk/effort).
Verified: vite build, typecheck:ci, oxlint (0 errors), oxfmt --check, vitest
(641 pass), test:visual (48 pass incl. 6 new baselines), frozen lockfile in sync.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
refactor(css): consolidate ~15 residual stub stylesheets into src/styles/residual.css (#795)
After the Tailwind v4 migration, ~15 component .css files were reduced to tiny
stubs holding only the few irreducible rules that can't be layered utilities
(@keyframes animations, focus-visible rings, a glass surface, a <select> caret,
::before/::after pseudos, attribute-selector overrides). Each still lived as its
own file + its own per-component import. They are all plain GLOBAL class
selectors, so the file boundary bought nothing.
This collapses them into one shared, intentionally-UNLAYERED stylesheet
(src/styles/residual.css), loaded once at app root (main-app.jsx, right after
index.css to preserve cascade order) and once in the visual harness
(harness.jsx, which previously got these rules transitively via the component
imports). Rules are moved verbatim — byte-identical selectors/keyframes/values —
with a "from <Component>" provenance header above each block. No @layer wrapping,
so they keep beating Tailwind's @layer utilities exactly as before. Zero visual
change: all 42 visual-regression snapshots pass unchanged.
Net -14 .css files (68 -> 54): 15 stubs removed, 1 consolidated file added.
Deleted stub stylesheets (import removed from each component .jsx):
- ui/Badge.css (.ui-badge--pulse dot animation)
- ui/Input.css (.ui-select native caret)
- ui/Panel.css (.ui-panel--glass backdrop surface + ::before)
- ui/Progress.css (shimmer ::after + indeterminate keyframes)
- ui/Segmented.css (.ui-seg__opt:focus-visible ring)
- components/AudioTrimmer.css (.audio-trimmer layout)
- components/DemoPresetGrid.css ([aria-pressed] active preview)
- components/MultiLangPicker.css (.multi-lang__drop + mlp-in keyframes)
- components/ReadinessChecklist.css (glass panel + rc-spin keyframes)
- components/TranscriptionPicker.css (row hover/focus-visible combinators)
- components/UpdatesPanel.css (updates panel chrome)
- components/VoiceSelector.css (combinators + spin keyframes)
- components/settings/ApiKeysPanel.css(.apikeys-row/badge test contract)
- pages/ToolsPage.css (h1 + code/pre typography overrides)
- components/BootstrapSplash.css (comment-only, no rules; import dropped)
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat(ui): convert more DubTab CSS to Tailwind (wave 2, live-screenshot-verified) (#794)
Second-wave CSS->Tailwind conversion of DubTab, building on wave 1 (#788).
Removes 119 more lines from DubTab.css (919 -> 800) by moving the
stateless/standalone idle-skeleton rules into utilities in IdleSkeleton.jsx.
Every conversion was proven pixel-identical against the LIVE app (real Dub
screen on a dev server, not the isolated component harness). A throwaway
Playwright spec captured baselines of three reachable Dub states, the rules
were converted, and the same states were re-shot and pixel-diffed with
maxDiffPixels:0 (exact). States verified:
- idle drop-zone (drop-zone leaves, URL ingest row, landing options)
- idle + Advanced expanded (landing-adv field row)
- file-loaded skeleton via setInputFiles, no backend upload (skel settings,
skel table cells/headers/hint, cast strip, stepper)
Converted (base/standalone rules -> utilities): dub-idle-drop__lines/__title/
__sub, dub-ingest-row + __input, dub-idle-upload-label, dub-hidden-file,
dub-landing-opts + __label, dub-landing-opts__lang base, dub-landing-adv +
__field base, dub-cast base + __row + __kicker/__label base + --muted__chip,
dub-skel-settings, dub-skel-field/--sm, dub-skel-translate-btn,
dub-skel-transcript-toggle, dub-inline-icon, dub-skel-cell-*/header-* cells,
dub-skel-hint, dub-skel-gen-row.
Deliberately LEFT as CSS (would regress, per the diff oracle / wave-1 doctrine):
anything with @keyframes/animation (dub-skel-bar shimmer, dub-idle-drop pulse),
:hover/state interplay (dub-ingest-row__cta.is-ready, dub-landing-opts__adv,
dub-cast__pair), and cross-file unlayered overrides that a layered utility
would lose to (dub-skel-table on .segment-table, dub-skel-row on .segment-row,
dub-skel-gen-btn / dub-change-row__cta on .btn-primary,
dub-skel-transcript-toggle__inner on .override-toggle,
dub-skel-cell-acts__icon on .segment-del, dub-speakers-input on .input-base,
dub-ghost-footer on .studio-panel). Class hooks were kept on elements whose
.dub-cast--muted / --grow / select descendant rules still need them.
Gates: oxlint (0), oxfmt --check (clean), vite build, vitest (641 pass),
bun install --frozen-lockfile (no change), bun run test:visual (42 pass).
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
test(visual): extend harness to render panels/pages with mocked store/query/i18n (#793)
The visual-regression harness could only snapshot pure leaf components.
Pages and settings panels couldn't render because they depend on the
Zustand store, react-i18next, react-query, and direct api/* fetches — so
the CSS→Tailwind migration had no pixel safety net for them.
Add an OPT-IN provider wrapper (providers.jsx): a spec declaring a
`providers` block gets a seeded Zustand store, forced-English i18n, a
snapshot-tuned QueryClient pre-filled via setQueryData, and an optional
window.fetch stub for components that call api/* directly. Nothing runs
for pure leaf specs, so existing leaf baselines are byte-for-byte
unaffected (verified: 0 leaf PNGs changed on regenerate).
Prove it on three CSS-heavy settings panels, each x3 themes:
- AppearancePanel — store + i18n only
- GeneralTab — store + i18n + seeded useSystemInfo query
- StoragePanel — fetch-stubbed GET on mount
ModelStoreTab is documented as not-harness-able yet (live EventSource SSE
+ virtualized react-table + required props). No new deps. Suite stays
local-only (bun run test:visual), not a CI gate.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat(ui): convert Settings + misc page CSS to Tailwind utilities (partial) (#791)
Mechanical, conservative CSS→Tailwind v4 migration of the safe layout/spacing/
typography 80% across the Settings page and several smaller pages. No intended
visual change. Kept in CSS (per the migration plan's "hard 20%"): @keyframes,
::before/::after, :has()/child/sibling combinators, glass/backdrop-filter,
!important, media/container queries, state-modifier specificity interplay, and
any rule that fights an unlayered global element rule (h1..h4 font/letter-spacing,
code/pre, a) which would beat @layer utilities.
Conventions followed: BEM class names retained alongside utilities so external
selectors and removal stay safe; only @theme tokens use named utilities
(text-fg, bg-bg-elev-2, rounded-lg, font-mono); --chrome-*/--space-*/--text-*/
--frs-* and exact pixels use arbitrary var()/px values; no-preflight borders via
[border:1px_solid_...]; transitions via arbitrary [transition:...].
Files (rules removed → utilities; rules kept = the hard 20%):
- ToolsPage: page/card layout → utils; kept h1, code/pre descendants.
- BatchQueue: page/cards/progress/meta/outputs → utils; kept h1, card status
modifiers, progress-fill shimmer pseudo + keyframes.
- Transcriptions: header/list/detail/segments → utils; kept search input
(+placeholder), list scrollbar, item hover/active interplay, h4 seg-title.
- Projects: page/header/toolbar/search/rail/body/content/empty → utils; kept
title h1, search input, view-toggle + rail-item + card clusters, list-view
descendants, content view modifiers.
- AudiobookTab: page/head/body/script/side/field/duo → utils; kept title h2,
scoped .field-label, textarea/select descendants, @media collapse.
- Donate/Support/Enterprise (shared across SupportPage + ContactPage): page,
content, hero subtitle, footer, social-proof, amounts, topbar, spacer,
methods, chips, contact value, ent kicker/subtitle/why-grid/label/desc →
utils; kept all animation/pseudo/state/color-mix/custom-prop chrome.
- SetupWizard: standalone swiz-slide/note/checks/loading + frs-embed → utils;
left frs-coupled lib/hfbar rows in CSS (first-run, extends frs primitives).
- Settings: settings-muted/prose(base)/log-meta/log__empty/link-row/actions-row
+ models-row__progressline → utils in the settings/* tab components; kept the
page grid/container-query shell, tab-rail rules, tables, rows, reco-banner.
Verified: vite build clean, oxlint exit 0 (only pre-existing warnings), oxfmt
--check clean, vitest 641/641 pass, bun install --frozen-lockfile no change.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat(ui): convert VoiceGallery + CloneDesign page CSS to Tailwind utilities (partial) (#792)
Mechanically convert the safe, low-risk page CSS of the Voice Gallery and
Clone/Design pages to Tailwind v4 utilities, removing each converted rule from
the page CSS so there is a single source of truth.
Scope (conservative, partial — complex rules left as CSS):
- VoiceGallery.css: pure flex/grid containers + text/ellipsis spans converted
(voice-gallery, gallery-header, header-top, gallery-sub, gallery-search,
search-row base, search-results-panel, panel-header, results-list, result-*,
content-header, content-title base, count-badge, voice-list base, voice-info/
name/meta/actions base, arch-head/title, archetype-name/sub/chips, arch-foot,
archetype-section, load-more, import-explainer, community-explainer,
submit-actions).
- CloneDesignTab.css: studio-def-col, clone-script-wrap, clone-insert-backdrop,
clone-prod-col/check, clone-hear-demo-chip, clone-drop-row, clone-drop-filename,
grid-2--indent, describe-voice-block margin / hint / feedback, starting-points
(+__label), clone-sliders-col, clone-slider-kicker, identity-line__kicker/recipe,
design-seed(+__row/__keep), clone-coachmark(+__icon/__msg), clone-profile-banner
(+__label), clone-save-profile(+__row base).
Rules followed:
- No reliance on Tailwind preflight: borders use arbitrary [border:...]; only
@theme tokens map to named utilities (bg-bg-elev-2, text-fg, text-success,
rounded-lg/md), everything else (--chrome-*/--space-*/--text-* + literal px)
stays exact via arbitrary var()/px.
- Left in CSS: keyframes, ::before/::after, :has/combinators, masks, scrollbar
pseudo, !important, media queries, hover/border-heavy buttons & chips, and any
rule overriding an unlayered base (.file-drag/.input-base/.studio-panel) that a
@layer utility can't beat.
- Retained class names that anchor kept descendant selectors
(search-row, clone-save-profile__row).
Verified: oxlint 0, oxfmt --check clean, vite build OK, 641 vitest pass,
bun install --frozen-lockfile unchanged.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat(ui): convert misc component CSS (Sidebar/EngineMatrix/donate/…) to Tailwind utilities (#790)
Move the mechanical, self-contained layout/spacing/typography/simple-color CSS
of six leaf/misc components to Tailwind v4 utilities in their JSX, deleting the
now-redundant rules from each component .css. No intended visual change.
Conversion rules followed (matching the prior ui/ migration PRs):
- No preflight reliance: borders use `[border:1px_solid_…]`, button resets are
replicated (border/background/padding) rather than assuming a base.
- Only @theme tokens become named utilities (font-sans/serif/mono, rounded-lg,
text-fg…); --chrome-*/--space-*/--text-*/shadows use arbitrary `var()`/exact px.
- Component .css is unlayered and outranks @layer utilities, so a class is only
converted when its rule is removed; classes still governed by an unlayered
global rule (.input-base) or a remaining state rule keep their CSS.
- Kept in CSS: @keyframes, ::before/::after, :has()/child/sibling combinators,
:hover/:focus-visible/.is-active states, gradients/box-shadow/glass, animation,
!important, and @media. Class names are retained on the elements so those
rules (and the test selectors) keep matching.
- Shared/other-owned classes left alone: Sidebar's history-*/save-btn (rendered
by Workspace*), EngineMatrix's chip block (tested `.is-effective`, color-mix
variants) and __table (Table primitive), all Pip animation classes.
Files (rules removed vs kept):
- Sidebar: tabs/badge/search/empty/section-title/icon-tile/subtitle/scroll/tile
bases → utilities; kept .sidebar__tab (interactive), search-input (.input-base
override), search-clear (!important), save-btn (shared), is-collapsed
combinators, hovers. 286→182.
- EngineCompatibilityMatrix: matrix/head/title/body/row/cells/name/id/reason/
hint/last-error/why/why-body/chips/result/tabs/empty → utilities; kept table,
why-summary pseudo triangle, chip color system + tested .is-effective. 289→104.
- donate/Postcard: close/body/title/lead/goal-link/actions/cta/later/minor/star/
optout bases → utilities; kept the animated card, ::before perforation, grain,
stamp, hovers, keyframes, reduced-motion @media. 229→134.
- donate/DonateGoal (GoalBar): goal root/head/title/pct/track/caption/remaining/
caption-met → utilities; kept fill/shimmer/pip animations, --met/--mini
overrides, amounts-strong combinator, all Pip classes. 179→128.
- VoiceSelector: container/adornments/btn base → utilities; kept > .ss-wrap
combinator, btn hover/disabled, spin animation. 45→23.
- TranscriptionPicker: search/list/row/text/meta/empty base → utilities; kept
search>* and meta-span combinators, row hover/focus-visible. 29→10.
Verified: oxlint (0 errors), oxfmt --check clean, vite build, vitest (641
passed), bun install --frozen-lockfile (no change).
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat(ui): convert StoriesEditor + LogsFooter CSS to Tailwind utilities (partial) (#789)
Migrate the safe, mechanical layout/spacing/typography CSS of two components to
Tailwind v4 utilities, leaving the hard-to-express rules in their .css files.
Conservative + partial by design (per the migration plan §8): no preflight is
assumed, so borders/transitions/chrome tokens stay as arbitrary properties
referencing the exact original vars (`[border:1px_solid_var(--color-border)]`,
`[color:var(--chrome-fg-muted)]`), @theme tokens use named utilities
(text-fg, bg-bg-elev-2, rounded-sm/md, text-accent/brand, bg-border), and every
non-@theme value (--chrome-*, --space-*, --text-*) is exact px or `var()`.
StoriesEditor.css 525 -> 349 (-176): converted the editor shell, header,
subtitle, toolbar groups/divider, stats/footer, empty state, cast/split panels,
the panel title, voice/cast dot, and the tone/drawer containers. KEPT: the h2
title (global `h1..h4` element rule is unlayered and would beat a `font-serif`
utility), the `.stories-track` grid + its hover/active/drag combinators, all
native controls (textarea/select/range + their focus states), every button
(UA reset + hover/disabled/`--on`/`--delete` states), the chapter bar (hover
combinators), the `::-webkit-scrollbar` pseudos, and the `[data-char]` color
palette attribute selectors.
LogsFooter.css 507 -> 376 (-131): converted the resize handle, top bar,
left/right clusters, the LOGS title, the count-badge base, the log-line base +
icon + line-text base, and the notification panel (body/item/icon/content/msg/
action). KEPT: the `.logs-footer` fixed shell (anchor for the
`.app-container .logs-footer` inset combinators + the <=600px media query),
every button (toggle/pill/version/discord/donate/icon-btn with hover/disabled/
animations), the severity color modifiers + their descendant overrides
(`.logs-footer__line--error .logs-footer__line-text`, badge/item variants,
clickable hover), the body scrollbar pseudos, the `notif-content strong` rule,
and all @keyframes + the reduced-motion block.
Classes that remain referenced by kept CSS (combinators/pseudos/attrs) keep
their BEM class in the JSX alongside the new utilities; fully-removed rules drop
the class entirely. No class used elsewhere in the tree was removed (grep-checked
across frontend/src).
Verified: npx oxlint (0 errors), oxfmt --write src + --check . (clean),
vite build (ok), vitest run (641 passed), bun install --frozen-lockfile
(no changes).
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat(ui): convert DubTab page layout CSS to Tailwind utilities (partial; complex/stateful CSS kept) (#788)
Mechanically convert the low-risk layout/spacing/typography/simple-color rules
on the Dub section components to Tailwind v4 utilities, removing each converted
rule from DubTab.css so the unlayered page CSS can't shadow the utilities.
Converted (rule removed from CSS + utilities applied in JSX):
- DubHeader/IdleSkeleton: .dub-head strip, __filename/__meta/__project/__actions/__primary
- PrepOverlay: .dub-prep-overlay base, .dub-prep-chips base, __title/__note/__detail
- TranscribeOverlay: .dub-trans-overlay base, __head/__title/__bar
- DubFailureNotice: .dub-failure-notice + __hint/__actions
- DubFooter/IdleSkeleton: .dub-footer-banner, __badge-gap
- DubRightColumn: .dub-bulk-row__label-brand, .dub-lazy-fallback
- DubTab/IdleSkeleton: .dub-col, .dub-split-1, .dub-split-2
- IdleSkeleton: .dub-change-row, .dub-speakers-hint
Kept in CSS (left as-is, by the project's gotchas):
- .dub-head__title (coexists with the unlayered global .label-row it overrides)
- .dub-panel-col / .dub-ghost-footer (sit on .studio-panel, override its overflow/padding)
- .dub-change-row__cta (sits on .btn-primary, overrides its margin-top)
- .dub-trans-overlay__stats (targeted by the global tabular-nums rule)
- .dub-prep-bar/__fill, .dub-prep-chip, --large/--lg modifiers (combinators/state/animation)
- .dub-hidden-file (used outside the converted files)
- all keyframes/animations, ::before, :has/combinators, !important, media queries,
chrome-token surfaces, the stepper, skeleton bars, footer-btn family, etc.
Tokens preserved exactly: spacing/text/chrome → arbitrary var()/px; @theme colors
+ radius + weight → named utilities. Borders use the [border:...] arbitrary form
since the app ships Tailwind v4 without preflight.
DubTab.css: 989 → 919 lines (92 CSS lines removed, 22 explanatory notes added).
Verified: oxlint 0, oxfmt clean, vite build, vitest 641 pass, bun --frozen-lockfile no-op.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat(ui): convert FirstRunSetup layout CSS to Tailwind utilities (partial; animations/states kept) (#787)
Converts only the clearly-mechanical, low-risk layout rules of the shared
"studio console" sheet (FirstRunSetup.css) to Tailwind v4 utilities in the
JSX consumers (FirstRunSetup, BootstrapSplash, SetupWizard). Most of the
1020-line sheet stays in CSS by design.
Converted (15 static layout-only rules, full property sets):
- containers: .frs__deck, .frs__col, .frs-panel, .frs__grid
- masthead: .frs__mast, .frs__mast-row, .frs__mast-meta, .frs__mast-selects, .frs-wsteps
- misc layout: .frs-opt__head, .frs__hw, .frs-row__gauge, .frs__foot-row,
.frs-log__bar, .frs-banner__actions
Approach honoring the no-preflight setup (only theme.css + utilities.css
are imported): exact rem/px preserved via arbitrary values
(gap-[1.1rem], grid-cols-[minmax(0,7fr)_minmax(0,5fr)], etc.); each base
rule is removed from CSS (component CSS is unlayered and would otherwise
beat @layer utilities) and replaced with a one-line breadcrumb. Every
remaining override stays in CSS and still wins because it is unlayered:
responsive media queries (.frs__grid/.frs__mast-row/.frs__foot-row/
.frs-row__gauge), modifier classes (.frs__deck--focus,
.frs-banner__actions--end, .frs-wsteps--journey), and descendant rules
(.frs-row__gauge .frs-meter).
Kept in CSS (unchanged): all @keyframes/animations (rise, breathe, alarm,
hw-pulse, meter), ::before/::after, glass/masks, color-mix backgrounds,
hover/focus/state (.is-active/.is-armed/etc.), typography, and media
queries. Cross-file/combinator-bound classes (.frs-wnav, .frs-embed,
.frs-row*, .frs-check*) left as CSS.
Verification: oxlint 0, oxfmt --check clean, vite build OK, vitest 641
passing, bun install --frozen-lockfile no change.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat(ui): convert modal/dialog CSS to Tailwind utilities (#783)
Move the mechanical layout/typography rules of five modal/dialog/panel
components from their .css files onto JSX utilities (Tailwind v4). Exact
pixels preserved via arbitrary utilities referencing the same tokens/px;
no preflight, so UA resets (bg/border/padding) are replicated explicitly.
Overlays, positioning, open/close animations, state-class (.is-*) and
descendant selectors, gradients-as-state, media queries, and any
cross-file class are intentionally left in CSS.
- ExportModal: converted drawer head/handle/close, body, presets,
preset-chip, kicker, tracks, section-head, track-row, track-label,
tabs container, grid, field/field-head/-label/-hint, note, mt6,
pkg-grid/-card(+ghost)/-head/-body, summary(+left/-name/-right),
license-notice/-link. Kept: overlay, sheet+keyframes, track-quick
(button descendant), track (input descendant + .is-on/.is-dub.is-on),
tab (.is-active + hover), toggle (input descendant + --indent).
- CompareModal: converted drawer head/handle/title/close, body, foot,
desc/head/audio/audio-empty. Kept: overlay, sheet+keyframes, and
.ui-compare__grid (its responsive collapse is driven by a media query
here AND in index.css — cross-file, STOP rule).
- BatchAddDialog: converted head/title/close, body, drop-hint,
file-input, files/kicker/file-row/-name/-size/-x, settings, field,
foot/estimate. Kept: overlay, card+keyframes, drop (.is-over state),
select (overrides global .input-base), toggle (input descendant).
- SupertonicLicenseDialog: converted title, intro, sections, link,
footer, actions. Kept: overlay, card + section (color-mix + unclassed
h3/p/code descendants), buttons (color-mix :not(:disabled) states).
- NotificationPanel: converted the bell trigger + count badge (made the
color/bg conditional in JSX to avoid Tailwind utility-ordering ties).
Unused .notif-panel/.notif-item/.notif-hf-input blocks left as-is
(pre-existing dead code; removal is out of scope for this refactor).
Verified: oxlint exit 0 (only pre-existing warnings), oxfmt --check
clean, vite build OK, 641 vitest tests pass, bun install
--frozen-lockfile unchanged.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat(ui): convert dub/demo component CSS to Tailwind utilities (#784)
Move mechanical layout/typography from five component CSS files onto JSX as
Tailwind v4 utilities. Conservative: rules that are shared across files, use
!important/color-mix/compound or descendant selectors, focus rings, font:inherit,
@media, or that would lose to unlayered index.css rules in the cascade are kept in
CSS. Verified visual equivalence, oxlint (0), oxfmt, vite build, and 641 vitest
tests; bun.lock unchanged.
DemoPresetGrid: fully converted grid/cards/buttons; CSS trimmed to only the
.demo-preset-card__preview[aria-pressed="true"] state (attribute selector kept
unlayered so it wins over the button's hover utilities).
DubbingDemo: converted head/title/dismiss/pane/caption/picker/chip/cta; kept the
shared container base (reused by the loading state), the max-width:720px media
query, and the input/pane-label-span/pane-video descendant + chip.is-active
compound rules.
DictationDemo: converted head/title/lede/card/lang/script/actions/result-base;
kept .dictation-demo and .dictation-demo__scripts (queried by
DictationDemo.test.jsx), plus status/result variants with their descendants.
DubSegmentRow: converted the local cell badges/labels/time-spans/restore-button/
checkbox; kept the shared .segment-* row/state classes (used by
DubSegmentTable.css, index.css, IdleSkeleton.jsx), the text inputs (font:inherit
+ focus), and the select/range/actions cells whose unlayered input-base /
input[type=range] siblings would otherwise beat utilities.
SegmentTrack: converted container/onsets/viewport-base/lane/label/handle-base/
actions/action-btn/playhead; kept the box and its JS-toggled state variants,
handle edges with hover/selected compounds, the self-scroll viewport modifier,
the disabled compound, and the visually-hidden announce region.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat(ui): convert settings panel CSS to Tailwind utilities (#782)
Converts the clearly-mechanical CSS (layout/sizing/typography/simple
color+border+radius, simple hover/focus/disabled) in the settings panels
to Tailwind v4 utility classes on the JSX, mapping to @theme token
utilities + arbitrary var()/px values for exact-pixel parity. The app
ships without preflight, so borders use the `[border:1px_solid_…]`
arbitrary-property form (matching ui/Badge.jsx) to keep border-style.
No behavior change; verified by strict 1:1 mapping + build + full tests.
StoragePanel: fully converted → StoragePanel.css DELETED (import removed).
field/input/buttons/restart/error all utilities; placeholder + focus-ring
via placeholder:/focus-visible: variants.
SharingPanel: fully converted → SharingPanel.css DELETED (import removed).
section/row/addr/btn(+ghost)/iconbtn/tailscale-*/qr/note/envname/portinput.
AppearancePanel: converted scale slider+readout, theme/font containers, and
the range input (accent-color). KEPT in CSS: `.appearance-panel__row--fonts
.st-row__control` (reaches into the SettingRow primitive), and the
theme-dot + font-tile rules (stateful transitions, multi-layer box-shadow
rings, is-active state) — not 1:1 utility-safe.
ApiKeysPanel: converted error/rows/head/name/meta/set/unset/whoami/masked/
actions/input/buttons/clear-dialog/checkbox. KEPT in CSS:
`.apikeys-row`, `.apikeys-row--active`, `.apikeys-badge`,
`.apikeys-badge--active` — ApiKeysPanel.test.jsx selects these by class
name (cross-file contract; STOP rule).
VoicePanel: converted the warn banner + most of the speech-model dropdown
(dropdown/trigger/name/list/item/itembtn/check/body/itemtop/itemname/
size/itemdesc/progresstext/action/iconbtn). KEPT in CSS:
`.voicepanel__row--model .st-row__control` (primitive descendant),
`.voicepanel__dd-chev`/`.is-open` (transform transition — Tailwind
`rotate-*` targets the `rotate` property, not `transform`, so it wouldn't
animate), `.voicepanel__dd-progress` + `> :first-child` (child combinator),
and `.voicepanel__spin` + `@keyframes` (animation).
PerformancePanel: UNTOUCHED. PerformancePanel.css is a de-facto shared
stylesheet — `.perfpanel`, `.perfpanel__error`, `.perfpanel__row`,
`.perfpanel__badge`, `.perfpanel__help` are used by 6 other panels
(MCPBindings, LLMEndpoint, Refinement, RemoteBackend, HFMirror,
Pronunciation), so the STOP rule leaves the whole file as-is.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat(ui): convert standalone widget CSS to Tailwind utilities (#781)
Move the clearly-mechanical CSS (flex/grid, spacing, sizing, typography,
simple colors/borders/radii, and simple hover/disabled states) for seven
standalone widgets onto their JSX as Tailwind v4 utilities. Animation
(@keyframes), glass/backdrop-filter, pseudo-element/compound/sibling
selectors, !important, media queries, and any class referenced from
another file are left in CSS verbatim. Exact pixels/colors are preserved
via @theme token utilities plus arbitrary var()/px values; transitions use
arbitrary-property syntax so the timing function stays identical (Tailwind's
transition utilities inject a different default ease). No preflight is loaded,
so every converted border pairs an explicit border-solid/border-dashed + color.
- NetworkToggle: fully converted; NetworkToggle.css deleted and its import
removed (all classes were local).
- FloatingPill: converted the static content/label/meta/timer/error/progress
track + dismiss button; kept the pill base (animation+glass+fixed pos), dot,
progress-fill (base + indeterminate !important/animation), keyframes, the
prefers-reduced-motion block, and the --done/--error descendant overrides.
- AudioTrimmer: converted all audio-trimmer__* parts + trim-field*; kept the
.audio-trimmer base rule (also targeted by unlayered overrides in index.css).
- ReadinessChecklist: converted title/list/item/status-layout/label/detail/
fix/all-pass; kept the glass base, the rc-spin keyframe, and the dynamic
status--pass/warn/fail/loading color+animation modifiers.
- MultiLangPicker: converted chips/add/summary/search/list/section/option;
kept the .multi-lang__drop dropdown (animation + shadow) and mlp-in keyframe.
- WorkspaceVoices: converted only the local wv__active*/wv__empty-cta active-
voice card; kept wv/wv__head/wv__title/wv__search*/wv__scroll/wv__empty/
wv--collapsed/wv__rename-input (shared with WorkspaceProjects.jsx).
- WorkspaceHistory: converted the local wh/wh__* panel chrome (active chip
state expressed as a mutually-exclusive ternary since utilities are equal
specificity); kept the studio-with-history/studio-right/shell-narrow/
shell-mini layout rules (referenced by App.jsx, index.css, and tests).
Verified: oxlint exit 0, oxfmt --check clean, vite build OK, 641 vitest pass,
bun install --frozen-lockfile clean.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat(ui): convert Button/Panel/Input/Menu to Tailwind utilities (visual-verified) (#780)
Migrate three UI leaf primitives from component .css to Tailwind v4 utilities,
each verified pixel-identical against the visual-regression harness across all
three baselined themes (default / midnight / catppuccin).
Because the app ships Tailwind v4 WITHOUT Preflight and themes override the
design tokens, colors/shadows/borders/transitions are expressed as arbitrary
*properties* (`[prop:value]`) referencing the exact original CSS variables
(avoiding `--tw-*` composition and color/length type ambiguity), while
@theme-mapped tokens use named utilities (text-fg, bg-bg-elev-2, rounded-lg,
text-danger…) which resolve to the same `var(--…)` and track themes. The
harness renders resting state, so hover/focus/active are converted faithfully
but not pixel-gated.
- Button: component is now fully utility-driven and no longer emits `.ui-btn*`
classes. Button.css is RETAINED unchanged because AudiobookTab.jsx consumes
`.ui-btn--{subtle,primary,icon}` as raw classNames (out of scope to refactor);
keeping the component class-free avoids double-application.
- Panel: layout/border/radius/padding/header/title/actions + solid & flat
variants → utilities. Panel.css trimmed to the glass variant only
(backdrop-filter + layered gradient surface + ::before highlight, which
utilities can't express). The header+body top-padding sibling rule is
reproduced via a conditional `pt-` when a header is present.
- Input: shared input/textarea/select shell, sizes, states, and the Field
wrapper → utilities. The `:has(.ui-field__icon)` padding rule is reproduced by
cloning the control with `pl-` when an icon is present. Input.css trimmed to
the native <select> caret (SVG data-URI background) only. Added Input to the
visual harness with a representative spread; baselines committed.
- Menu: left as CSS. It is a Radix dropdown whose content renders through a
Portal to document.body (outside the harness snapshot root #visual-root) and
only renders when open + collision-positioned, so it cannot be captured in
isolation here; its surface is also dominated by keep-as-CSS features
(backdrop-filter glass, gradient, @keyframes pop-in, box-shadow token).
Verified: bun run test:visual (18 passed), npx oxlint (0 errors),
oxfmt --check (clean), vite build (ok), vitest (641 passed),
bun install --frozen-lockfile (no changes).
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat(ui): convert Dialog/Slider/Table/Tabs to Tailwind utilities (visual-verified) (#779)
Continue the component CSS -> Tailwind v4 utility migration for UI group 3.
Added Slider, Table, and Tabs to the visual-regression harness (specs.jsx +
manifest.ts) and committed machine-local baselines, then converted each
component, proving the result pixel-identical with `bun run test:visual`.
- Slider: fully converted; Slider.css deleted (no keyframes / complex
selectors). Token + arbitrary-value utilities preserve exact pixels; thumb
hover/active/focus-visible and the multi-easing transition are kept faithful
via arbitrary-property utilities. Visual-verified across all 3 themes.
- Tabs: fully converted; Tabs.css deleted. pill/underline variants, size,
active and hover:not(active) states mapped to conditional utility sets. The
`ui-tabs* / is-active / ui-tabs__icon` class names are retained as inert
hooks so Settings.css's unlayered overrides (`.ui-tabs.settings-tabs-ui …`)
keep winning over the layered utilities — Settings tab rail unchanged.
Visual-verified across all 3 themes.
- Dialog: partial conversion. Header / title / body / footer box-model +
typography and per-size max-width converted to utilities; the glass
gradient surface, backdrop-filter, fixed centering, and open/close
@keyframes remain in Dialog.css (cannot be reduced to utilities). NOT
visually verified: Radix Portal + position:fixed render the dialog outside
the harness's #visual-root, so it can't be snapshotted in isolation;
verified instead by 1:1 token equivalence + build + unit tests.
- Table: LEFT AS CSS (STOP rule). Its classes are a shared CSS contract, not
a private leaf — ModelsTable.jsx renders `ui-table-header`/`ui-table-header__cell`
directly without the component, and DubSegmentTable.css,
EngineCompatibilityMatrix.css, Settings.css, and index.css all hook those
global classes. Removing Table.css would break them, so converting yields no
safe net benefit. Added to the harness with a baseline for a future pass.
Verified: oxlint (0 errors), oxfmt --check (clean), vite build, vitest
(641 passed), bun install --frozen-lockfile (no changes), test:visual (24
passed).
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat(ui): convert Badge/Segmented/Progress to Tailwind utilities (visual-verified) (#778)
Migrate three UI leaf primitives from component .css to Tailwind v4 utility
classes, mapping colors/radii/fonts to the @theme token utilities and using
arbitrary values (px / var() / color-mix / gradients) to preserve exact pixels.
Each conversion is proven pixel-identical to its pre-conversion baseline by the
Playwright visual-regression harness across all three themes.
- Badge: base, sizes, tones, and dot moved to utilities. Kept the
`.ui-badge--pulse .ui-badge__dot` rule in CSS — it is driven by an
externally-applied parent class (Header status badge) + global `pulse`
keyframes, which a utility on the component can't express.
- Segmented: container, options, sizes, hover (Radix data-state=off) and
active (data-state=on) moved to utilities. Kept `.ui-seg__opt:focus-visible`
in CSS: the global `:focus-visible` rule is unlayered and would otherwise win
over a layered utility, so the component override must stay unlayered too.
- Progress: track, sizes, fill, and per-tone gradient fills moved to utilities.
Kept the shimmer `::after` + indeterminate descendant rule + both `@keyframes`
in CSS (pseudo-elements / keyframes are not expressible as utilities).
- Tooltip: left as CSS. Its content renders through a Radix Portal into
document.body, outside `#visual-root` (the only element the harness snapshots),
so a conversion can't be visually verified — left untouched per the rule to
not force an unverifiable change.
Added Segmented + Progress to the visual harness (specs.jsx + manifest.ts) with
representative variants/states and committed their baselines. Badge was already
in the suite; its baseline is unchanged (byte-identical).
Verified: oxlint (0 errors), oxfmt --check (clean), vite build, vitest
(641 passed), bun install --frozen-lockfile (no change), test:visual (21 green).
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
test(visual): add Playwright component visual-regression baseline for CSS migration (#776)
Gating prerequisite for the CSS -> Tailwind v4 migration: a pixel-for-pixel
safety net so each utility conversion can be verified against a known-good
baseline. There were previously no visual tests.
Approach: a lightweight Vite-served harness (NOT @playwright/experimental-ct-react)
that renders one presentational leaf component in isolation, with no Python
backend. Chosen because it adds zero new deps (root bun.lock untouched -> no
Docker frozen-lockfile risk), reuses the existing @playwright/test + bundled
chromium, and renders through the project's real Vite 8 + Tailwind v4 + token
pipeline so snapshots reflect the actual build output. CT's experimental React
runner on Vite 8 + React 19 was an avoidable compatibility risk.
- harness.html / harness.jsx: isolated render target driven by ?component=&theme=
URL params; applies themes via [data-theme] (default = bare :root Gruvbox),
loads the same fonts + token layers as the app, signals font-ready for stable
shots.
- specs.jsx: registry of pure variant spreads for Badge, Button, Panel,
SettingRow, SettingsToggle.
- manifest.ts: COMPONENTS x THEMES (default, midnight, catppuccin) the spec
iterates -> 15 committed baselines in __screenshots__/.
- playwright.visual.config.ts: dedicated config (separate from e2e), own Vite
server on port 3902, animations disabled, caret hidden.
- scripts: test:visual / test:visual:update.
- README: how to add a component, how to update baselines after an intentional
change, and why this stays local/manual (font/anti-alias differences across
OSes) rather than a blocking CI gate for now.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(css): make @theme the single source for design tokens (dedup drift) + parity test (#777)
The Tailwind v4 `@theme` block in src/index.css and the unlayered `:root`
in src/ui/tokens.css both declared the same `--color-*`, `--radius-*`, and
`--font-*` tokens. Because `@theme` lands in `@layer theme` (low priority)
while tokens.css's `:root` is unlayered, the tokens.css copy silently won —
the `@theme` literals were dead, losing duplicates. The two copies had
already drifted: the font stacks in `@theme` were the short variants while
tokens.css carried the full stacks (with 'Söhne', 'Cascadia Code', etc.),
so the resolved font-family came from tokens.css.
Make `@theme` the single home for the overlapping color/radius/font tokens
and delete the duplicates from tokens.css. To keep every resolved value
byte-identical (this is a pure de-dup, not a restyle), `@theme` adopts the
full font stacks that were actually winning at runtime. Tokens unique to
tokens.css (--color-muted-mono, --radius-pill, --font-display, --font-ui,
spacing, shadows, motion, z-index, etc.) are left untouched.
Theme switching is preserved: themes.css's `[data-theme=...]` overrides are
unlayered, so they still beat the now-@theme-sourced base (unlayered always
wins over @layer theme, regardless of source order).
Verification: a before/after `vite build` shows all 31 effective
`--color/--radius/--font` values identical; full vitest suite (641 tests)
green. Adds src/test/tokenParity.test.js, which fails if any
color/radius/font token is ever re-declared in both @theme and tokens.css
(catching the drift before it can recur).
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
docs(contributing): switch the frontend CSS guidance to utilities-first (#775)
The "Vanilla CSS … no Tailwind" rule contradicted the (already-wired) Tailwind
v4 setup and the CSS→Tailwind migration plan (#772). Replace it with the
utilities-first standard: Tailwind utilities (bridged to the design tokens via
index.css @theme) for layout/spacing/typography; keep .css files only for the
hard parts (glass, keyframes, pseudo-elements, :has(), theme rules).
Required by the docs-sync rule as P0 of the migration.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
chore(format): adopt oxfmt for JS/TS/JSX + CI format gate (#774)
Adds oxfmt (Rust formatter, Prettier-conformant) — the repo had no formatter, so
this is a one-time normalization of the JS/TS/JSX code (257 files; purely
cosmetic — full suite stays 638/638).
Scope is deliberately narrowed in .oxfmtrc.json to JS/TS/JSX only:
- singleQuote:true + jsxSingleQuote:false — preserve the project's existing
style (single-quoted JS, double-quoted JSX attrs), not oxfmt's double-quote
default. (Flipping quotes globally also broke a source-string-parsing test;
preserving them keeps featureCoverage green.)
- Excludes **/*.css (the CSS→Tailwind migration will rewrite those — formatting
them now is wasted churn), **/*.json (avoids reformatting 20 i18n locale
files + config), **/*.toml, and src-tauri/** (Rust/Tauri config — out of scope
for a frontend JS formatter; oxfmt was reformatting Cargo.toml/tauri.conf.json).
Tooling:
- `bun run format` (write) / `bun run format:check` (verify).
- ci.yml: new "Frontend format check (oxfmt)" gate after the oxlint gate.
Verified: format:check clean; oxlint 0 errors; vite build; full suite 638/638;
bun install --frozen-lockfile in sync.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
chore(deps): add taze for manual dependency freshness checks (#773)
Adds taze (root devDep) + `bun run deps:check` = `taze -r --maturity-period 7`:
recurses the bun workspace (root + frontend), lists available updates, and is
READ-ONLY (never writes package.json without -w). The 7-day maturity window
skips just-published versions as a supply-chain precaution.
Manual tool by design — no auto-update, no Renovate infra, nothing added to CI.
Run `bun run deps:check` when you want a refresh overview; `taze major` for major
bumps; add `-w` to apply.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
docs: add the CSS → Tailwind v4 migration plan (#772)
Phased, bounded migration plan (not a big-bang): convert the mechanical ~80%
(flex/grid/gap/padding/typography/simple color) to Tailwind v4 utilities,
deliberately keep ~15-25% as CSS (glass/backdrop-filter, @keyframes,
::before/::after, :has(), !important). Realistic end state ~10-12k of 16.6k CSS
lines removed across ~5-7 weeks of small PRs.
Key gates the plan establishes before any conversion starts (P0):
- A Playwright screenshot baseline (default + dark + light) — the className-diff
trick used for the page refactors is useless here since class names change.
- Fix the @theme ↔ tokens.css token drift (single source + a parity test).
- Rewrite the CONTRIBUTING.md "no Tailwind" line (docs-sync rule).
Companion to docs/maintenance-pages-modularization.md.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
chore: adopt knip + remove dead files, deps, exports, and types (#771)
Add knip (dead-code/dep finder) for the bun workspace, then act on what it
found. Complements the oxlint gate: oxlint flags per-file unused symbols; knip
finds whole dead files/exports/deps across the project.
Tooling:
- frontend/knip.json + `bun run knip` script. Ignores the legitimate false
positives: public/aec-worklet.js (loaded via a dynamic AudioWorklet URL),
/@react-refresh (Vite dev inject), and tailwindcss + the Rust-side
@tauri-apps/plugin-updater / plugin-window-state JS packages (used by the
native plugin, not imported in JS).
Removed (all verified — build + tests + tsc + oxlint green):
- Dead files: CastingView.{jsx,css}, UpdateStatusChip.{jsx,css} (no refs; the
latter only survived in a stale comment, now reworded), and ui/motion.js.
- Unused deps: @radix-ui/react-popover, @radix-ui/react-select, @eslint/js,
eslint-plugin-react-refresh (the last two orphaned when eslint.config.js was
stripped for the oxlint adoption).
- 44 dead exports + 56 dead exported types across api/*, store/*, ui/*, utils/*:
deleted where used nowhere; dropped just the `export` keyword where still
referenced in-file.
Kept (justified): Slider primitive (keeps @radix-ui/react-slider meaningful),
AppMode export (a test string-parses its source), tailwindcss (CSS @import +
vite plugin).
Verified: oxlint 0 errors; tsc clean; vite build; full suite 638/638;
bun install --frozen-lockfile in sync (Docker rule).
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
refactor(tauri): use tauri-plugin-positioner for the dictation pill (replaces hand-rolled monitor math) (#770)
The floating dictation pill (the "widget" window) was positioned bottom-center
by three near-duplicate blocks in lib.rs that each read primary_monitor(),
divided size by scale_factor, and called set_position(LogicalPosition...), with
a win.center() fallback. Replace all three with the official
tauri-plugin-positioner: window.move_window(Position::BottomCenter), preserving
the center() fallback on error.
- Add tauri-plugin-positioner = { version = "2", features = ["tray-icon"] }
(tray-icon enabled because the app ships a system tray).
- Register .plugin(tauri_plugin_positioner::init()) after single-instance.
- Collapse the global-shortcut, tray "dictate", and pill-mode pre-position
blocks to the plugin API. Behavior-preserving: same window, same trigger
points, still bottom-center.
Verified with cargo check (passes; the one warning is pre-existing in setup.rs).
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
refactor(tauri): remove dead pill-autostart code (#764)
The enable/disable/is_pill_autostart commands (and pill_autostart_path) were
defined in commands.rs and registered in lib.rs but NEVER invoked — no JS
caller, no internal Rust call, and no Settings toggle. ~155 lines of unwired,
hand-rolled cross-platform code (macOS plist / Windows registry / Linux
.desktop) maintained for a feature that was never shipped.
Investigated adopting tauri-plugin-autostart instead, but since nothing exposes
the feature, replacing dead code with a plugin (+ a new toggle) would be
building an unrequested feature. Removing the scaffolding is the honest cleanup;
if the "launch dictation pill at login" feature is ever wanted, wire it then via
tauri-plugin-autostart (init(LaunchAgent, Some(vec!["--pill"]))).
Kept: dirs-next (still used by config.rs/setup.rs — comment updated) and the
launch_as_widget config commands (those ARE used). cargo check passes.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(api): route backend fetches through apiFetch so they carry LAN-share auth (#765)
Under LAN-share / remote-backend (a PIN/API key is set), ~28 raw fetch() calls
to the backend 401'd because they skipped the X-OmniVoice-Pin / Authorization
headers that apiFetch injects. Route them through apiFetch — fixing the auth
gap and adding the same transport-retry robustness (backend-restart windows
become invisible) the rest of the app already has.
Since apiFetch throws ApiError on !ok (and fires ov:pin-required on 401), the
now-dead `if (!res.ok) {…}` blocks were removed; surrounding try/catch handles
the ApiError. Streaming (.body.getReader), FormData, cache, and signal opts are
all preserved (apiFetch passes opts through; apiUrl is idempotent for absolute
URLs).
Deliberately left as raw fetch (documented): the auth-exempt /health liveness
probe (custom timeout/backoff), the RemoteBackendPanel pre-save connectivity
test (uses a user-typed target+key), WaveformTimeline (branches on 404 + may be
a blob: URL), VoiceGallery playUrl (also serves external community-CDN URLs),
and bugReport's fetchJsonWithTimeout (hard 2.5s bound, no retry by design).
Updated the #532 in-app-playback regression test to assert via apiFetch.
Verified: oxlint 0 errors; vite build passes; full suite 638/638.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
chore(lint): adopt oxlint as the linter + CI gate; fix the bugs it surfaced (#761)
ESLint was misconfigured (only globals.browser → 47 false no-undef) and run
NOWHERE in CI, so 259 errors had accumulated unnoticed. Replace it with oxlint
(Rust, ~50-100x faster) as the primary linter AND a real CI gate so lint debt
can't silently pile up again.
Tooling:
- frontend/.oxlintrc.json — correctness=error; no-unused-vars with the existing
^[A-Z_] convention; node/vitest env overrides + AudioWorklet/__APP_VERSION__
globals (kills the false no-undef class); max-lines:500 (warn).
- package.json: `lint` → oxlint, `lint:fix`, `lint:hooks` (advisory eslint).
- eslint.config.js stripped to ONLY the React-Compiler rule family oxlint can't
do yet (set-state-in-effect etc.), run via `lint:hooks`, NOT gated. Drop once
oxlint's JS-plugin support leaves alpha.
- ci.yml: new "Frontend lint (oxlint)" step in the Tests job — the gate.
Real bugs oxlint caught (were buried in ESLint's noise):
- GlossaryPanel: <X/> close-icon used but never imported → the edit-row cancel
button threw ReferenceError at render. Imported X.
- Two use*-named NON-hooks (useEngine action, useArchetypeAsProfile API call)
tripped rules-of-hooks; suppressed with documented disables (renaming these
misleading names is a worthwhile follow-up).
Cleanup to reach a 0-error gate: removed 52 genuinely-dead vars/imports across
18 files (heavy in App.jsx — stale useState left over from prior refactors) and
4 behavior-preserving autofixes (no-useless-fallback-in-spread / no-useless-escape).
Verified: oxlint 0 errors; bun install --frozen-lockfile in sync (Docker rule);
vite build passes; full suite 638/638.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
refactor(pages): modularize Clone/Gallery/Profile pages (all files <500) (#760)
Phase 3 — same standard as #758/#759, applied to the last three over-cap pages.
Pure-mechanical, no behavior change.
- VoiceGallery.jsx 768 → 205: relocate the already-separate zone components
(ArchetypesZone, ArchetypeCard, CommunityZone, ImportsZone) + shared helpers
into components/gallery/.
- CloneDesignTab.jsx 837 → 395: split the ~540-line JSX return into section
components (ScriptPanel, AudioMethodPanel, DesignMethodPanel, ActionBar) +
MicButton, under components/clone/. State stays in the page.
- VoiceProfile.jsx 515 → 287: split the main return into ProfileHeader /
ProfileDetails / ProfileActivity under components/profile/.
Safety contract for the JSX splits (no render tests): explicit NAMED props on
every section so eslint no-undef verifies completeness on both ends; JSX moved
verbatim. Verified: 0 no-undef across all changed files; every original
className preserved (diffed main vs new set); every file <500 lines.
Verified: vite build passes; FULL frontend suite 638/638 pass.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
refactor(dub): modularize DubTab page (1593→380 lines, all files under 500) (#759)
* refactor(dub): extract DubTab sibling sub-components into components/dub (1593→1361)
Phase 2 (partial). Move the 5 self-contained presentational sub-components out
of the oversized DubTab.jsx into a new components/dub/ folder, matching the
components/settings/ pattern. Pure-mechanical, logic byte-for-byte identical.
Extracted (each with its own private helpers/constants):
- DubFailureNotice, DubPipelineStepper (+DUB_PIPELINE/DUB_PHASE_BY_STEP),
PrepOverlay (+PREP_FULL/PREP_CACHED/fmtBytesRate/fmtEta), TranscribeOverlay,
FooterBtn. fmtDur stays — it's used by the main component.
Pruned imports orphaned by the moves (copyText, errorDocsMap, a few icons).
Verified: vite build passes; dub tests (dubExpiredJobError + DubbingDemo)
11/11 pass; no new lint errors.
NOTE: DubTab.jsx is still 1361 lines — the main component is one ~1000-line
stateful JSX return over 28 hooks. Getting it under the 500 cap needs that JSX
split into section components, a higher-risk change deferred for a careful,
test-backed pass (see docs/maintenance-pages-modularization.md).
* refactor(dub): split DubTab JSX into section components (1361→380, all files <500)
Completes Phase 2. The DubTab component was one ~1000-line stateful JSX return.
Split that markup into five section components under components/dub/, keeping
ALL state/hooks/handlers/effects inside DubTab — only the JSX moved (verbatim,
by line-slicing).
Safety contract (this is behavior-critical and has no render test):
- Explicit NAMED props on every section (no bag/context object), so eslint
no-undef verifies prop completeness on BOTH ends — a dropped value becomes a
build error, not a silent runtime undefined. Verified: 0 no-undef across all files.
- All 137 classNames from the original are preserved (diffed main vs new set).
New sections: IdleSkeleton (368), DubLeftColumn (336), DubRightColumn (172),
DubFooter (78), DubHeader (63). DubTab.jsx is now a thin composition (380).
Verified: vite build passes; FULL frontend suite 638/638 pass; every settings &
dub file now under the 500-line cap.
---------
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
refactor(settings): modularize Settings page (1969→399 lines, all files under 500) (#758)
* refactor(settings): extract Settings.jsx tabs into components/settings (1969→602 lines)
Settings.jsx had grown to 1969 lines — every edit reloaded the whole file
into context and risked unrelated breakage. This finishes the migration the
existing components/settings/*Panel.jsx pattern started: the page is now a
thin orchestrator and each heavy tab lives in its own file.
Extracted (logic byte-for-byte identical; only import paths adjusted + the
shared isTauri/askConfirm moved to components/settings/native.js):
- GeneralTab, ModelStoreTab, EnginesTab, HotkeyTab, CredentialsTab
- native.js — shared isTauri() wrapper + askConfirm() Tauri-dialog helper
Also establishes the standard so files can't silently regrow:
- CONTRIBUTING.md: frontend file-structure & size limits (soft 300 / hard 500)
- eslint.config.js: warn-only max-lines:500 guardrail (CI stays green)
- docs/maintenance-pages-modularization.md: the phased refactor plan
Verified: vite build passes (all imports resolve); 18/18 settings tests pass;
no new lint errors introduced (the pruned imports were the only regressions).
Follow-ups (tracked in the plan doc): ModelStoreTab.jsx is 836 lines and
Settings.jsx 602 — both still over the 500 cap (warn-only); split next.
* refactor(settings): split ModelStoreTab + Settings.jsx under the 500-line cap
Follow-up to the tab extraction: bring the two remaining over-cap files into
compliance with the new standard. Pure-mechanical, no behavior change.
Settings.jsx 602 → 399:
- Extract AboutTab, PrivacyTab, LogsTab into components/settings/
- Move the shared Row helper to components/settings/Row.jsx
- LogsTab keeps its state in Settings() (lower-risk); About/Privacy take props
ModelStoreTab.jsx 836 → 439, split into components/settings/models/:
- format.js (fmtBytes/orgColor), runtime.js (computeRowRuntime)
- columns.jsx exposes makeModelColumns(...) — a factory so the TanStack cell
closures keep working; called with the same useMemo dep array as before
- ModelsTable.jsx (virtualized table view), RecoBanner.jsx
Every settings file is now under 500 lines. Verified: vite build passes;
18/18 settings tests pass; no new lint errors (the 4 remaining in Settings.jsx
are pre-existing — refreshInfo no-op, a catch(e), two set-state-in-effect).
---------
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(settings): contain + tighten the whole Settings surface (design-system pass) (#750)
* fix(settings): contain + tighten the whole Settings surface (measure cap, container-query stacking, wrap the shared rows)
Two systemic issues drove 'too spread out' + 'elements go out of view' across
many Settings pages:
1. Spread — .settings-content capped at 1280px, so on wide windows every
label-left/control-right row left a huge void. Introduce a --settings-measure
token (720px, macOS-like) + --settings-rail, and cap the content to it,
left-aligned under the nav. One token now controls the reading width.
2. Overflow + bad responsiveness — the row stack break was a *viewport* media
query (560px), but the 168px nav rail means a 760px-viewport window only has
~530px of content, so rows went side-by-side in a cramped box. Make
.settings-content a container (container-type: inline-size) and stack on the
CONTENT width via @container, keeping the viewport @media as a fallback for
the .st-row instances used outside Settings (Splash/FirstRun/Dub/SetupWizard).
3. The shared .perfpanel__row (button/badge row reused by 6+ panels:
RemoteBackend, HFMirror, LLMEndpoint, Pronunciation, MCPBindings, …) was an
inline-flex with no wrap and no max-width, so it ran off the right edge —
add flex-wrap + max-width:100% + min-width:0. Plus two rigid-width fixes that
escaped the row cap: ApiKeys input min-width:220→0, Appearance scale floor.
Frontend builds clean; tokens, @container query, and the wrap all verified in the
emitted CSS bundle.
* fix(settings): center the settings block + tighten measure (kill the lopsided right void)
The capped content was left-aligned, so on a wide window everything jammed to the
left with a dead empty third on the right (screenshot). Center the whole settings
block (nav rail + content) as a unit via max-width + margin-inline:auto, and drop
the measure 720→660 so label→control rows read denser. The cap is computed from
the tokens (rail + gap + measure + page padding) so the content track lands
exactly at --settings-measure.
---------
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
docs(changelog): finalize the [0.3.8] release notes (date, ASR-hang scope, dev-launch fix) (#747)
Set the release date to 2026-06-29, extend the #730 entry to note the chunked
dub-stream path is bounded + pool-reset too (#742), and add the bun desktop
dev-launch fix (#745) under CI. release.yml extracts this section verbatim as
the GitHub Release body, so it's now tag-ready.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(dev): stop the Tauri dev app from killing concurrently's backend (bun desktop crash) (#745)
`bun desktop` runs concurrently[dev:api, dev:desktop] with --kill-others-on-fail.
dev:api is a uvicorn backend on :3900, but the Tauri app launched by dev:desktop
ALSO manages a backend — on boot it sees :3900 in use (and not yet healthy,
because the dev backend is still importing torch + loading 32 models) and
'takes ownership', killing the dev:api process. That exits 137, which trips
--kill-others-on-fail and tears the whole session down.
The Tauri app already supports TAURI_SKIP_BACKEND to skip backend management
(lib.rs:654) — it just wasn't wired for the concurrently-managed dev flow. Set
it on dev:desktop so the dev app attaches to concurrently's backend instead of
fighting it. Set only on dev:desktop (not dev:api, and not the standalone
`frontend` desktop script, which legitimately self-manages the backend).
bun's script shell evaluates the inline VAR=val cross-platform (verified), so no
cross-env dep / lockfile churn. Prod (desktop-prod) is unaffected — there the
Tauri app is the sole backend manager and orphan-kill is correct.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(tts): force re-download a corrupt-but-right-size model blob before giving up (#739) (#744)
snapshot_download's resume trusts an existing file by size, so a present-but-
corrupt blob is never re-fetched: the resume-repair 'succeeds' yet the reload
still raises the truncated-cache OSError, and the user was sent to a manual
delete-and-reinstall. Add a force=True path (force_download) and wire it as a
last resort — on the post-resume reload failure, force a full re-download once
(replacing corrupt blobs) and retry the load before falling back to the
actionable message. Force is reached only after a plain resume-repair didn't
fix it, so the common missing-file case still avoids re-downloading everything.
Tests: corrupt cache force-repairs on the 2nd failure (resume then force),
force_download is set only when force=True, and an unfixable cache still
surfaces the 'could not be auto-repaired' message.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(asr): reset the GPU pool when a chunked dub-stream chunk wedges too (#730) (#742)
The whole-file transcribe paths recover from a wedged worker via
run_transcribe_guarded's pool reset (#731), but the chunked dub transcribe-stream
only recorded a per-chunk timeout error and moved on — leaving the stuck thread
holding its GPU-pool worker, so subsequent chunks / a concurrent TTS generate
could still starve into 'can't reach backend'. Reset the pool on the per-chunk
TimeoutError via a small _reset_pool_on_wedge() helper (best-effort, no-op for a
plain executor). Closes the residual on #730.
Tests: helper resets a reset-capable pool and no-ops a plain ThreadPoolExecutor.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(tts): retry the incomplete-cache auto-repair so a transient blip doesn't dead-end (#739) (#741)
_repair_model_cache attempted snapshot_download exactly once; a single transient
failure (the very cause of an interrupted download) returned False and sent the
user back to a manual delete-and-reinstall. Wrap the re-fetch in a bounded retry
loop (3 attempts default, linear backoff) — snapshot_download resumes between
attempts so retries are cheap and idempotent. Counts/backoff are env-tunable
(OMNIVOICE_MODEL_REPAIR_RETRIES / _BACKOFF_S) for restricted networks and set to
zero-backoff in tests. Offline mode + the actionable fallback message are
unchanged.
Tests: retry-then-succeed self-heals, exhausted-retries returns False after N
attempts, single-attempt tunable, backoff disabled so the suite stays fast.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
docs: add rust as prerequisite for from-source builds (#704)
Adds Rust/Cargo as a from-source build prerequisite across the linux/macos/windows install docs. Thanks @Deepakv2104.
fix(asr): bound every transcribe path + reset the GPU pool on hang so a wedged ASR can't brick the backend (#730) (#731)
A whisperx/CTranslate2 transcribe can hang hard on some Windows+CUDA setups and
never return. ASR shares the small (1-2 worker) _gpu_pool with TTS, so one stuck
worker starved every other request — the next TTS generate then surfaced as
"Can't reach the local backend" though the process was alive (#720/#721/#723).
Two parts:
- Bound the three remaining unguarded whole-file transcribe paths (dub
whole-file dub_core.py, batch.py, live-dictation capture_ws.py) with
run_transcribe_guarded, matching the dub-QC/dictation/OpenAI paths that were
already bounded by #656.
- On timeout, run_transcribe_guarded now calls executor.reset() when the pool
supports it (_ResilientGpuPool, already built for the model-load-timeout case
in #589/#599): the wedged worker is abandoned and the next submit gets a fresh
one, restoring capacity without an app restart. Best-effort — a plain
ThreadPoolExecutor (tests) just gets the bound + actionable error.
Regression tests: pool.reset() is invoked on timeout; a non-reset pool still
bounds cleanly.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(generate): classify [Errno 32] Broken pipe as a lost-pipe error, not OOM (#715) (#722)
A BrokenPipeError surfacing from generation means the backend's stdout/stderr
pipe to the desktop shell that launched it closed mid-render (an orphaned or
relaunched backend) — not out of memory. _oom_friendly_reraise mislabeled it
"ran out of memory — try Flush," which never helps. Add a BrokenPipeError /
[Errno 32] branch (same pattern as the #705 WinError-193 and #437 permission
branches) that tells the user to restart the app instead. main.py already wraps
sys.stdout/stderr to swallow EPIPE; this catches the C-level writes inside the
native engine/torch that escape that guard.
Regression test covers both the typed BrokenPipeError and a string-wrapped
"[Errno 32] Broken pipe".
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(settings): harden control inputs against right-edge overflow (belt-and-suspenders) (#718)
Follow-up to the responsive-containment fix (#713). Make Settings control inputs
unable to overflow the available width regardless of inline widths a panel sets:
- RemoteBackend's Backend URL + API key inputs hard-coded style={flex:1,
minWidth:220} in a right-aligned 60%-max control — on a narrow control that
220px floor overflows. They're long-value fields, so lay them out as full-width
stacked rows (st-row--stack) with the shrinkable .st-input class instead.
- Add a universal guard: any text-ish input/select/textarea inside .st-row__control
gets min-width:0 / max-width:100% / box-sizing, so no panel's raw input can
spill past the row. Pairs with the page/row minmax(0,1fr) grids.
Pure presentation; 638 frontend tests pass; build clean.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(dub): pass OmniVoice's ffmpeg to yt-dlp so URL merge works off PATH (#712) (#716)
Dubbing a video URL on Windows (v0.3.8) failed with 'You have requested merging
of multiple formats but ffmpeg is not installed.' The download format selector
pulls separate video+audio streams, so yt-dlp muxes them via ffmpeg
(merge_output_format=mp4) — but yt-dlp only checks PATH, while OmniVoice's ffmpeg
is typically a bundled Tauri sidecar / imageio-ffmpeg binary that isn't on PATH.
yt_download_sync now sets ydl_opts['ffmpeg_location'] = find_ffmpeg() (the same
resolver the rest of the dub pipeline uses) when ffmpeg is resolvable; if it
isn't, the key is omitted so yt-dlp falls back to PATH as before (no regression).
Tests assert the location is passed when resolved and omitted when not.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(generate): self-heal schema + don't 500 a generated clip on a history-write fail (#710) (#714)
A synth that already produced and saved its audio could still return a 500:
'no such table: generation_history' — a DB that somehow missed schema init
(init_db's executescript never took) made the history INSERT raise after the
clip was done, losing the user's generation to a logging side-effect.
- Add db.ensure_schema(): idempotent CREATE ... IF NOT EXISTS + additive column
reconcile (no _migrate/alembic), safe to call from a write path.
- Generation history write now self-heals: on a sqlite OperationalError it runs
ensure_schema() and retries once; if it still fails it logs and returns the
audio anyway. A history-logging failure can never fail the generation.
Regression test: the write raises 'no such table: generation_history' before the
heal and succeeds after (fail-before/pass-after), plus ensure_schema idempotency.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(settings): contain page content within available width (no right-edge clip) (#713)
Right-side control values/pills (e.g. Privacy's LOCAL SQLITE / OFFLINE
TRANSLATION / NONE — NO TRACKING, and long stored-at paths) clipped off the
right edge on wide windows.
Root cause: both settings grids used a bare '1fr' track (= minmax(auto,1fr)),
whose 'auto' minimum is the content's min-size. A non-shrinking child — a nowrap
status pill or an unbreakable path — forces the track wider than the viewport,
and .settings-content's max-width can't claw that back, so it clips at the
window edge.
Fix: minmax(0, 1fr) on both grids so the tracks can shrink below content
min-size:
- .settings-page → 168px minmax(0, 1fr) (the content column)
- .st-row → minmax(0, 1fr) auto (a long title can't shove the control
off-screen; the label shrinks/wraps)
Shared layout primitives, so this contains EVERY settings page responsively.
Pure presentation; 638 frontend tests pass; build clean.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(dub): stream segments to disk to stop long-video RAM spikes (#639) (#709)
Takes over and completes #639 (original work by @trungthanh1288). Dub generation
held every segment's audio in RAM until final mix, so long/feature-length dubs
and big batches could exhaust memory. Segments now stream to disk as rendered;
the final track assembles from those files via a 30s-chunk memmap writer, so
peak memory stays flat regardless of length.
Completed on top of the original PR:
- Watermarking: keep the project's 'every OmniVoice audio carries the signature'
guarantee without double-marking. Since seg_<id>.wav is BOTH the downloadable
file AND the assembly input, mark each fresh segment once at synthesis and drop
the per-chunk embed in the memmap writer (the final mix inherits the mark) —
main's proven policy. Verified with real AudioSeal: 0.9999 detect confidence on
the final track and on seg WAVs; cached/silence not re-marked.
- Fix a crash regression: zero/negative-duration segments returned an in-memory
zero-length entry instead of writing empty audio (which raised). Regression test
added.
- Perf: drop per-segment gc.collect(); throttle empty_cache() to every 16th call
(the replaced code batched I/O to keep this off the hot path).
- Clean up the mix_<id> temp WAVs after assembly.
- Rewrite the watermark test for the multi-chunk (>30s) path; assert both the
final track and the seg WAV are marked, with no double-mark.
212 passed / 1 skipped; route inventory clean.
Co-authored-by: mergetest <test@local>
Co-authored-by: trungthanh1288 <trungthanh1288@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
docs(changelog): refresh the [0.3.8] headline for the release body (#708)
The headline predated the later 0.3.8 work. Bring it current — Settings
redesign, macOS native drag-drop (incl. macOS 26), the ASR CTranslate2-load
fallback, the pronunciation dictionary, and the more-honest error messages
(corrupt binary != OOM, model-id self-heal, stale-dub reset). release.yml
publishes this section verbatim as the GitHub Release body, so the headline is
the first thing users read on the v0.3.8 release.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(model): route every OMNIVOICE_MODEL read through the resolver; tighten WinError 193 match (#693, #705) (#707)
Follow-up from independent verification of #693/#705.
#693 (whole-class): the resolver only guarded the model-load site. A leaked
engine id in OMNIVOICE_MODEL still hit four other raw reads — most importantly
preload_model()'s model_info() probe, which failed on the bad value and
SILENTLY disabled warm-up (first /generate then ate the full load). Plus the
Settings 'model_checkpoint' display, the loaded-models list, and the engine_id
baked into exported persona bundles. Route all of them through
resolve_omnivoice_checkpoint() (personas keeps its '' unset marker, sanitizing
only a set value). Add a source-level recurrence guard so a future raw read
can't reintroduce the class.
#705: tighten 'winerror 193' -> '[winerror 193]' so the substring can't also
match WinError 1930-1939 (the portable 'is not a valid win32 application'
clause still covers non-Windows formatting).
48 tests pass (resolver + guard + audio-guard + route inventory); edited
routers/services import clean.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(generate): classify WinError 193 as a corrupt native component, not OOM (#705) (#706)
A synth failure from a corrupt or wrong-architecture native binary on Windows
([WinError 193] %1 is not a valid Win32 application — torch, ffmpeg, or a
bundled engine binary) fell through to the generic OOM message ('ran out of
memory — try Flush'), sending the user down a path that can't help.
_oom_friendly_reraise() now detects the WinError 193 / 'is not a valid Win32
application' signature (before the OOM fallback, joining the existing
torch.compile / decode-glitch / bad-instruct cases) and surfaces an actionable
'reinstall or repair that component; Flush won't help' message. Regression test
added.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(desktop): enable native HTML5 file drag-drop on macOS (#700) (#703)
The app's drop zones (clone reference, dub video, stories, batch) all use HTML5
dataTransfer.files, but tauri.conf.json never set dragDropEnabled, so it defaulted
to true — Tauri intercepts the OS file-drop and the webview's HTML5 drop never
receives the files. Most visible on macOS WKWebView and fully broken on macOS 26
(Tahoe). Set dragDropEnabled: false on the main window so the webview handles
native HTML5 drops uniformly across platforms.
(The Clone/Design textarea-resize half of #700 was already fixed for 0.3.8 by
#595/#607; the reporter is on v0.3.7.)
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
docs(changelog): complete the [0.3.8] section for this release batch (#701)
Add the user-facing entries that landed after the initial [0.3.8] draft:
- Changed: the full Settings redesign (#686/#690/#696) and the inline first-run
HF-token input (#687/#688).
- Fixed: OMNIVOICE_MODEL self-heal (#693), ASR CTranslate2 .so-load fallback
(#692), and the stale-dub recovery extended to initial upload/ingest (#695).
Bump the section date to the expected cut date (set authoritatively at tag time).
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(asr): fall back instead of crashing when CTranslate2's .so won't load (#692) (#699)
On hardened kernels / newer glibc (e.g. WSL2 glibc 2.43) CTranslate2's shared
object is rejected at load with 'libctranslate2…cannot enable executable stack'
— an OSError, not ImportError. The WhisperX/faster-whisper is_available() probes
only caught ImportError, so the OSError escaped and crashed the ASR/dub
preflight ('ASR backend initialization failed: …').
- Both probes now also catch the non-ImportError load failure and REPORT
(False, 'failed to load …') instead of raising — a probe must never raise.
- _auto_detect() routes every probe through a never-raising _probe_available()
so no exploding probe can crash engine selection; it falls through to
pytorch-whisper (transformers, no CTranslate2), which works on CUDA/CPU.
Regression tests cover the raising probe, the .so-load OSError surfacing as
unavailable, and auto-detect falling back to pytorch-whisper.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(model): self-heal a leaked engine id in OMNIVOICE_MODEL instead of 500 (#693) (#698)
A stale/misconfigured OMNIVOICE_MODEL holding a bare TTS *engine id* (e.g.
"omnivoice") was passed straight to OmniVoice.from_pretrained(), which 500s
with "omnivoice is not a local folder and is not a valid model identifier
listed on huggingface.co/models".
Add resolve_omnivoice_checkpoint(): honor only a HF repo id (org/repo) or an
explicit local path (absolute / contains a separator); any bare token self-heals
to k2-fsa/OmniVoice with a logged warning — so a bad value can't brick model
load (and can't be faked by a cwd-relative folder of the same name). Regression
tests cover the leak, valid repo ids, absolute local dirs, and blanks.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(dub): reset gracefully on a stale job during initial upload/ingest (#695) (#697)
The #660 fix wired the stale-job recovery (isExpiredDubJobError → reset) into the
retry and SRT-import handlers, but NOT the two INITIAL handlers (handleDubUpload,
handleDubIngestUrl). So a job that went missing during the first upload→prep→
transcribe flow (backend reload, cache eviction, manual cleanup) surfaced the
scary "Job not found … report a bug" toast instead of quietly resetting the
stale session — exactly the reported error.
Route stale-job errors through isExpiredDubJobError() in both initial handlers
too (before the reportable fallback), matching retry/import. Add a source-level
regression guard so no dub handler can silently drop the stale-job check again
(the #660→#695 regression class).
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(settings): full-width content + fix right-side wrapping/overflow/padding (#696)
Owner review of the live pages: the 760px content cap left a dead empty right
half on simple tabs (Appearance), while wide tabs (Models) showed mid-word path
breaks, an overflowing HF_ENDPOINT input, and controls flush to the border.
- Content fills full width (removed the 760px cap + redundant models opt-out);
1280px ceiling only on ultra-wide. Comfortable side padding both sides.
- Read-only mono path values wrap only at boundaries (no `…cach/e…` mid-word).
- Inputs capped (min(360px,100%)) + box-sizing so HF_ENDPOINT/cache never overflow.
- Right padding on .st-row__control so controls aren't flush to the edge.
- Input-heavy rows (mirror preset, HF_ENDPOINT, cache location) go full-width
below their label instead of a crushed right slot.
Pure presentation; 636 tests pass; build clean.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
refactor(settings): premium redesign — compact density, nav rail, unified controls (#690)
A design-council-driven overhaul of the Settings UI for a clean, professional,
compact-yet-gorgeous feel (Notion/Obsidian quality), addressing "looks amateur,
too tall, doesn't make sense":
- Typography: section titles move from mono-uppercase ("debug log" look) to
sans sentence-case 600; mono reserved strictly for data values. Three clear
type levels.
- Density: single-line ~32-40px rows (grid 1fr auto), hairline dividers instead
of card-per-row, one muted description max per row (SettingRow hardened so the
old double-description line is structurally impossible).
- Navigation: kill the rainbow per-tab accents → one --chrome-accent; ≥760px a
sticky vertical nav rail + a calm 760px content column (no stretch to the rail
height — the empty-void fix); <760px a no-wrap horizontal scroll strip.
- Controls: full-width horizontal grids for the font + theme pickers (were a
squeezed vertical stack); unified tile/toggle/input styling via a new
SettingsInput primitive; tokenized off-token literals.
- No tacky wrapping: descriptions wrap at a comfortable measure (text-wrap:
pretty, no orphans); short control values never break mid-word.
Pure presentation — no behavior, handler, prop, testid, role, or i18n-string
changes. 636 frontend tests pass; build clean; --chrome-* tokens only.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat(setup): compact inline HF-token input pinned with the Continue action (#688)
Replace the bulky HF-token card (icon + title + paragraph + input row + link)
with a single-line input bar — paste a token, Save — pinned right by the
'Waiting for required models…' / Continue button. Takes only the HF token; the
explanation collapses to a one-line prompt (hidden on narrow widths) plus a
'Get one free →' link, and a slim '✓ saved' confirmation. Same save path and
i18n keys; cleaner and lighter on the page.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(setup): pin the HF-token card next to Continue, not buried in the model list (#687)
The 'Add a free Hugging Face token for faster downloads' card sat at the bottom
of the scrolling model library, so users had to scroll past every model to find
it. Extract it into a standalone HfTokenCard and pin it in the wizard's
always-visible action area, right above the 'Waiting for required models…' /
Continue button — visible at a glance, click and paste a token without scrolling.
Compact hint so it doesn't crowd the button. No behavior change to saving.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
refactor(settings): declutter + redesign the Settings UI onto a shared design system (#686)
* refactor(settings): shared design-system primitives + shell restyle (unit 0)
Foundation of the Settings redesign. Adds reusable primitives
(SettingsSection, SettingRow, InfoHint, SettingsToggle, Collapsible) styled
purely with --chrome-* tokens, and restyles the Settings shell: reordered
icon tab-nav, inline tabs (General/Hotkey/Credentials/Logs/Updates/About/
Privacy) migrated to the primitives, Proxy/FFmpeg/advanced rows tucked into
Collapsible, long prose moved into InfoHint popovers. Row() delegates to
SettingRow. No behavior changes; ModelStore table/SSE untouched.
* refactor(settings): restyle all panels onto the design system (units A/B/C)
Migrate the 13 settings panels to the shared primitives — pure presentation,
no behavior change:
- Bucket A (Aec/Performance/Refinement/HFMirror/LLMEndpoint/MCP/Pronunciation):
long prose (torch.compile OOM, refinement examples, etc.) moved into InfoHint
popovers; custom checkboxes → SettingsToggle.
- Bucket B (ApiKeys/RemoteBackend/Sharing): Tailscale/help prose → InfoHint +
Collapsible 'Advanced'; ApiKeys/Sharing CSS converted off hardcoded colors to
--chrome-* tokens (they mis-themed on 5 of 6 themes).
- Bucket C (Appearance/Storage/Voice): VoicePanel switch → SettingsToggle;
Appearance/Storage CSS tokenized; prose → InfoHint.
- SettingsToggle now forwards arbitrary props (data-testid/aria) to the input.
All 636 frontend tests pass; build clean; no new deps.
* test(settings): query VoicePanel/Appearance switches by role after SettingsToggle migration
The VoicePanel enable switch moved from a testid'd checkbox to the SettingsToggle
primitive (role=switch); update the assertion accordingly. Was missed in the
panel-restyle commit because this test lives under src/test/, not components/settings/.
---------
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat(tts): user pronunciation dictionary (expressive-tts slice 1) (#685)
* feat(tts): user pronunciation dictionary (expressive-tts slice 1)
Per-term, per-language pronunciation overrides applied to text before synthesis,
so names, brands, and acronyms come out right across generate, longform, and dub.
Closes part of the #1 perceived-quality gap vs ElevenLabs (pronunciation
dictionaries). First slice of docs/specs/01-expressive-tts.md.
- Schema: additive `pronunciation_entries` table (alembic 0008, mirrored into
_BASE_SCHEMA; tested upgrade — idempotent, downgrade, converge, back-compat).
- Service: extend pronunciation.py to load enabled entries (cached) and apply
longest-first, word-boundary-aware, per-language (global '*' + lang match,
lang overrides global), reusing the existing ReDoS-safe matcher.
- Inline one-off `[[term|replacement]]` overrides that don't persist and don't
collide with [voice:]/[pause]/[Name]/SSML-lite (resolved pre-chunking).
- API: /pronunciation CRUD + /test dry-run + import/export (loopback-guarded).
- Apply point: generation.py after language resolves, before chunking — covers
native + pluggable engines.
- UI: PronunciationPanel in Settings → General; all strings via i18n.
- Tests: migration lifecycle, CRUD, per-language, precedence, inline override,
apply-at-synth. Route snapshot regenerated (+7).
* fix(security): bound inline-override regex (ReDoS) + annotate parameterized UPDATE
CodeQL flagged py/polynomial-redos on the [[...]] inline-override regex: [^\]]
also matches [, so an unterminated run of [ allowed O(n) rescans from O(n)
positions. Bound the inner class to {0,256} (linear; an inline override is a
short respelling). Annotate the dynamic UPDATE (B608) — its column fragments are
fixed literals and every value is a bound parameter; not an injection vector.
---------
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
docs(specs): ElevenLabs-parity roadmap + Tier-1 implementation specs (#684)
Add the implementation-ready spec set mapping OmniVoice to ElevenLabs parity
while preserving local-first:
- 00-roadmap-elevenlabs-parity.md — gap analysis, prioritized tiers, sequencing,
prior-art reconciliation, and the deliberate "won't build" list.
- 01-expressive-tts.md — engine-agnostic emotion/style intent lowered onto each
TTS engine's real mechanism (degrade-visibly) + a DB-backed pronunciation dict.
- 02-conversational-agent.md — fully-offline full-duplex voice agent (/ws/converse,
Silero-VAD barge-in on AEC-cleaned mic) composing existing streaming STT/TTS + LLM.
- 03-longform-studio-editor.md — per-segment edit/regenerate across dub/audiobook/
stories, extending the existing content-addressed cache to longform.
Reconcile prior planning docs: banner the superseded parity/studio docs pointing
here; keep distinct-scope docs untouched (classification table in 00).
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat(dictation): live local dictation via sherpa-onnx + Voice settings panel (#683)
* feat(dictation): live local dictation via sherpa-onnx + Voice settings panel
Add a sherpa-onnx ASR engine alongside the existing Whisper/NeMo dictation
path, powering a genuinely live experience: as you speak, words type straight
into the focused field (streaming partials via a new simulate_type command,
self-correcting with backspaces) and commit per pause.
Backend:
- SherpaDictationBackend + sherpa_dictation registry of the 7 models (Parakeet
TDT v3/v2, streaming Zipformer EN/ZH/bilingual, Paraformer bilingual, Whisper
Tiny) from csukuangfj/* int8 HF repos; CPU provider for cross-platform parity.
- /dictation/models + /dictation/prefs router; get_capture_asr_backend() honors
the selected dictation model. get_active_asr_backend() (dub transcription) and
the legacy WebM/Opus capture path are untouched.
- True streaming over /ws/transcribe (OnlineRecognizer: live partials +
per-endpoint finals); offline models surface partials via short re-decode.
Frontend:
- New "Voice" settings panel (enable, Toggle/Hold mode, model picker with
offline/streaming/recommended badges + per-model download/delete).
- Live word-by-word typing via simulate_type (enigo) with prefix-diff delta and
backspace correction; paste fallback retained, no double-insertion.
Deps: sherpa-onnx>=1.13.3 (+ sherpa-onnx-core); uv.lock regenerated, Docker
frozen-install verified. API route-inventory snapshot updated. 40+ new tests.
* docs(dictation): register sherpa-onnx-asr engine in README + features inventory
Fixes the docs-drift CI guard: the new sherpa-onnx-asr ASR engine existed in
the registry but not in docs/features.yaml or README. Adds the live-dictation
engine row to the ASR Engines table, bumps the engine counts (8→9), and adds
the inventory entry.
* docs(changelog): fold live-dictation into the [0.3.8] section
main is 0.3.8 (untagged), so the dictation feature belongs in that release, not
a separate [Unreleased] block. Merge the two Added lists under one [0.3.8],
refresh the headline to lead with live dictation, and correct the capture
description to reflect live word-by-word typing (not paste-on-pause).
---------
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(design): don't forward a clone profile_id in design mode (gender attribute no-op) (#674) (#679)
In Voice Design, choosing "Male" (or any gender) could have no audible effect.
Root cause: the design synthesize branch forwarded the selected `profile_id`
alongside the design instruct. If that profile is a CLONE (reference audio, no
instruct) — e.g. the demo voice selected by default — the backend clones it, and
the reference voice's gender/timbre overrides the "male" attribute, so the design
slider appears to do nothing.
Fix: a pure `designModeProfileId(selectedProfile, profiles)` decides what to send
in design mode — it suppresses a KNOWN clone (no instruct) so the design
attributes drive the voice, while a design profile (carries an instruct) still
passes through to re-render a designed voice. Conservative: an unknown id
(profiles not loaded) or a design profile is unchanged, so this only removes the
gender-hijacking case. Threaded `profiles` into useTTS.
Test: voiceInstruct.test.js — clone (no/empty instruct) → null, design profile →
its id, empty/null → null, unknown id → passthrough.
Closes#674
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(model): actionable "reinstall transformers" hint on a corrupted-install model-load error (#676)
A model load failed with `[Errno 2] No such file or directory:
'…/site-packages/transformers/models/qwen3/modeling_qwen3.py'` — the user's
transformers install was incomplete (the file is missing while a correct 5.3.0
install has it; an interrupted `uv sync` / antivirus / partial update drops it).
The System Check showed the raw path + "Check logs and try restarting", which is
useless — restarting can't restore a missing file.
Two fixes:
1. core.failure.classify(): recognize this corrupted-install variant. It's a
FileNotFoundError, not an ImportError, so the existing TRANSFORMERS_IMPORT
match ("could not import module"/"AutoFeatureExtractor") missed it. Now also
matches a "no such file"/"errno 2" + "transformers" + "site-packages" signal
(substrings checked separately so it works on POSIX `/` and Windows `\`
paths). An unrelated package's missing file is NOT mislabelled.
2. model_manager._load(): build the /model/status error via build_failure so it
carries the classified hint AND strips the home dir, instead of storing the
raw str(exc). The System Check now shows "Your transformers install is
incomplete. Reinstall it (uv pip install --reinstall transformers) or switch
ASR to faster-whisper" — the existing TRANSFORMERS_IMPORT hint.
Docs: troubleshooting §1a documents the error + the reinstall fix.
Test: test_failure_classify.py pins the POSIX + Windows path forms classify as
TRANSFORMERS_IMPORT with a "reinstall" hint, and that an unrelated package's
missing file does not.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(setup): use the backend's authoritative aggregate for live download progress (#675)
The first-run download line showed wrong numbers — e.g. "8% · 1 KB/s · 0.0 MB
left" on a 2.4 GB model that was barely started. The #657 display summed the
PER-FILE tqdm SSE events on the frontend, but under parallel/segmented fetch the
big weight shards report total/rate as 0, so the sum was garbage (tiny total →
"0.0 MB left", a couple small files → "1 KB/s").
The backend already solves this: download_aggregator emits a throttled
`phase:"aggregate"` event with one windowed rate + ETA + bytes_done/total_bytes
(+ files done/total), seeded by the dry-run preflight totals — precisely because
summing per-file on the client is unreliable. But WizardLibrary dropped that
event (`if (!ev.filename) return prev`) and never used it.
Fix: capture the `aggregate` event into per-repo state and render from it
(new pure `progressFromAgg`), falling back to the per-file sum only until the
first aggregate arrives. Now the line shows real, live values, e.g.
"8% · 5.2 MB/s · 2.2 GB left · ~7m", updating in real time and landing on 100%.
Test: wizardLibraryAggregate.test.js — progressFromAgg yields correct
pct/remaining/rate/ETA from real totals (2.2 GB left, 5.2 MB/s — not 0.0 MB /
1 KB/s), returns null until totals are known, and caps pct at 100.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
docs(changelog): draft the [0.3.8] release section (#673)
Renames [Unreleased] → [0.3.8] — 2026-06-24 with a one-paragraph headline in the
house style, and adds the entries merged since v0.3.7 that weren't yet logged:
faster default downloads + the surfaced HF-token card (#669/#657), the auto-play
toggle (#666), the status-bar version badge (#671), and the Windows/stability
fixes — WhisperX-on-Windows (#630), transcribe timeout (#656), preview playback
(#653/#659), stale dub session (#660), bad-instruct 400 (#664/#612), Insert
popover clipping (#672), and the M1 startup-hang bound (#632). A fresh empty
[Unreleased] is left above it for the next cycle.
This makes cutting v0.3.8 a single `git tag` away: release.yml extracts this
section verbatim as the GitHub Release body, so the tag ships real notes instead
of the auto-generated fallback. (Owner adjusts the date if tagged on another day;
no version files touched — this is docs only.)
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(clone): cap the ⊕ Insert popover height so it can't clip off the top of the window (#672)
On the Voice Clone tab, the ⊕ Insert popover (15 expression-token chips) opens
upward from the lifted button (`bottom: 60px`) but had NO max-height — so the
wrapping chip grid grew unbounded and, when the button sat high in a tall script
panel, the popover shot past the top of the app window and the first rows were
clipped behind the title bar (reported with the tokens overflowing above the
OmniVoice header).
Cap it: `max-height: min(280px, calc(100vh - 120px))` + `overflow-y: auto`
(+ `overscroll-behavior: contain`). The popover is now a compact, scrollable box
that sits just above the button and always stays within the viewport, regardless
of how tall the script is or where the button lands. Horizontal guard (#481) and
the upward anchor are unchanged.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat(footer): clickable version badge → Updates, with an update-available indicator (#671)
The bottom status bar showed no version and had no quick path to updates. Add a
small `v<version>` badge next to the network/share icon; clicking it opens
Settings → Updates. When an update is available (or downloaded and ready), the
badge highlights and shows a pulsing notification dot, and its tooltip names the
new version — so users can see at a glance that an update is waiting and one
click takes them to install it.
Mechanism: a one-shot `pendingSettingsTab` hand-off in the UI store (mirrors the
existing `pendingProfileId` pattern) + an `openSettingsTab(tab)` convenience that
sets the tab and navigates in one call. Settings consumes it as its initial tab
and clears it (an effect covers the already-open case). The indicator reads the
existing `updateStatus`/`updateVersion` from the updater slice — no new update
plumbing. Version from the shared APP_VERSION constant; new strings via i18n.
Test: openSettingsTab.test.js — the convenience sets mode=settings + the pending
tab, and the value can be cleared after consumption.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat(setup): faster downloads by default + prominent, encouraged HF-token entry (#669)
Two changes that make first-run downloads faster and easier to speed up further.
1. Segmented (multi-connection) downloader is now ON by default. The app forces
the legacy-LFS path (HF_HUB_DISABLE_XET=1) for clear progress, but that path
is single-stream and slow — which is why downloads felt sluggish. The built-in
IDM/uGet-style segmented accelerator (parallel byte-ranges, live speed/ETA)
was already implemented but defaulted OFF. Flip it ON: it only engages when
Xet is inactive (the default), and ANY failure falls back to snapshot_download
("can never compromise a correct install"). Pure-httpx, cross-platform,
auth-safe (token never forwarded to a CDN). Override with
OMNIVOICE_SEGMENTED_DOWNLOAD=0.
2. The Hugging Face token field is now a prominent, always-visible card right
above Continue — was a collapsed "advanced" fold almost nobody opened. A free
token gives authenticated downloads (higher rate limits, fewer stalls), so it
pairs with change #1 to keep the parallel fetch from getting throttled. The
card leads with the speed benefit, shows a saved-state, and adds a one-click
"Get one free →" link to huggingface.co/settings/tokens.
Docs: downloading-models.md updated — the legacy-LFS section now documents the
default-on segmented accelerator + the HF-token speed tip, and the tuning table
reflects OMNIVOICE_SEGMENTED_DOWNLOAD=0 as the disable knob (docs-sync).
Test: test_segmented_download_default.py pins the new default ON and that the
env override still disables it; existing FDL-08 behavior tests stay green.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat(settings): add opt-out for auto-playing the preview after a render (#666) (#667)
After a render finishes in Voice Clone / Design / a profile's try-it box, the
output preview auto-plays unconditionally — `autoPlay` was hardcoded on the
WaveformPlayer. A user batch-generating Korean clone segments asked to turn it
off so each finished clip doesn't start playing on its own.
Add a persisted `autoPlayPreview` pref (default ON — preserves current behavior)
with a Settings → Appearance toggle, and thread it into the two preview call
sites (VoicePreview.jsx, VoiceProfile.jsx) so `autoPlay={autoPlayPreview}`.
WaveformPlayer already gates playback on the prop, so off = no auto-play; the
manual Play button is unaffected. Cross-platform-parity safe: it's a pure UI
preference that behaves identically on macOS/Windows/Linux, default unchanged.
New strings go through i18n.
Test: AppearancePanel.test.jsx — the toggle defaults checked (ON) and flipping
it sets the store to false.
Closes#666
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(generate): classify a bad-instruct error as a 400, not a 500 "ran out of memory" (#664) (#665)
A user typed free-form prose ("Speak with high energy … like a podcast host")
into the voice-design instruct field and got a **500** whose message read "TTS
engine stopped mid-generation. This usually means it ran out of memory. Try the
Flush button …" — with the real cause ("Unsupported instruct items found …")
buried as the underlying error. The user is told to Flush for an OOM that never
happened; the actual problem is a rejected instruct.
Root cause: `_resolve_instruct` raises on unknown/conflicting instruct items, but
by the time the error reaches `_oom_friendly_reraise` it's no longer a bare
`ValueError` (a lower layer wraps it), so the route's `except ValueError -> 400`
guard misses it and it falls through to the generic OOM `RuntimeError`. v0.3.7
has had that guard since v0.3.6 yet still produced the OOM message — proving the
error arrives wrapped, so type-based detection is insufficient.
Fix: in `_oom_friendly_reraise`, detect the instruct-validation **message
signature** ("unsupported instruct items" / "conflicting instruct items" / "in a
single instruct") regardless of exception type and re-raise a clean `ValueError`,
so the route returns a **400 with the instruct guidance** instead of a 500 OOM.
This is version-independent and complements the client-side guard (#658/#612):
it also covers API/MCP callers and stored profiles whose instruct slips through.
Test: two cases in test_generation_audio_guard.py — a bare instruct `ValueError`
and one wrapped in a `RuntimeError` both reclassify to a ValueError without the
"ran out of memory" text; the generic OOM path is unchanged.
Closes#664
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(dub): reset stale dub session gracefully instead of erroring "Job not found" (#660) (#661)
A persisted `dubJobId` outlives the backend's in-memory job store — after a
backend restart (or once a job is cleaned up), resuming/retrying a dub returns
404 "Job not found. It may have been cleaned up or was never created." The UI
surfaced this expected stale-session state as a hard error toast *with a "report
a bug" prompt* (toastErrorWithReport), so a user who just reopened the Dub tab
(#660: only action was view:dub) got a scary, un-actionable error for what is
really "your old session is gone — start a new one."
Fix the class: add a pure `isExpiredDubJobError(err)` predicate (matches the
dub_core preflight message, the dub_generate expired-session message, and a bare
404 "Job not found") and a `_resetStaleDubSession()` helper that clears the dead
job id/state, drops any pill, and shows a calm info toast inviting a fresh
upload. Wired into the two handlers that operate on a pre-existing job —
retry-transcribe (the #660 path) and SRT import. The fresh upload/ingest paths
are intentionally left reporting real errors: a just-created job going missing
*is* a bug worth reporting.
Test: dubExpiredJobError.test.js pins the predicate against both backend
messages + a bare 404, and asserts unrelated failures (stream dropped, CUDA OOM,
abort) stay reportable.
Closes#660
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(net): Windows preview playback — 127.0.0.1 loopback + quieter decode-fallback log (#659)
Two coupled Windows fixes for the preview/blob audio path (the "playBlobAudio
decode error: EncodingError: Unable to decode audio data" users see in
Logs → Frontend on Windows).
1. apiBase 127.0.0.1, not localhost (Tauri context). The backend binds IPv4
127.0.0.1 only; on Windows "localhost" often resolves to ::1 (IPv6) first, so
requests miss the backend. The main client (api/client.ts) already did this
since #174, but utils/apiBase.ts lagged on "localhost" — and its one consumer
is utils/media.js's preview upload, the #653 fallback. So #653's streamed
fallback fetched http://localhost:3900/preview/upload and FAILED on Windows,
leaving preview playback broken even after #653. Align the two resolvers.
2. Quieter, accurate logging in playBlobAudio. The Web Audio decodeAudioData
path is EXPECTED to fail for long-form / AAC renders on WebView2 and is
recovered by the streamed fallback — yet it logged at error level, so users
saw a red "decode error" even when playback succeeded. Downgrade that branch
to console.warn ("falling back to streamed playback"); reserve error level for
the real failure (both decode AND fallback failed). With fix#1 the fallback
now actually reaches the backend on Windows, so the recovery completes.
Tests: apiBase.test.ts asserts Tauri → http://127.0.0.1:3900; the existing
playBlobAudioFallback.test.js (#653) still passes (fetch hits /preview/upload,
plays the HTTP URL, never a blob:).
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(synthesize): validate clone-path instruct client-side so non-EN/ZH prose can't 400 (#612) (#658)
A Vietnamese user typed a free-form Vietnamese description into the voice style
(instruct) field and got "400 Bad Request: Unsupported instruct items found in
quảng cáo, sôi nổi và thu hút". The instruct field is a fixed EN/ZH style-tag
vocabulary (the model's trained tokens: gender/age/pitch/accent/dialect/whisper);
the backend _resolve_instruct deliberately *raises* on unknown items.
The design path already guarded this: it runs the free-text through
buildDesignInstruct(), keeping valid tags, dropping the rest, and surfacing a
localized warning toast (#115/#114). But the *clone* path
(defineMethod === 'audio') appended the raw `instruct` string straight to the
request — so a clone + free-text style in any non-EN/ZH language round-tripped to
a 400 instead of being handled locally.
Fix (localized client-side guard, the chosen approach): route the clone path's
free-text through the same buildDesignInstruct({}, instruct) guard. Valid style
tags survive (a clone can still ask for "whisper"); unsupported items drop with
the existing localized `tts_errors.ignored_unsupported` toast; synthesis proceeds
in the user's language without style control instead of failing outright. No
backend/engine change — the model genuinely can't honor non-EN/ZH instructs, so
this makes the failure graceful and understandable rather than a raw 400.
Test: two cases in voiceInstruct.test.js pin the clone scenario — a fully
Vietnamese instruct yields "" + all items in the unsupported bucket, and a mixed
"whisper, sôi nổi" keeps "whisper" while flagging the prose.
Closes#612
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat(setup): show live download rate + size-remaining, surface HF token as a speed lever (#657)
The first-run Models & Engines page showed only "downloading…" (and, once totals
arrived, a bare percent + ETA). Users asked to see the actual download rate, the
size remaining, and a way to speed downloads up.
The backend already streams per-file byte counts and a windowed rate over SSE —
the UI just wasn't surfacing it. Changes (frontend-only):
- aggregate() now also returns live rate + bytes-remaining (was pct + ETA only),
and is exported so the speed/remaining math is unit-tested.
- The download line now reads e.g. "38% · 5.2 MB/s · 1.2 GB left · ~3m", each
part shown only once the stream has it (still degrades to "downloading…" early).
- New fmtBytes()/fmtRate() helpers (MB/GB, MB/s↔KB/s).
The Hugging Face token field already existed but was buried in an "advanced"
fold and framed only as "unlocks gated models" — so users hunting for a faster
download never found it. Reframed the title/hint to lead with what they want:
authenticated downloads are faster, have higher rate limits, and stall less
(and still unlock gated models like pyannote diarization). Token persistence and
the segmented/faster downloader (segmented_download.py) are unchanged — this just
makes the existing speed levers visible.
Test: frontend/src/test/wizardLibraryAggregate.test.js — aggregate sums bytes,
ignores completed-file rate, returns nulls before totals; fmtBytes/fmtRate
formatting + idle blanks.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(asr): bound whole-file transcription so a stall isn't reported as "can't reach backend" (#656)
A Windows/CUDA user (Vietnam) hit "Can't reach the local backend" only when
dubbing/transcribing. Their log proves the backend started fine — model loaded,
preload complete, 25 models — and the log ends right after
`whisperx transcribing …tmp.wav`. The backend was alive; the *transcription*
stalled (large-v3 ASR contending with the resident TTS model for VRAM on an
8 GB-class GPU), which the UI surfaces as an unreachable backend.
Root cause (class, not instance): the chunked dub pipeline already bounds each
chunk (OMNIVOICE_TRANSCRIBE_CHUNK_TIMEOUT_S), but the *whole-file* transcribe
paths ran unbounded:
- dub QC re-transcribe (dub_export)
- dictation (capture)
- OpenAI-compat /audio/transcriptions
A slow/stuck transcribe on any of these hung the request AND held a GPU-pool
worker — indistinguishable from a dead backend.
Fix: add run_transcribe_guarded() in services/asr_backend.py — a shared
asyncio.wait_for wrapper (ASRTimeoutError, a TimeoutError subclass) with a
generous env-tunable bound (OMNIVOICE_ASR_TRANSCRIBE_TIMEOUT_S, default 300 s).
On timeout the request returns 504 with actionable guidance (backend is alive;
free VRAM / pick a smaller ASR model / use CPU; restart to clear the stuck
worker) instead of hanging forever. Wired into all three whole-file paths.
Docs: new troubleshooting §14 — "Can't reach the local backend during
transcription/dubbing" — explains it's ASR weight/VRAM pressure, not a network/
mirror problem, and corrects the misconception that a "Network → Restricted/Global
mirror" Settings toggle exists (the Network control is LAN sharing). Serves the
#602/#585/#567 "can't reach backend" cluster.
Test: backend/tests/test_asr_transcribe_timeout.py — slow fn raises ASRTimeoutError
with the actionable message, fast fn passes through, subclass-of-TimeoutError so
the openai_compat broad catch still maps to 504.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(asr): cross-platform speechbrain lazy-import guard — unblock WhisperX on Windows (#630) (#655)
WhisperX (the default ASR) aborts transcription with zero segments on Windows
only, surfacing "Lazy import of LazyModule(...speechbrain.integrations.k2_fsa...)
failed" (#630) or its generic wrapper "Transcribe stream dropped..." (#611, #647).
Root cause is in speechbrain 1.x. It exposes optional integrations (k2_fsa,
numba losses, spacy/flair nlp) as LazyModule redirects in sys.modules. Stray
introspection during whisperx.load_model (pyannote -> speechbrain) — PyTorch's
op-registration machinery, pickling, a dir()/hasattr walk — touches one of these
redirects. speechbrain suppresses such inspect-triggered imports via a guard, but
the guard checks filename.endswith("/inspect.py") with a hardcoded POSIX
separator. On Windows the frame filename uses backslashes, the guard misses, the
redirect actually imports k2_fsa -> import k2 -> k2 not installed -> ImportError
that bubbles out and kills ASR. macOS/Linux use forward slashes, so the guard
fires and the feature works — a Windows-only break of a cross-platform default
(P0 parity).
Fix the whole class (every optional-integration redirect, not just k2) by
re-implementing LazyModule.ensure_module with a separator-agnostic basename check
(normalise both "\\" and "/"), applied right before whisperx loads. Idempotent;
a no-op on macOS/Linux and when speechbrain is absent; genuine missing-dep
accesses from real user code still raise ImportError — only inspect-triggered
spurious imports are suppressed, now on every platform.
Regression test fakes the importer frame with Windows- and POSIX-style inspect.py
paths plus a real-caller path, so it pins the behaviour on any CI host (fails
before the fix on the Windows-path case, passes after).
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(audiobook): play long-form preview via streaming HTTP, not decodeAudioData (#653) (#654)
In-app preview of a finished audiobook/story did nothing on Windows. playBlobAudio
(Tauri path) decodes the whole render into one PCM AudioBuffer via Web Audio
decodeAudioData, which throws "EncodingError: Unable to decode audio data" on a
long-form .m4b/AAC under WebView2. The catch-block fallback used a blob: URL,
which the file's own fileToMediaUrl notes does NOT play in a Tauri <audio>
element — so it silently played nothing.
The fallback now uploads the blob to /preview/upload (ffmpeg-extracts a
streamable WAV server-side) and plays the returned HTTP URL via <audio> — the
exact pattern video previews already use. Streams instead of whole-file-decode,
so it also fixes hour-long renders regardless of platform. Short WAV TTS previews
keep the fast decodeAudioData path. Regression test pins that the fallback hits
/preview/upload and plays an HTTP URL (never blob:). No version bump.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
chore(desktop-prod): add --keep-models for fast fresh-app runs (#650)
`bun desktop-prod` (clean) wipes everything including the HF model cache, so
every fresh-install emulation re-downloads multi-GB weights — slow and bandwidth
-heavy, and the exact pain users on flaky networks hit. --keep-models wipes
app/backend data, logs, and webview state for an honest first-run, but KEEPS the
model cache so the weights aren't re-pulled. Ignored under --keep-data (which
keeps everything). Adds the `desktop-prod:keep-models` convenience script.
Scripts-only package.json change — no deps, bun.lock unaffected.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat(setup): calmer descriptions + surface platform-tuned models by default (#649)
Two first-run setup polish items:
1. Descriptions dimmed + tightened (opacity 0.72->0.55 / 0.68->0.5, smaller
line-height/reserve) and shortened (subtitle, compute, channel, mode copy).
2. "Models & engines" (WizardLibrary) now surfaces optional models tuned for the
detected platform — those whose catalog "platforms" tag matches the host
(MLX mac-ARM on Apple Silicon, CUDA variants on NVIDIA) — up-front with a
green "recommended" chip + their note, folding only the universal long tail.
Generic across platforms; graceful when none match. No backend change (the
/models API already ships "platforms" + the host "platform_tags").
isPlatformPick extracted as a pure exported helper; 6 vitest cases. en.json +
JSX fallbacks synced; orphan check clean; vite build green. No version bump.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
test(onboarding): guard demo clip stays un-ignored + a bundled resource (#621) (#648)
A Windows user's log showed 'Demo audio not found … demo_voice.wav — skipping
onboarding seed'. The local bundle DOES ship the clip (verified), so that user
just has a pre-#633 build — but the existing test only checks the file exists in
the repo. It misses the two ways the clip could silently drop from ALL builds
while still sitting in the repo: (1) the .gitignore un-ignore allowlist
(!backend/assets/samples/*.wav) being weakened — gitignore-aware build walkers
would then skip it; (2) backend/ being removed from tauri.conf.json bundle
resources. Pin both. test-only.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(i18n): clear orphan-key advisory — add en bootstrap.lines, drop dead gallery.cat_* (#646)
The locale orphan-key judge flagged 20 non-en locales carrying keys absent from
en. Two distinct causes:
1. bootstrap.lines (used at BootstrapSplash.jsx:467, t('bootstrap.lines',{count}))
existed in de/es/fr/ja but NOT en — so English (and 16 locales falling back to
it) rendered the literal key instead of '{{count}} lines'. Added to en.
2. gallery.cat_* (anime/books/celebs/disney/gaming/marvel/news/politicians) were
renamed to archetypes.use_* long ago (VoiceGallery.jsx:309) but left orphaned
in 20 locales — 160 dead keys. Removed.
Zero orphans remain. Flipped the probe test to assert the judge now PASSES
(regression guard). Locale files edited losslessly (json indent=2, ensure_ascii
=False, trailing-newline preserved). No version bump.
Co-authored-by: mergetest <test@local>
fix(startup): timeout-bound MCP session-manager start to stop M1 startup hang (#632) (#645)
* fix(startup): timeout-bound MCP session-manager start to stop M1 hang (#632)
A reporter's faulthandler thread dump showed the asyncio loop alive but the
lifespan suspended at an await with an idle pool worker + a leaked semaphore —
the MCP Streamable-HTTP session manager hanging on its anyio task group during
startup (Apple-Silicon M1). Because `enter_async_context(_sm.run())` is awaited
before yield, the hang meant 'Application startup complete' never fired and the
backend was unreachable with no error — a P0 (default feature dead on a platform).
The MCP layer is explicitly best-effort, but the old guard only caught
exceptions, not hangs. Bound the start with asyncio.wait_for
(OMNIVOICE_MCP_START_TIMEOUT_S, default 30s): a hang → logged warning + backend
serves without MCP. Extracted _enter_mcp_session_manager + _mcp_start_timeout_s;
4 regression tests (hang→False fast, healthy→True, None→noop, env override). No
version bump.
* fix(startup): run MCP in its own task (anyio task-affinity) — fix CI cancel-scope error
The first attempt wrapped enter_async_context in wait_for, which entered the MCP
anyio task group in a throwaway sub-task while the AsyncExitStack exited it on the
lifespan task → 'Attempted to exit cancel scope in a different task' (caught by
test_coverage_critic's real backend boot). Correct fix: _serve_mcp owns the full
enter→exit in ONE task; _start_mcp_session_manager only waits (with timeout) on a
ready Event. A hang still can't block startup, and enter/exit share a task.
Shutdown signals stop + bounded-awaits the task. Tests updated (5; incl broken-
manager case). test_coverage_critic now boots+shuts down clean.
---------
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(dub): skip yt-dlp mtime stamp to avoid [Errno 22] on Windows (#642) (#644)
Dubbing a URL could fail with 'Unable to download video: [Errno 22] Invalid
argument' on Windows: yt-dlp stamps the downloaded file's mtime with the video's
upload date, and an out-of-range/invalid timestamp makes os.utime raise
[Errno 22], aborting the ingest. We download to a throwaway original.* and never
use its mtime, so set updatetime=False (yt-dlp --no-mtime). Regression test
asserts the opt is set. No version bump.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
docs(troubleshooting): add stuck-download / incomplete-cache recovery (#622) (#643)
The 'stuck on the download page, model folder has only refs/ no weights' case
(a connection dropping/blocking mid-pull) is a recurring support report but
wasn't in the install troubleshooting guide. Add section 13 with the recovery
steps + antivirus/VPN/mirror escalation + a huggingface-cli manual fallback.
Docs-only; no version bump.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(dub+generate): yt-dlp 403 player-client fallback (#625) + non-finite audio guard (#629) (#635)
Two independent fixes from issue triage; no version bump.
#625 — yt-dlp 403 on the media download (some videos serve formats
signature-protected to the default player client) is not transient, so the
existing broken-pipe retry (#579) kept 403ing. The URL download now escalates
the YouTube player client (tv → android → web_safari) on a 403 before giving up;
a 403 no longer counts against the transient-retry budget.
#629 — a numerical glitch in the model (seen on MPS) could leave NaN/inf samples
that write an unreadable WAV; a downstream decode then failed with an opaque
"ffmpeg returned error code: 183 / Invalid data", surfaced to the user as a
misleading "ran out of memory". Sanitize non-finite samples to silence in
_apply_effect_chain (single chokepoint, covers the raw path too) so the WAV is
always decodable, and classify a decode/ffmpeg failure as unreadable-audio
rather than OOM in _oom_friendly_reraise.
Tests: 403 escalation order + success-on-alternate-client; NaN/inf sanitize +
finite-passthrough + decode-error classification. Full suite 1851 passed.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat(startup): watchdog that dumps thread stacks on a startup hang (#632) (#634)
A silent hang during the FastAPI lifespan startup (reported as a Mac M1 hang
after 'Loading weights: 527/527') leaves the app unusable with no error: weights
load, then 'Application startup complete' never fires. Without a thread dump the
deadlock is invisible.
Arm faulthandler.dump_traceback_later at the top of the lifespan and cancel it
the instant startup completes (just before the yield). If startup stalls past
the window (default 300s, OMNIVOICE_STARTUP_WATCHDOG_S to tune, 0 to disable),
every thread's stack is dumped to stderr → backend_err.log, capturing the hang
point for #632 and any future startup deadlock. Best-effort + exit=False, so the
diagnostic can never itself break or kill startup; a normal (even slow-download)
boot disarms it first and never trips.
No version bump.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(onboarding): commit + bundle the demo voice clip (#621) (#633)
backend/assets/samples/demo_voice.wav is a build artifact (generated by
scripts/build_demos.sh) that was never committed, so it shipped absent from
installs: onboarding logged 'Demo audio not found', seeded nothing, and the
Launchpad was empty on first run + the /demo_audio route was unavailable.
The file is already un-ignored in .gitignore and bundled via the Tauri
'backend' resource — it just needed to exist in git. Commit it (regenerated
via the script's say/Samantha path, 24kHz mono 16-bit, content matching
DEMO_REF_TEXT) so first-run works on every platform. Onboarding keeps its
graceful skip (now with a regenerate hint) for a partial checkout.
Regression test guards the asset is present + valid and that onboarding seeds
the demo profile from it (and is a no-op on a non-empty DB). No version bump.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(dub): speaker-aware re-split so merged speaker turns separate (#486) (#616)
Segmentation groups words into sentences BEFORE diarization, so a two-speaker
exchange can land in one segment; assign_speakers_* then only relabels it with
the majority speaker, losing the turn boundary (the second half of #486 — the
per-speaker voice auto-assign was fixed in #490).
Add a post-diarization pass that re-splits any segment whose words span >1
speaker at the word-level boundary, assigning each piece its speaker:
- backend/services/segmentation.py: resplit_segments_by_diarization /
resplit_segments_by_turns + a pure _resplit_core. Single-speaker segments are
returned BYTE-FOR-BYTE UNCHANGED (same dict/id/text/start/end) — the
no-single-speaker-regression guarantee. Pieces keep the segment's outer
start/end (preserving onset-snap) and use word times for interior splits, so
they exactly cover the original span. A lone mis-attributed word is smoothed,
not split (diarization noise).
- backend/api/routers/dub_core.py: accumulate global-timeline words alongside
segments; apply the re-split after both the pyannote and FunASR-turns assign.
Heuristic fallback (no word-speaker data) is untouched.
8 regression tests pin the invariant + the split/3-way/noise-smoothing/label
behaviour. Full suite: 1836 passed.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(setup): weight-aware install-state so truncated model cache isn't read as installed (#622) (#626)
A first-run user whose model download was interrupted after the config/
tokenizer files landed but before the weight shard got stranded on the
Models & Engines page: GET /models computed "installed" purely from cache
size on disk, so a size-positive-but-weight-less cache reported installed=true,
the wizard hid the re-download button, and the model manager (Settings → Models)
that could repair it was unreachable behind the wizard gate.
Make install-state weight-aware. The boolean weight-floor scan now lives in
models.py (the lowest module in the setup import graph) as snapshot_has_weights()
+ cache_is_complete(); list_models() and recommendations() downgrade a truncated
cache to installed=false (+ an explicit incomplete=true on /models), so the
existing "install" action re-appears and the user can re-download in-wizard.
Fixes the whole class, not just /models: download.py's install-time validator
now delegates to the same shared scan (one source of the floors, can't drift),
matching the load-time repair in model_manager.py (#581/#606).
config_only repos (pyannote/speaker-diarization-3.1 — a pipeline whose real
weights live in referenced sub-repos and whose own cache is legitimately tiny)
carry a new config_only:true hint in models.yaml and are exempt, so they're not
false-flagged as incomplete.
Tests: tests/test_mm2_lifecycle.py — snapshot_has_weights truncated-vs-complete,
cache_is_complete on a truncated weight repo + config-only exemption, and
list_models downgrading a size-positive truncated cache to installed=false /
incomplete=true. Full backend suite green (1832 passed).
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
feat(stories): tagged-script [Name] parsing → auto multi-voice cast (#487) (#615)
Paste a `[Alice] … [Bob] …` podcast/audiobook script into Stories and Auto-cast
now builds the cast and assigns a voice per character — no manual setup. This
sits entirely on the existing Stories pipeline (autoCast → storyToSpans →
/longform/render); the only missing piece was recognizing the `[Name]` tag
format, which parseScript now auto-detects and routes through a new
parseTaggedScript (alongside `NAME:` screenplay + quoted prose).
- parseTaggedScript: `[Name] dialogue`, multi-line blocks join until the next
tag, prose before the first tag → Narrator. Inline synthesis markers
([pause], [pause 500ms], [voice:ID], [fast], [spell]) are NOT treated as
speakers (no colon + reserved-keyword guard), so they stay in the text.
- parseScript auto-routes tagged scripts so the existing Auto-cast button works
unchanged; single-line re-render is already covered by the content-addressed
chapter cache.
- autocastHint advertises all three formats. 16 parseScript tests pass.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
test: backend route-inventory + webUI feature-coverage guards (#609)
* test: backend route-inventory snapshot + webUI feature-coverage guards
A reusable testing system that verifies every feature surface is present:
- tests/test_api_route_inventory.py: boots the app, diffs all 213 routes vs a
committed snapshot (tests/fixtures/api_routes.txt), guards a critical-endpoint
set, and floors the route count — any endpoint drift fails CI.
- scripts/dump_api_routes.py: regenerates the snapshot.
- frontend featureCoverage.test.js: every AppMode has a render branch, every
lazy-imported page file exists, every feature has an i18n namespace.
* docs(changelog): note the feature-coverage test system
* test(api-inventory): isolate via subprocess + exclude env-dependent mounts
CI surfaced two flaws in the first cut:
- the in-process app import + sys.modules purge polluted later DB-touching
tests (a cascade of 404s in test_dub_subtitles_309 etc.);
- the snapshot included StaticFiles mounts (/demo_audio) and a conditional
GET / root that register based on filesystem state, so a macOS-generated
snapshot didn't match a fresh Linux CI runner.
Compute routes in an isolated subprocess (scripts/dump_api_routes.py --print)
and cover only the deterministic router surface (drop Mounts + root). 209
routes; inventory + previously-polluted tests now pass together.
---------
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(design): heal validator-rejecting instruct on design voices (#594/#571/#596) (#600)
* fix(design): heal validator-rejecting instruct on design voices (#594/#571/#596)
A designed voice could persist an `instruct` the engine validator rejects —
either the literal "[object Object]" from a pre-fix build (#550) or freeform
prose typed into the style field — so every Generate/Dub that used the voice
failed with `Unsupported instruct items found in …` (400/500, and "Can't reach
the local backend" when it tore down mid-render). Migration 0006 only *blanked*
"[object Object]", which silently discarded the design — an Indonesian female
voice then rendered male (#594).
Fix the whole class by healing at every seam and rebuilding from the
authoritative source (the design's saved `vd_states` category picks):
- omnivoice/utils/voice_design.py: add sanitize_instruct / instruct_from_vd_states
/ heal_design_instruct — forgiving (never raise), drop poison/prose to valid
tags, and rebuild tags from vd_states when the stored value is unusable.
- profiles.py: sanitize + rebuild at save (POST) and sanitize at edit (PUT), so
no poisoned instruct can ever be persisted again.
- generation.py + dub_generate.py: heal whenever a profile drives synthesis, so
legacy poisoned rows resolve to valid tags instead of 400-ing.
- migration 0007: heal existing profiles in place (recovers gender/age/pitch
from vd_states), self-contained (frozen vocab snapshot) so it never drags
torch into startup; supersedes 0006's blanking. Backward-compatible.
Tests: unit coverage for the healer, a migration test driving 0006->0007 on the
real schema, a parity guard so the frozen snapshot can't drift, and two API
guards. Corrected one existing test that had encoded the #594 behaviour.
Resolves#571, #594, #596; removes a major driver of the "Can't reach backend"
reports.
* test(cjk): allowlist migration 0007's frozen dialect-tag snapshot (#564)
The 0007 instruct-heal migration carries a frozen copy of the design-tag
whitelist (incl. Chinese dialect tags) so it stays self-contained; add it to
the hardcoded-CJK allowlist like omnivoice/utils/voice_design.py.
---------
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(transcribe): surface the real ASR-load failure instead of a generic "stream dropped" (#578) (#608)
When WhisperX (or any ASR backend) failed to load its model, the transcribe
SSE stream dead-ended on a generic "Transcribe stream dropped … Likely ASR
backend failed to load" message with no actionable cause.
Two root causes, both fixed:
1. WhisperX loads lazily inside transcribe(), so a load failure (faster-whisper
weights, CTranslate2/cuDNN mismatch, torch-2.6 weights-only VAD regression)
was buried in per-chunk errors and retried on every chunk. Added
ASRBackend.ensure_loaded() (no-op default; WhisperX triggers its lazy
loader) and call it in the transcribe pre-flight so the genuine cause
surfaces once, up front, as a structured error event.
2. The pre-flight and audio-load error paths closed the SSE stream with a bare
`error` and no terminal `done`, so the browser's native EventSource
connection-drop could race and win against the structured error — discarding
the real cause. Every terminal error now emits `done`, and the frontend
latches the structured cause so a connection drop can't overwrite it with
the generic message.
Adds a fail-before/pass-after regression test driving the stream's async
generator through the ASR-load-failure path; updates the existing #516 fake
backend to the new ensure_loaded() contract; CHANGELOG ### Fixed entry.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(ui): dub play button + designer script resize on Windows (#595) (#607)
Two frontend bugs reported on v0.3.7, both Windows/Chromium-flavoured:
1. The PLAY button on the dubbed-video preview did nothing. WaveSurfer
builds its AudioContext at mount (before any user gesture), so on
Windows WebView2 / Linux FF/Chrome it stays "suspended" and
playPause() resolves with no sound. This is the same autoplay-policy
trap #510 fixed for WaveformPlayer, but the dub timeline player was
missed. togglePlay and the per-segment playRange now await the shared
unlockAudio() on the click before starting playback, and swallowed
play() rejections are logged. A source-contract regression test pins
the invariant (fail-before/pass-after verified).
2. The designer Script text field couldn't be expanded. It was a
`flex: 1` item in a flex column, so flex-grow recomputed its height
each reflow and snapped the resize-drag back — `resize: vertical` is
ignored on a flex-grown item in Chromium/WebView2. The textarea now
owns its height (flex: 0 1 auto + a taller min-height) so the corner
grip grows it reliably on every platform.
Gates: `bun run build` and `bunx vitest run` (563 tests) both pass.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(tts): auto-repair incomplete model cache instead of dead-ending (#581) (#606)
An interrupted first download leaves the HF cache with config/tokenizer
files but no weight shard. transformers then raises an OSError ("does not
appear to have a file named pytorch_model.bin or model.safetensors") on
load, which model_manager translated into a 500 with a manual "delete the
model and install it again" instruction — a dead-end for the user.
Make the load path self-repair: on the truncated-cache OSError, re-fetch
just the missing files via snapshot_download (already-present blobs are
skipped, so a near-complete cache repairs fast and a healthy cache never
reaches this branch), then retry the load once. HF offline mode is
respected, and the actionable delete-and-reinstall message is preserved
as the fallback when repair can't fix it.
Adds tests/test_model_cache_repair.py covering completeness detection,
the fast path (no repair on healthy cache), self-repair + retry, the
offline guard, and the repair-failure fallback.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(dub): retry transient broken-pipe on URL download (#579, #598) (#605)
Pasting a video URL into the dubber could fail outright with
`download: Unable to download video: [Errno 32] Broken pipe`. A broken
pipe raised while the write side of a pipe closes mid-stream (a killed
ffmpeg merge child, a CDN reset during muxing) aborts the whole
`extract_info` call and is NOT covered by yt-dlp's own per-fragment
retries, so a single transient blip killed the entire ingest.
Root cause: no download-level retry around `yt_download_sync`'s
`extract_info`. The failure was already classified as
`VIDEO_DOWNLOAD_NETWORK` (#554/#536) and carried a "just retry" hint, but
nothing actually retried.
Fix: wrap the download in a bounded retry (1 + 2 attempts) that retries
only on transient/broken-pipe-class failures, reusing the single
`failure.classify() == VIDEO_DOWNLOAD_NETWORK` taxonomy (plus the
BrokenPipeError/ConnectionError classes) rather than a parallel keyword
list. Partial `original.*` files are wiped between attempts so a
half-written download can't poison the next try. Unsupported links still
fail fast with their own hint (no wasted retries); after retries are
exhausted the existing actionable network hint is surfaced.
Adds tests/test_dub_download_retry.py: retryability classification +
retry-then-recover, bounded give-up, and no-retry-on-unsupported-URL.
Fails before (no retry loop / helper), passes after.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat(support): Contact page, Ko-fi/PayPal donate, simpler license (#604)
GitHub Sponsors isn't available for this account, so route donations to Ko-fi /
PayPal instead, add a standalone Contact page, and trim the commercial-license
page to the essentials.
- Donate: drop GitHub Sponsors. Pick an amount ($10 / $20 / $50) then choose
Ko-fi or PayPal; PayPal.me carries the amount into checkout. Updated
.github/FUNDING.yml (ko_fi + custom PayPal) and the README badges to match.
- Contact page (new `mode: 'contact'`, ContactPage.jsx): Discord, email, GitHub
issues, and website (palash.dev) as clean one-tap rows; reachable from a new
footer button. Routed in App.jsx, sidebar hidden like the other full pages.
- Commercial License: cut the 6-tile benefit grid + 3-item FAQ down to the
three deciding factors (IP ownership, no per-minute cost, direct support) and
one clear "request a quote" email CTA.
- All new copy goes through i18n (en.json: donate.choose_method*,
enterprise.hero_simple/contact_lead, contact.*, logs.contact*).
Build (vite) + vitest (561 passed) green; en.json validated.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(bootstrap): gate venv on omnivoice import + source fallback (#564) (#603)
* fix(bootstrap): gate venv on omnivoice import + source fallback (#564)
`No module named 'omnivoice'` is a venv that starts uvicorn but can't import the
project's OWN package: an interrupted/offline `uv sync` installed deps yet never
laid the editable record (`_editable_impl_omnivoice.pth`), or antivirus removed
it. The bootstrap health gate only checked `import uvicorn` + `import
pkg_resources`, so it handed back the broken venv and the app failed only at the
first model call (the dub/generate SSE error in #564). #573's source fallback in
main.py wasn't enough on its own because the editable record, not the source
tree, was the missing piece.
Fix the root cause at the gate and harden the runtime:
- bootstrap.rs: add an `omnivoice` import check beside the uvicorn/pkg_resources
gates, using `importlib.util.find_spec` (resolves without importing, so no
torch load). When it fails, fall through to the repair `uv sync`, which
re-lays the editable install. Mirrors the #248 pkg_resources pattern exactly.
- core/omnivoice_path.py (new): `ensure_omnivoice_importable()` — a tested
helper that no-ops when the install resolves and otherwise appends the sibling
source root to sys.path, with a precise diagnostic when neither is found.
- main.py: replace the inline #573 block with the helper.
- model_manager._lazy_omnivoice: self-heal on ModuleNotFoundError at the actual
import site so the model-load path recovers and logs the searched roots.
Regression tests cover the path-resolution logic (env override, append-not-
insert precedence, no-source-found). cargo check passes for the Rust change.
* test(omnivoice-path): patch via live module object to survive core reloads (#564)
The #603 CI flake: other suites importlib.reload(core.*), leaving the
top-level-imported ensure_omnivoice_importable closed over a stale module whose
_already_importable a string-form monkeypatch didn't touch, so it returned None.
Resolve the function + the patch target from sys.modules together.
---------
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(backend): self-healing GPU pool so a reset can't strand requests (#589/#599) (#601)
`_reset_gpu_pool()` fires on a model-load timeout to recover a wedged worker —
it shut the ThreadPoolExecutor down and rebuilt a fresh one on next access. But
several request handlers (generation, dub_generate, dub_core, dub_translate,
openai_compat) did a *module-level* `from services.model_manager import
_gpu_pool`, capturing the executor object at import time. After a reset those
references pointed at the dead pool, so the next generate/dub/transcribe/
translate raised `RuntimeError: cannot schedule new futures after shutdown` —
surfacing as a 500 or "Can't reach the local backend" (#589#599).
Make `_gpu_pool` a single long-lived `_ResilientGpuPool` wrapper (a
concurrent.futures.Executor) whose *inner* ThreadPoolExecutor is swapped:
- every submit() resolves the live pool, and a submit that races a shutdown
rebuilds once and retries, so a stale captured reference self-heals;
- `_reset_gpu_pool()` now drops only the inner pool (fresh worker on retry)
while preserving the wrapper identity every importer holds;
- pool sizing stays lazy, so we still probe the device after torch's lazy
import (the reason for the original __getattr__ indirection).
Fixes the whole class — all importers share one wrapper, module-level or
function-level. Regression tests cover stale-ref-survives-reset, identity
stability, submit-after-inner-shutdown self-heal, and asyncio.run_in_executor
compatibility; updated the load-timeout test to the new reset semantics.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(tts): NFC-normalize text + dense-script-aware chunking for long-form quality (#502/#505) (#587)
Two defensive fixes for non-Latin / long-form synthesis quality:
#502 (Vietnamese clone distorted/unintelligible): the /generate text path never
NFC-normalized its input, so pasted decomposed (NFD) Vietnamese — base letter +
combining diacritic instead of the single composed codepoint — reached the
tokenizer/model as two characters and rendered as garbled speech. Normalize the
input text to NFC at the endpoint (no-op for already-composed text), mirroring
what the duration estimator already does so the estimate and synthesis agree.
#505 (long-form 5+ min degrades — repeated/skipped/mispronounced): the chunker
split purely by character count (800), but CJK/kana/Hangul pack ~1 char =
1 syllable, so an 800-char chunk is ~4-5 minutes of audio in a single shot —
past the model's reliable range, where it starts repeating/skipping. When a
chunk is predominantly dense-script, cap it to max_chars/2.5 so each chunk's
spoken length stays bounded; Latin/spaced text is unchanged. Dense-script
detection is by code point (no literal CJK in source — no-literal-CJK gate stays
clean).
Tests: _dense_char_count, _effective_max_chars (shrink-when-dense, unchanged-for-
Latin, disabled-passthrough, floor), and that a 400-CJK-char string now splits
(was one chunk) while a Latin paragraph still doesn't.
Note: #502's exact distortion still wants a user sample to fully confirm; this is
the defensive NFC fix that's correct regardless.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(gpu): prevent the 8 GB-card OOM crash behind the "backend unreachable" wave (#567/#570/#571/#580+) (#586)
The wave of "Can't reach the local backend" reports — all on ~8 GB NVIDIA cards,
all during generate bursts — is the backend *process* dying, not a transport
blip. Root cause: the GPU pool was sized at 2.5 GB/job, so an 8 GB card (~7 GB
free) got 2 workers. The interactive clone path co-loads WhisperX large-v3 ASR
(~3 GB) alongside TTS (~1.6 GB), so two concurrent clone jobs is ~10 GB on an
8 GB card → a sticky CUDA "illegal memory access" that aborts the whole
interpreter (uncatchable by the per-request OOM guard, which only re-raises a
clean torch.cuda.OutOfMemoryError as HTTP 500).
Budget 5 GB/job (the real TTS+ASR concurrent footprint) instead of 2.5 GB:
≤10 GB cards now serialize to a single GPU worker — no concurrent-kernel
contention, so the crash can't happen — while 16/24 GB cards still parallelize.
Overridable via OMNIVOICE_GPU_WORKERS. This *prevents* the crash; the
auto-restart supervisor (#572) *recovers* from any other cause — defense in
depth.
Extracted `_workers_for_free_vram()` (pure) with tests pinning 8 GB → 1 worker,
the larger-card ladder, the floor/cap, and a guard on the budget constant so a
regression toward 2.5 GB can't silently re-enable the crash.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat(design): seed pin / re-roll for designed voices (#526) (#577)
Voice design rolled a brand-new random seed on every synth, so tweaking an
attribute also re-rolled the whole base timbre — you could never iterate on the
"same voice, slightly different". #526 asks for the seed to be shown with a
"keep this seed" control.
- Backend: `/generate` already accepted `seed` and echoed `X-Seed`, but left
`used_seed=None` when nothing supplied one (non-deterministic, unreproducible,
empty X-Seed). Now it materializes a concrete random seed when none resolves,
so every take is reproducible and the real seed is always returned and stored
— this also helps the clone/profile paths, not just design.
- Frontend: new store slice (`designSeed`, `keepSeed`); the design synth reuses
the pinned seed when "keep this seed" is on (via `pickDesignSeed`) and reads
the authoritative seed back from `X-Seed`. Design tab gains a Seed field +
"keep this seed" checkbox + "New seed" (re-roll) button.
Test: `pickDesignSeed` (pin when kept+valid, re-roll otherwise, range guard).
i18n keys added to en.json (other locales fall back; parity probe is advisory).
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(dub): auto-assign per-speaker cloned voices to segments (#486) (#576)
Multi-speaker dubbing diarizes the speakers and clones each one from the video
(the Voice dropdown shows "From Video → Speaker 1 / Speaker 2"), but every
segment was left on "Default" — the user had to set the voice on each row by
hand. The clone→segment binding simply never happened: the transcribe `final`
handler stored `speaker_clones` but set the segments without filling their
`profile_id`.
Bind them up front: new `applySpeakerCloneDefaults(segments, speakerClones)`
sets each segment's `profile_id` to its speaker's `auto:<safe>` clone id when a
clone exists and the user hasn't already chosen a voice. The id is computed by
`autoProfileId()`, which mirrors the backend clone-resolution key
(`speaker_id.lower().replace(" ","_")`) and the DubTab dropdown option value, so
all three agree. Only an *empty* profile_id is filled — an explicit per-speaker
or per-segment choice is never clobbered.
Pure helper + unit test (assign-when-cloned, never-clobber, no-clone-stays-
Default, no-op-without-clones).
Note: the issue's second symptom — different speakers' turns merged onto one
line — is a separate diarization/segment-grouping concern (speaker-turn
re-split) tracked as a follow-up; this fixes the per-speaker voice assignment.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(install): actionable torch-wheel-download failure + local-wheel recovery (#569) (#574)
#569: on a restricted network the first-run install fails downloading the
~2.5 GB cu128 PyTorch wheel from download.pytorch.org, and the app won't launch.
Two problems: the error told users to "set UV_DEFAULT_INDEX to a mirror" — which
CANNOT redirect torch, because it comes from a *named, explicit* uv index
(uv 0.11 rejects index-name override values and `--frozen` pins the exact wheel
URLs); and there was no way to supply a manually-downloaded wheel.
- Detect a torch/pytorch-host `uv sync` failure and emit torch-specific guidance
(Clean & Retry → VPN → drop the wheel locally) instead of the wrong mirror
advice.
- Add a local wheel-drop dir `<env_root>/wheels` (survives Clean & Retry) wired
via `UV_FIND_LINKS`. On a frozen-sync torch-download failure WITH wheels
present, retry NON-frozen with find-links so uv re-resolves from the local
wheels. Verified empirically: a non-frozen find-links sync installs from a
local wheel fully offline, while a `--frozen` sync ignores find-links — so the
retry is the only mechanism that can consume a dropped wheel. Best-effort: if
it can't satisfy, it fails identically to before and the actionable error
still fires.
- docs/install/troubleshooting.md: new "#12 CUDA PyTorch wheel download fails"
entry (docs-sync) — the offline wheel-drop path + why a PyPI mirror can't fix
this index.
Note: an automatic mirror redirect for the cu128 index is intentionally NOT
shipped — uv provides no working override for a named explicit index, so it
couldn't be verified; the offline wheel path is the reliable escape hatch.
Test: sync_failure_is_torch_download host/keyword detection + negative guard.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(backend): import omnivoice from source when the editable install is missing (#564) (#573)
#564 ("No module named 'omnivoice'") is the backend failing to import its OWN
package at the first model call (the dub SSE error on dub:upload). `omnivoice`
is an editable install, so an interrupted/offline `uv sync` that installed deps
but never laid the editable record, an antivirus-quarantined
`_editable_impl_omnivoice.pth`, or an upgrade where only the lock-gated drift
sync ran leaves the venv able to start uvicorn yet unable to import omnivoice —
it boots fine and only fails at runtime, so the bootstrap health gate and the
exit-based broken-venv self-heal (which only see a process that won't start)
never catch it.
Fix the whole class at the import layer: main.py now also appends the project
root (the parent of backend/, where the desktop layout always copies
omnivoice/) to sys.path, guarded on omnivoice/__init__.py existing. The backend
then resolves omnivoice from source regardless of the editable-install state —
covering every variant above. Appended (not inserted) so a real
site-packages/editable install keeps precedence and it can't shadow a different
omnivoice; a no-op in Docker (no sibling omnivoice/) and a harmless duplicate
in a dev checkout.
Also routes "No module named 'omnivoice'" through failure.classify() →
BROKEN_VENV so, if it ever still surfaces, the toast points at Clean & Retry
instead of a bare import error. Regression test covers the classify mapping and
its negative guard (a legitimately-named omnivoice_* helper must not match).
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(backend): auto-restart supervisor + client transport-retry (#567/#570/#571) (#572)
The "Can't reach the local OmniVoice backend" cluster was a long-standing
supervision gap (dates to v0.3.0/#38), not a v0.3.7 regression: the backend
was spawned once and never watched again — `spawn_backend_and_wait` returned
the instant it was healthy. When the uvicorn process then died mid-session (a
CUDA OOM/context fault under a burst of generations — #571's log shows the
startup banner replaying 6× during a 20-generate burst — an antivirus kill, any
crash), nothing restarted it, so every later request threw connection-refused
and the user was stuck on the toast until a full app restart.
Two layers, both default-mode and platform-neutral:
1. Backend auto-restart supervisor (bootstrap.rs). After Ready, the bootstrap
thread (which used to just return) keeps watching the child and respawns it
on a *confirmed process exit* (try_wait — never a slow health probe, so a
busy-but-alive backend is never killed). Bounded to 5 restarts/60s (then
Failed) so a deterministic startup crash can't fork-bomb; the #314
broken-venv self-heal stays the venv-failure path. Strictly gated on
AppFlags.quitting so it never resurrects the backend during shutdown. A
single-supervisor guard (compare_exchange) prevents duplicate loops when
Retry re-enters concurrently. Emits backend-restarting/backend-restored
events (the splash poll stops post-Ready, so the stage alone can't show it).
2. Client transport-retry (client.ts). A *thrown* fetch (the backend briefly
down while it respawns) is retried a bounded few times with backoff
(~2.9s total) before surfacing the actionable ApiError, making the restart
window invisible. HTTP errors and deliberate aborts are never retried.
Resolves the whole cluster regardless of the crash trigger. Tests: Rust
backoff-policy unit test (cap + window-pruning); 4 client-retry vitest cases
(retry-then-succeed, no-retry-on-HTTP-error, no-retry-on-abort, bounded
give-up). Also corrects a stale Cargo.lock omnivoice-studio version (0.3.6→0.3.8).
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
docs(changelog): complete the v0.3.7 section (items that landed but were under-listed) (#568)
The v0.3.7 notes were missing several user-facing changes that shipped between
v0.3.6 and the tag: Stories global reading-speed (#508), the Settings sparse-tab
fill + Appearance i18n (#507), the donate progress correction (#513), and a
### Changed (version single-source #503, preview-nightly #500) + ### Internal
(frozen-backend version #501) section. Restructured to the 0.3.6 house style.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
chore(version): main -> 0.3.8 after v0.3.7 release
docs(changelog): add the non-English language fixes to v0.3.7 (#533/#505/#502) (#566)
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(lang): propagate profile/request language into generation + longform (#533/#505/#502) (#565)
Non-English voices drifted to English/wrong-language because the request's or
profile's language wasn't reaching the model:
- #533: generate_speech() read instruct/ref_text/seed from a resolved profile row
but never row['language'] (and collapsed Auto→None), so a German archetype
previewed in German yet generated in English on the user's own call (and via
Docker/API). Fall back to the profile's stored language when the request didn't
pin one; an explicit non-Auto request language still wins. (Frontend already
sets the dropdown on profile-select; this is the authoritative backend fix.)
- #505 (B2): the audiobook/longform synth hardcoded language=None, so the engine
re-autodetected per chunk and a non-English clone flipped language mid-render.
Add _resolve_default_language (request → profile → autodetect) and thread the
resolved language through _build_synth/_prepare_synth/_render_longform_sse, the
three longform request models, the preview path, and the resume manifest.
Genuine Auto/unset behavior is unchanged.
- #502 (partial): the duration estimator weights combining marks (U+0300–036F)
at 0.0, so NFD/decomposed text under-allocated frames → rushed audio. NFC-
normalize text at the estimator entry — fixes the whole diacritic-script class
(no-op for precomposed text). (The residual "distorted" core still needs the
reporter's sample; tracked separately.)
Tests (fail-before/pass-after): profile language reaches the engine (German→de;
explicit/Auto override semantics); longform synth gets the resolved language
(→ja), not None; NFD vs NFC duration parity (Korean Hangul diverges ~3x pre-fix).
Full suite: tests/ 1740 passed, backend/tests/ 114 passed.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
docs(changelog): v0.3.7 release notes (#563)
The stabilization release: tags the startup-crash fixes already on main + the
wave of 0.3.6-line fixes (voice design [object Object], consent_audio_path schema
self-heal, Linux WebKitGTK buttons, pip install, ASR float16 fallback, audiobook
import, Windows auto-play, download errors, relocated-venv self-heal, About
version), and folds in the MOSS-TTS/dots.tts engines (#498/#531) + the
Linux/Android audio-playback fix (#510) that landed on main.
release.yml extracts this section verbatim as the v0.3.7 GitHub release body.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix: actionable download errors + self-heal a relocated venv (#554/#536/encodings) (#562)
Two robustness fixes that share the failure taxonomy:
- Video downloads (#554 douyin "Unsupported URL", #536 "Broken pipe"): yt-dlp's
raw error surfaced with no next step. classify() now names UNSUPPORTED_VIDEO_URL
(non-downloadable link shape — paste a direct video page or drop a file) and
VIDEO_DOWNLOAD_NETWORK (transient CDN/network drop — just retry; the partial
download is already cleaned up), each with an actionable hint. yt-dlp is on a
current pin, so this is graceful classification, not a dependency bump.
- "No module named 'encodings'" (relocated/copied/restored venv whose interpreter
can't bootstrap its stdlib — exit 1, not 106): slipped BOTH #314 self-heal
matchers, so the user saw the error forever. Widen
backend_exit_indicates_broken_venv to also match the full quoted phrase, routing
it into the existing rebuild-once self-heal. Kept narrow so an app-level import
of an 'encodings'-prefixed package can't trigger a rebuild. Plus a BROKEN_VENV
hint for the case where the rebuild itself can't run.
Tests: classify() maps the 3 new classes with hints (a generic reason still ""),
and the 'encodings'-prefixed-package negative guard holds; the Rust matcher test
gains the encodings positive + negative cases. 5 pytest passed; the matcher is
compiled by CI's Tauri shell check.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(asr): float16-unsupported GPUs fall back to int8 instead of "no segments" (#561)
#551: both CTranslate2 ASR backends request compute_type="float16" on CUDA with
NO fallback. On GPUs without efficient fp16 (older Maxwell/Pascal, GTX 16xx) or a
CTranslate2/cuDNN binary mismatch, WhisperModel/whisperx.load_model raise a
ValueError at construction — which escaped the existing OOM-only `except
RuntimeError`, so every chunk failed and the user got "Transcription produced no
segments". Add a per-device compute_type fallback chain (cuda: float16 →
int8_float16 → int8; cpu: int8 → float32) to both backends + the ASR sidecar,
alongside (not replacing) the existing OOM→CPU path, with an ASR_COMPUTE_TYPE
override for exotic hardware (documented in README).
Also in the same ASR-robustness pass:
- #549: PyTorchWhisperBackend._ensure_pipe wraps the transformers pipeline load
and re-raises an actionable error (reinstall transformers / use faster-whisper)
instead of a bare "Could not import module 'AutoFeatureExtractor'".
- #516: the /dub/transcribe SSE generator is wrapped so it can NEVER close
without a terminal event — any unanticipated exception now yields a structured
`error` (with build_failure's hint) + `done`, turning "stream dropped, likely
ASR failed" into the real cause + Retry.
- failure.py: COMPUTE_TYPE_UNSUPPORTED + TRANSFORMERS_IMPORT classes so the
no-segments toast is actionable.
Tests (fail-before/pass-after): float16-unsupported → int8 for both WhisperX +
FasterWhisper; a generic non-OOM RuntimeError still raises; classify() maps the
two new classes; the SSE stream always terminates with error→done. 7 + 1 passed,
17 in the failure suite (no regression).
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(projects): preview finished renders in-app, not via window.open (#532) (#538)
The audiobook/story library card called window.open(audioUrl, '_blank').
Under Tauri's WebView2 on Windows that handed the file to a new webview/OS
media surface, spawning a separate black playback window with centered
controls that couldn't be closed without force-quitting the whole app.
Route the render through the shared single-playback manager (playBlobAudio)
so it previews inside the app — identical behavior on macOS/Windows/Linux,
and starting another preview stops this one. This is the only raw
window.open on a media URL in the frontend, so it fixes the whole class.
Adds a regression test: the card plays in-app and never calls window.open.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
fix(audiobook): add AudiobookPlan.chapter_count so import doesn't 500 (#544)
POST /audiobook/import ended with `plan.chapter_count`, but AudiobookPlan
only exposed `char_count` (a property) and emitted `chapter_count` from
`to_dict()` — so the attribute access raised AttributeError, surfacing as
"500 Internal Server Error: 'AudiobookPlan' object has no attribute
'chapter_count'". The parse itself succeeded, so this hit every import
format (.txt/.md/.epub/.pdf), not just PDF.
Add a `chapter_count` property mirroring `char_count`, and have `to_dict()`
derive its key from it so the attribute and serialized key can't drift.
No API/schema/data change.
Tests: unit property test + a direct-handler /audiobook/import regression
(pdf/md/txt) that fails-before with the AttributeError.
Fixes#543
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
fix(settings): About → Version never blank (web/Pinokio build) (#560)
In the web/Pinokio (non-Tauri) build with the backend idle, Settings → About
rendered an EMPTY Version cell. Both sources were unavailable: appVersion is set
only inside the isTauri()-gated effect, and info.app_version comes from
/system/info which never resolves while the backend is IDLE (retry: Infinity).
So `appVersion || info?.app_version || '—'` produced nothing.
Add resolveAboutVersion() in a small util that falls back to the build-time
__APP_VERSION__ (Vite-injected from package.json — always present regardless of
Tauri/backend), and use it at BOTH the About → Version row AND the diagnostics-
copy block (which had no fallback at all — the whole-class fix). Tauri/live-
backend sources still take precedence, so the packaged build is unchanged.
Test: resolveAboutVersion prefers Tauri → backend → build constant, and is never
blank/dash. vitest 2 passed; typecheck clean.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(engines): pin `uv pip install` to the running interpreter (#529/#527) (#559)
The engine Install chip (deep_translator / openai / argostranslate) 500'd with
"No virtual environment found". run_pip shells out to bare `uv pip install`,
which discovers its target venv from VIRTUAL_ENV / a .venv in CWD — NOT from the
running interpreter. The desktop spawns `<venv>/bin/python -m uvicorn` without
exporting VIRTUAL_ENV and CWDs outside the venv, so uv finds nothing. The
existing `--system` fallback never fires because it's gated on _in_virtualenv()
being False, but the running interpreter genuinely IS in a venv (it just can't be
auto-discovered) — the heuristic answers the wrong question.
Pass `--python sys.executable` for uv install/uninstall: targets the same
interpreter _probe()/is_installed() import from, fixing the whole class
(spawned-venv, system, conda). It takes precedence when both flags are present,
so the Docker `--system` path is untouched. (#527's openai is already bundled by
#484; this hardens the chip for the remaining runtime-installed engines.)
Test: run_pip's spawned argv contains `--python <sys.executable>` after
install/uninstall. 2 passed.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(ui): fill the shell on WebKitGTK so Generate/Settings buttons aren't clipped (#558)
#523 "voice synthesis not pressable" / #524 "Settings button not visible" — and
the Discord "where is the Generate button / the clone button disappeared" reports
on 0.3.6/0.3.7 with the backend UP. The #504 zoom fix only half-worked: it sizes
.app-container as calc(100vw/scale) magnified back by `zoom`, which round-trips to
the viewport on Chromium — but older WebKitGTK (Linux AppImage/deb) treats `zoom`
as a LAYOUT NO-OP, so the box stays shrunk to 77vw, leaving a ~23% black band and
pushing the bottom-pinned Synthesize/Generate CTA + the NavRail Settings footer
off-screen. The old comment's "FILLS the window (no black bands)" claim was false.
No single static rule satisfies both engines (they disagree on whether `zoom`
lays out), so detect it at runtime:
- App.jsx: a one-shot probe measures a real `zoom:2` element; if its rect isn't
magnified, the engine treats zoom as a no-op → set html[data-zoom-layout=off].
Robust where @supports(zoom)/UA-sniffing aren't (both lie on WebKitGTK), and
future-proof (flips back to the zoom path on engines that start honoring it).
- index.css: html[data-zoom-layout=off] .app-container renders at 1.0 filling
100vw/100vh — no band, no clipped CTAs. Chromium keeps the scaled zoom path.
- appShellScale.test.js: guard BOTH branches (the existing calc+zoom path AND
the 100vw/100vh fallback) so a future "simplification" can't re-break one engine.
vitest 4 passed; typecheck clean. NOTE: CI e2e is chromium-only, so the WebKitGTK
fallback path must be eyeballed on a real Linux build before the v0.3.7 tag.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(db): self-heal additive schema columns so consent_audio_path 500s stop (#557)
A profile/persona/consent endpoint 500'd with "no such column:
consent_audio_path" (#552/#547) — and the same class for kind/vd_states/is_demo.
The 0003/0005 migrations exist and are wired, but init_db's
CREATE TABLE IF NOT EXISTS never adds columns to a pre-existing table, the legacy
_migrate only knows pre-0.3 columns, and _run_alembic_upgrade swallows every
failure. So on a DB whose alembic_version is stamped at a revision no longer in
versions/ (common after running a preview build) or where alembic isn't
importable, the alembic-era columns silently never land.
Fix the whole class: add _reconcile_additive_columns(conn), which builds the
canonical schema from _BASE_SCHEMA in-memory and ALTER TABLE ADD COLUMN any
column an existing table is missing (additive only — never drops/retypes;
names/types from _BASE_SCHEMA so injection-safe). Call it in init_db (so the
schema converges regardless of alembic) and again in the alembic-failure branch.
Also correct the false "_BASE_SCHEMA guarantees the schema regardless" comment.
Test (fail-before/pass-after): init_db on a legacy voice_profiles whose
alembic_version is a removed revision now lands consent_audio_path/kind/etc.
without raising; reconcile converges to the canonical column set; idempotent +
additive-only. 21 passed (incl. existing 0003/0005 migration tests).
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(voice): stop poisoning design profiles with "[object Object]" instruct (#556)
Voice Studio "Save design as profile" passed buildDesignInstruct()'s RETURN
OBJECT ({instruct, unsupported, duplicates}) straight to FormData.append, which
string-coerced it to the literal "[object Object]". That got persisted into
voice_profiles.instruct and 400'd on first preview/use with "Unsupported instruct
items found in [object Object]" (#550#545#542#537#530#525).
Fix the whole class + heal already-poisoned data (backward-compatible-data rule):
- CloneDesignTab.jsx:609 — pass `.instruct` (the string), not the builder object.
- useProfiles.js — append via new instructToFormValue() helper, which extracts
`.instruct` if an object ever slips through again (defense-in-depth).
- omnivoice/_resolve_instruct — drop the "[object Object]" sentinel instead of
raising, so any value that slips through (e.g. generation_history) degrades to
neutral conditioning rather than a hard 400. A genuine unsupported token still
raises (keeps the #114/#115 user feedback).
- migration 0006 — UPDATE voice_profiles SET instruct='' WHERE it's the sentinel,
idempotent + table-guarded, to heal profiles saved on the buggy build.
Tests (fail-before/pass-after): frontend voiceInstruct (instructToFormValue never
yields "[object Object]"); backend _resolve_instruct tolerates the sentinel but
still rejects a real bad token; alembic 0006 heals a poisoned row, leaves a
healthy one untouched. Frontend 9 passed + typecheck clean; backend 5 passed.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Merge pull request #531 from debpalash/debpalash/feature-adding-moss-tts-v1.5-8b-model-as-an-alte
feat(tts): add MOSS-TTS-v1.5 (8B) and dots.tts (2B) as opt-in engines (#498)
feat(tts): add MOSS-TTS-v1.5 (8B) and dots.tts (2B) as opt-in engines (#498)
Adds two zero-shot voice-cloning TTS engines requested in #498, both
opt-in and subprocess-isolated with their own dedicated venv — the same
pattern as IndexTTS-2. The dedicated venv is forced, not just chosen:
each upstream pins a transformers version that conflicts with the
parent's >=5.3 (MOSS-TTS-v1.5 ==5.0.0, dots.tts ==4.57.0), so they cannot
share the parent interpreter.
Because they use the clone+venv bootstrap (env var -> clone -> uv venv),
this touches no pyproject.toml / uv.lock / bun.lock — `uv sync
--all-extras` and Docker's `bun install --frozen-lockfile` are unchanged,
so main's CI/Docker matrix stays green.
Engines:
- moss-tts-v15: 8B, 31 langs, ~16 GB weights, 24 kHz. AutoModel/
AutoProcessor via trust_remote_code. gpu_compat=(cuda,cpu) — MPS is
undocumented/untested upstream so it is never claimed; on a Mac it runs
on CPU. Apache-2.0, no license gate.
- dots-tts: 2B, 24 langs, ~9 GB weights, 48 kHz. DotsTtsRuntime;
continuation cloning (prompt_audio_path+prompt_text). Upstream is
Linux/macOS-only, so is_available() gates it off cleanly on Windows
(cross-platform parity rule — it is opt-in, never a broken default).
Wiring: registered in _LAZY_REGISTRY + _INSTALL_HINTS. list_backends()
surfaces both as subprocess/[cuda,cpu]/available-until-installed; the
data-driven Settings engine picker needs no frontend change.
Tests (19, fail-before/pass-after): registry resolution, subprocess
marker, no-MPS gpu_compat, the Windows gate, not-installed honesty, and
the parent-side generate() kwarg arbitration. Existing engine suite still
55 passed / 5 skipped. Sidecar inference follows the upstream-documented
APIs but, like IndexTTS/Supertonic, can't be executed in CI without the
multi-GB model clones.
Docs (same-PR per docs-sync rule): README + README_CN engine tables, new
docs/engines/moss-tts-v15.md + dots-tts.md, disk-usage.md (torch-dedup
note), CHANGELOG.
Merge pull request #519 from debpalash/fix/ui-scale-clipping-504
fix(ui): shrink layout box by uiScale so zoomed shell fits viewport (#504)
fix(ui): shrink layout box by uiScale so zoomed shell fits viewport (#504)
CSS `zoom` magnifies visually but does NOT enlarge the layout box. At the
default `uiScale=1.3` the shell was drawn at 130vw x 130vh while the window
stayed 100vw x 100vh, so the bottom/right edges were clipped by `overflow:
hidden` on `#root` and `.app-container`. This hid Settings save, Dub transcribe,
and Clone buttons (only reachable via Tab).
Fix: shrink the layout box with `calc(100vw / var(--ui-scale))` /
`calc(100vh / var(--ui-scale))`, then let `zoom` magnify it back to exactly the
viewport on Chromium. On WebKitGTK `zoom` is a no-op, so the UI renders smaller
but still fills the window - no black-band regression.
Updates the appShellScale regression test to match the new intentional
`calc(... / --ui-scale)` rule and keeps the `transform: scale()` blacklist.
Merge pull request #518 from debpalash/docs/readme-reorder-header
docs(readme): nav links above badges, download badges to Quickstart
docs(readme): move nav links above badges, download badges to Quickstart
- Nav links (Quickstart, Features, Why OVS, etc.) now sit above the
badge row in the header for better scannability
- Download badges (macOS/Windows/Linux/Debian) moved from the header
to the Quickstart section where the user actually installs
Merge pull request #509 from debpalash/discord/fix-508
fix(stories): apply global reading speed to preview + stem export
Merge pull request #517 from debpalash/docs/readme-shorten-why-heading
docs(readme): shorten 'Why OmniVoice Studio?' to 'Why OVS?'
docs(readme): shorten 'Why OmniVoice Studio?' to 'Why OVS?'
Merge pull request #513 from debpalash/fix/donation-progress-10-of-200
fix(donate): correct progress from $137.50 to actual $10/$200 raised
fix(donate): correct progress from $137.50 to actual $10/$200 raised
The in-app goal bar and its bundled snapshot showed $137.50 / $200 (23
sponsors) — fabricated numbers. Updated both the runtime JSON and the
TypeScript fallback to reflect the real amount: $10 / $200, 1 sponsor.
Also updates the README badge color from red to yellow (in-progress).
Merge pull request #511 from paoloantinori/fix/audio-mime-and-audiocontext-unlock
fix(web): audio playback on Linux/Android (MIME + AudioContext unlock)
Merge pull request #512 from debpalash/docs/readme-update-donate-cta-and-features
docs(readme): add donate CTA, update features/engines/roadmap for v0.3.6
docs(readme): add donate CTA, update features/engines/roadmap for v0.3.6
- Add Sponsor/Donate section with Ko-fi, PayPal, GitHub Sponsors
links and progress bar ($10/$200 agent bill fund)
- Add Ko-fi + GitHub Sponsors badges to header
- Expand features table: Audiobook, Stories, Diagnostics,
Engine Routing, Portable Personas, Unlimited TTS,
Remote Backend, Dictation+LLM (3x4 → 5x4)
- Update TTS engines: 6 → 11 (add GPT-SoVITS, Sherpa-ONNX,
IndexTTS 2, OmniVoice GGUF, Supertonic 3)
- Update ASR engines: 7 → 8 (add isolated Faster-Whisper)
- Expand roadmap Shipped with v0.3.6 additions (Longform,
engine routing, diagnostics, MCP server, remote backend,
reliability, etc.)
- Update architecture diagram (100+ endpoints, engine routing)
- Add comparison table rows: Audiobook/Stories, TTS count,
ASR count, MCP Server, Self-check
- Update FAQ engine count to 11
- Fix CTranslate2 link typo
- Remove already-shipped Audiobook Editor from Up Next
docs(changelog): note MIME + AudioContext fixes for Linux/Android playback
test(audioUnlock): unit-test the gesture-driven AudioContext resume
Locks down the resume path from the parent commit:
- Every AudioContext is tracked at construction (the wrap is active)
- unlockAudio() resumes all suspended tracked contexts in parallel
- Idempotent: repeated calls do not re-resume
- Contexts created after unlock are not re-resumed by a second call
- resume() rejections are swallowed — one bad context doesn't block others
- installAudioUnlock() is idempotent (the _installed gate works)
The unlock path was the fix for the "click does nothing" bug on Linux
Firefox/Chrome and Android Chrome, where AudioContexts created before a
user gesture stay suspended — decodeAudioData hangs → WaveSurfer's ready
never fires → the play button never enables. Without this test, breaking
the gesture wiring silently regresses every non-macOS browser.
Adds the __resetForTesting() export so the unlock can be exercised
repeatedly against the same module instance (the unlock is meant to be
a one-shot per page load).
fix(web): resume AudioContext on first gesture so play button works on Linux/Android (#fix)
Browser autoplay policy (Linux Firefox/Chrome, Android Chrome, mobile
Safari): AudioContexts created before a user gesture start in "suspended"
state — decodeAudioData hangs and WaveSurfer's `ready` event never fires.
The play button gated on `ready` stays disabled forever, so the click
silently does nothing and no /audio/ request ever fires.
macOS Safari/Chrome are more lenient (typically auto-resume on first
interaction) which masked the bug cross-platform.
Fix has three parts:
1. `frontend/src/utils/audioUnlock.js` (new) — monkey-patches
`window.AudioContext` (and `webkitAudioContext`) to track every
instance ever created. Exports `installAudioUnlock()` which wires a
one-time pointerdown/keydown/touchstart listener that resumes all
suspended contexts on the first user gesture. The patch MUST install
before any module constructs an AudioContext, so this file is imported
first in main.jsx before the dynamic import of main-app.jsx.
2. `frontend/src/main.jsx` — imports and installs the unlock before any
other module loads.
3. `frontend/src/components/WaveformPlayer.jsx` — three changes:
- Remove the `Loader` spinner that gated on `ready`. The spinner
itself was a visual signal that the user was waiting on a state
the browser refuses to produce without user interaction.
- Button is now `disabled={!resolvedUrl}` — clickable as soon as the
audio URL exists, so the user's click IS the gesture that unlocks
the AudioContext.
- `togglePlay()` explicitly awaits `unlockAudio()` before calling
`playPause()` to close any race with the global gesture listener.
The console warning "An AudioContext was prevented from starting
automatically" may still appear once on page load — that's the
informational signal that the pre-gesture context was created suspended;
it's harmless because we explicitly resume on first interaction.
Tested: Linux Firefox 151.0.3 — before fix, clicking play did nothing
(no /audio/ request fired, button never enabled). After fix, single
click on play resumes AudioContext + starts playback, waveform animates.
Pairs with the audio/wav MIME fix in the same PR — both bugs had the
same user-visible symptom (silent play button on Linux) but different
root causes.
fix(stories): apply global reading speed to preview + stem export (#508)
The #415 global speed only flowed through the full longform export
(storyToSpans). Per-segment preview and stem export resolved speed with a
hardcoded `track.speed || 1.0`, silently dropping the global → generated
audio played at 1.0x even with the global set to e.g. 0.70x.
Add a pure `effectiveSpeed(track, globalSpeed)` helper (mirrors
effectiveProfile / storyToSpans precedence: per-line override → global →
engine default) and use it at both call sites so all three generation
paths agree. Regression-tested in storyCast.test.js.
Fixes#508
fix(settings): fill sparse tabs + route Appearance strings through i18n (#507)
* fix(settings): fill the panel on short tabs instead of a stunted box in a void
Settings tabs with little content (Appearance — UI scale/theme/font; About;
Privacy) rendered the accent-bordered .settings-content as a short box with the
rest of the page as empty black void below it (reported on Appearance).
Make .settings-page a flex column with min-height:100% (a FLOOR — tall tabs grow
past it and scroll exactly as before) and let .settings-content flex:1 1 auto
grow to fill. Safe by design: it only adds space when there's slack, and if the
parent height is ever indeterminate the rule no-ops rather than constraining
content.
* i18n(settings): route AppearancePanel strings through i18n
The Appearance panel hardcoded English ("UI scale", "Color theme", "Font", and
the help paragraph) — against the localization hard rule. Wire them through
t('settings.*', { defaultValue }) matching the ApiKeysPanel pattern; reuse the
existing settings.appearance/ui_scale/theme keys and add color_theme/font/
appearance_help to en.json (the reference superset — other locales fall back to
English and backfill via the translation pipeline; the i18n parity gate only
requires valid JSON). Also renamed the THEMES.map(t =>) variable to `th` so it
no longer shadows the translation `t`.
typecheck + Appearance vitest (3) + i18n parity probe (4) all pass.
---------
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(test): resolve package.json version reference in the desktop probe spec (#506)
#503 made tauri.conf.json derive its version from package.json
("version": "../package.json"), but the L3 desktop probe spec
(desktop_smoke.probe.yaml) asserts config.version == pyproject_version and was
reading the literal path string "../package.json" — reddening main.
Resolve the package.json reference in load_tauri_config() the way Tauri does, so
the integrity check sees the effective bundle version. The check now validates
the *real* thing end-to-end: the resolved desktop bundle version matches the
project version (and implicitly that package.json == pyproject).
Full suite: 1691 passed, 20 skipped, 11 xfailed, 3 xpassed.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
chore(version): make frontend/package.json the single source of truth (#503)
Five hand-maintained version literals (pyproject, Cargo.toml, tauri.conf.json,
package.json, version.py) drifting is what shipped a 0.3.6 build calling itself
0.3.5 (package.json lagged; the frozen backend's literal lagged). Collapse to
one canonical source.
- frontend/package.json is canonical: vite already injects __APP_VERSION__ from
it (first-run setup footer + bug reports).
- tauri.conf.json now reads its bundle version from it ("version":
"../package.json", a supported Tauri v2 feature) — the MSI/dmg/updater version
can no longer drift from the UI. Removes the most error-prone literal.
- Cargo.toml + pyproject.toml + version.py's _FALLBACK_VERSION remain as
toolchain-required CI-guarded mirrors, bumped in lockstep from the canonical.
- release.yml: the preview-stamp and version-bump jobs now read/write
package.json (the canonical) and no longer touch the derived tauri.conf.json.
- tests/test_app_version.py: new test_tauri_version_derives_from_package_json
guards the path; the lockstep test now checks the mirrors against the
canonical package.json.
- CLAUDE.md versioning rule updated to document the single-source model.
6 version tests pass.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(web): serve .wav/.flac with IANA-canonical MIME so Linux/Android browsers play inline (#fix)
Python's `mimetypes.guess_type()` returns `audio/x-wav` for `.wav` and
`audio/x-flac` for `.flac` — vendor-experimental types (x- prefix) that
were never IANA-registered. macOS Chrome/Safari MIME-sniff leniently via
CoreAudio so playback works there, but Linux Chrome/Firefox (FFmpeg) and
Android Chrome (ExoPlayer) strictly honor the declared type and treat
the x- variants as download-only.
Result: the play button in the browser web app silently did nothing on
Linux/Android (download prompt instead of inline playback), while the
Tauri desktop shell worked because its WebView is lenient. The
diagnostic signal — Chromium short-circuits to download BEFORE the
<audio> element sees the response, so no MEDIA_ERR_SRC_NOT_SUPPORTED
fires; just a download prompt that's easy to miss.
Fix: register `audio/wav` and `audio/flac` (the IANA-canonical types)
via `mimetypes.add_type()` before the StaticFiles mounts in main.py.
No browser-side workaround exists (no chrome://flags, no about:config
pref) — the server is the only place this can be fixed.
Existing comment in dub_export.py:766 already acknowledges this exact
quirk for video files; this applies the same treatment to audio.
Test: regression test in test_api.py asserts `/audio/<file>.wav` returns
`Content-Type: audio/wav`. Without the fix this returns `audio/x-wav`.
Ref: https://www.iana.org/assignments/media-types/media-types.xhtml#audio
fix(version): frozen backend reports real version, not the 0.3.5 fallback (#501)
The desktop app's About panel, /health, /system/info, diagnostics, bug reports,
and exported persona/marketplace bundle metadata all read core.version.APP_VERSION.
In a synced env that resolves from package metadata (correct), so CI and the
lockstep test were green — but the PyInstaller-frozen backend has no omnivoice
.dist-info, hits PackageNotFoundError, and fell back to a hardcoded
APP_VERSION = "0.3.5". The version-bump job never touched that literal, so every
0.3.x desktop build has been reporting 0.3.5 regardless of its real version.
Fix (belt and suspenders, so it can't recur):
- backend.spec: copy_metadata('omnivoice') so importlib.metadata resolves in the
frozen build — the primary path now works there too.
- backend/core/version.py: resolution chain is metadata → pyproject (walked up,
correct for raw source checkouts) → a named _FALLBACK_VERSION literal as last
resort (no longer the only fallback).
- tests/test_app_version.py: _FALLBACK_VERSION joins the lockstep (now FIVE
sources); + a test that the fallback resolves to pyproject, + a test that
backend.spec copies the metadata (so the frozen path can't silently regress).
- release.yml version-bump: also bumps _FALLBACK_VERSION so the lockstep guard
never reddens main after a release.
Already-shipped binaries can't be fixed, but every build from here (tonight's
preview, the next stable) reports its real version. 5 version tests pass.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(updater): preview channel builds nightly from main + prerelease/parity guards (#500)
The Preview update channel was effectively dead: its only build trigger was a
manual workflow_dispatch, so "preview = main" was never enforced — the live
preview manifest was stuck at 0.3.5-41 (June 7) while main moved to 0.3.7.
It also shipped two latent hazards: the `preview` GitHub release had drifted to
isPrerelease=false (a non-prerelease `preview` is eligible to become GitHub's
"Latest" — the exact URL the *stable* updater reads, so it could hijack the
Stable channel), and its updater manifest dropped darwin-x86_64 (Intel-Mac
preview users silently got no updates — a cross-platform-parity breach).
Changes (release.yml):
- Add a nightly `schedule` (07:00 UTC) that rebuilds the rolling `preview`
prerelease from main. A new `preview-gate` job no-ops the 4-platform matrix
on nights when main didn't move, so idle days cost only a ~30s gate job.
- Centralize the preview-vs-stable decision in `preview-gate.outputs.is_preview`
(schedule OR workflow_dispatch+publish_preview), consumed by the stamp step,
tauri-action, and preview-notes — replacing the repeated inline conditions.
- Harden the prerelease flag: preview-notes' `gh release edit` now re-asserts
`--prerelease` every run, and a new post-publish step fails the run if the
preview release isn't a prerelease or its manifest is missing any platform
stable ships (catches the Intel-Mac regression in CI).
Docs (docs-sync): update docs/update-channels.md — previews are no longer
"manual / no scheduled spend"; they build nightly from main (+ on demand).
The live `preview` release was re-flagged prerelease out-of-band to close the
hazard immediately; this makes it recurrence-proof.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
docs(changelog): backfill high-quality v0.3.6 release notes + make it a hard rule (#499)
v0.3.6 was tagged with the "Auto-generated release for v0.3.6…" fallback body
because CHANGELOG.md had no matching section (release.yml's "Extract CHANGELOG
section for tag" step found nothing). Backfill a real, user-facing
`## [0.3.6] — 2026-06-16` section (Longform suite, engine routing, dubbing +
install reliability, FSL→AGPL relicense) and fold the shipped `.ovsvoice`
persona entry out of [Unreleased]. The live GH release body was updated
in-place to match (notes + the per-platform checksum blocks preserved).
Add a "Release notes / changelog" hard rule to CLAUDE.md: every tagged release
(and preview build) gets a high-quality, house-style CHANGELOG section before
the tag — never the auto-generated fallback — since release.yml ships that
section verbatim as the release body.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
chore(version): main -> 0.3.7 after v0.3.6 release
chore(version): bring frontend/package.json into the version lockstep (0.3.6) (#497)
Pre-v0.3.6 release sweep found frontend/package.json stuck at 0.3.5 while the
other three version files were 0.3.6. package.json drives the runtime
`__APP_VERSION__` (vite.config.js), so a v0.3.6 build was calling itself "v0.3.5"
in the first-run footer AND in every auto bug report (undercutting the bug-report
feature). Root cause: the release.yml version-bump job only bumped the trio
(tauri.conf.json / Cargo.toml / pyproject.toml), never package.json, and no test
guarded the lockstep.
- Bump frontend/package.json 0.3.5 → 0.3.6 (matches the trip; `--frozen-lockfile`
still passes — the version field doesn't affect the bun lock graph).
- Add frontend/package.json to the release.yml version-bump job (set absolutely
via jq so any prior drift self-heals on the next release).
- Add tests/test_app_version.py::test_all_version_files_in_lockstep — fails CI if
the four files ever diverge again.
- CLAUDE.md versioning rule updated: it's now FOUR lockstep files, not three
(docs-sync).
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
docs(planning): commit singing-mode + donate-cta specs + SPIKE-02 supersession (#496)
Persist the planning artifacts produced this cycle:
- specs/006-dubbing-singing-mode/ (SoulX-Singer SVS evaluation + plan; supersedes
SPIKE-02, which is marked superseded here).
- specs/007-donate-cta/ ("Fund Claude Max" goal bar + kawaii postcard design,
conversion strategy, frequency state machine, Discord surface).
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(bootstrap): force-reinstall setuptools so pkg_resources repair actually works (#248) (#495)
The auto-repair ran `uv pip install setuptools>=75,<80`, which `uv` treats as
"already satisfied" (no-op, "Checked 1 package in 5ms") whenever setuptools'
*metadata* is present but its `pkg_resources` files are gone — the common cause
being Windows Defender quarantining `pkg_resources/`, or a partial extract on a
restricted network. So the repair never restored the files, the post-check
failed, and users hit the #248 dead-end. The error message *also* told them to
run the same no-op command, so the suggested manual fix didn't work either
(reported on Discord, Win11 + RTX 5070 Ti).
Fix: both repair sites in bootstrap.rs now use `--reinstall` (the flag already
used for the ROCm torch repair), which force re-extracts pkg_resources even when
uv thinks setuptools is satisfied. The fail() message and the failure.py hint now
suggest `uv pip install --reinstall 'setuptools>=75,<80'` + an antivirus-exclusion
note, and docs/install/troubleshooting.md (#pkg_resources-missing) is updated with
the real cause (metadata-present/files-missing) + AV guidance.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat(donate): "Fund Claude Max" goal bar + kawaii postcard + milestones (#007) (#494)
Problem
OmniVoice's donate surface was a static link page. There was no sense of
shared progress toward a concrete funding goal, and no gentle, success-only
ask after a user got value — only an always-on footer heart.
Design
Phase 1 — Goal bar + data (Option B):
- frontend/public/donation_progress.json (committed snapshot) + a bundled
offline fallback in api/donation.ts. loadDonationProgress() best-effort
fetches a fresher copy and gracefully falls back to the bundle on any
failure (offline / non-2xx / bad JSON). Never throws.
- <GoalBar> (page + `mini` variant), --goal-pct-driven fill, Pip mascot
perched on the fill, ONE shimmer pass, reduced-motion guard on every
animation. Added to SupportPage above the payment cards with
"Join {n} supporters" social proof + suggested amounts ($3/$5/$10/Custom,
middle flagged "most common", NONE pre-selected).
Phase 2 — Pip + postcard + state machine:
- Pip.jsx (currentColor->accent, pipBob/pipWave idle, reduced-motion off).
- donationSlice.ts composed into the store: added to partialize (all EXCEPT
shownThisSession), version 5->6 with a pass-through migrate branch.
shouldShow rules: first-3 grace, <=1/session, escalating 7d/14d/30d/75d
cooldowns, optedOut terminal, success-only.
- Postcard.jsx rendered via react-hot-toast as a NON-BLOCKING custom toast
(no backdrop, no focus steal, ~12s auto-dismiss, pause on hover) with the
perforation / dot-grain / stampThunk / postcardIn / .is-leaving art,
a mini GoalBar, and Chip in / Maybe later / quiet Don't ask again /
free Star on GitHub actions.
- One shared evaluateDonationPrompt() called right after each SUCCESS
(dub-complete, clone-save resolve, longform export) — never on the
error / in-progress / setup / first-run paths.
Phase 3 — Milestones + pill:
- Milestone eval (1st clone / 10th dub / 30-day sustained, each once-ever,
same cooldowns + opt-out) inside the shared evaluator.
- Quiet nav-rail .donate-pill (🩷 Support) that warms to the accent on
hover and opens setMode('donate').
Tests (vitest, all green: 60 files / 533 tests)
- donationSlice.test.ts: shouldShow truth table with injected `now` — grace,
each cooldown rung, session cap, opted-out terminal, success-only.
- GoalBar.test.jsx: renders from injected JSON, offline fallback to bundle,
goal-met state, mini variant; plus the data module's clamp/normalize/fetch.
- evaluateDonationPrompt.test.jsx: gating + that the postcard never fires on
the error path (success-only contract).
bun run typecheck:ci clean; vite build green; root bun.lock untouched
(frozen install verified); i18n: all user-facing strings via t('donate.…')
with English defaultValue fallbacks — no hardcoded CJK.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(profiles): decouple design-profile save from TTS render (#476) (#488)
* fix(profiles): decouple design-profile save from TTS render (#476)
Saving a design voice profile forced a full TTS model load + inference to
render a deterministic identity sample. On a fresh model-less image (Docker
first-run) that 503'd, so the save failed. A secondary guard also rejected an
all-Auto design (empty instruct) with a 422.
Saving a design profile is now a pure persistence operation:
- The seed-42 identity sample render is attempted opportunistically but is
non-fatal — if the engine isn't ready the row is persisted with
ref_audio_path=NULL (sample pending). The row's vd_states + instruct already
make the voice fully usable (generation.py falls back to instruct-only
conditioning for design profiles with no ref audio).
- The sample is rendered lazily + cached on the first GET /profiles/{id}/audio
request; if the engine is still unavailable that path returns a precise
"model not ready — finish setup / download a model" 503.
- The all-Auto (empty-instruct) design is now saveable (vd_states still
required).
Adds tests/test_profile_design_save_decouple.py (top-level tests/, asyncio.run
per test) covering: design save with model unavailable creates the row instead
of 503-ing; all-Auto design is saveable; the pending sample materializes on
first /audio request. Updates the unification spec (docs-sync).
* fix(profiles): contain profile-audio paths under VOICES_DIR (CodeQL CWE-22)
The lazy design-sample path was built as `os.path.join(VOICES_DIR,
f"{profile_id}.wav")` / `os.path.join(VOICES_DIR, audio_file)` where profile_id
is the request path param — CodeQL flagged 5 high-severity path-injection alerts
(profiles.py + the taint flowing into archetypes.py's torchaudio save). Add
`_safe_voice_path()` (basename + safe-char sanitise + realpath containment,
mirroring core.config.dub_seg_path) and route both the read and lazy-render
sites through it; a traversal id now 404s instead of escaping VOICES_DIR.
Regression test covers the containment guard.
* fix(profiles): use CodeQL-recognized path-injection guards (CWE-22)
The previous `_safe_voice_path()` helper was correct (basename + realpath
containment) but CodeQL's taint tracking didn't propagate the barrier through
the function return, so the 5 path-injection alerts persisted. Switch to guards
CodeQL recognizes, inline at each file-op site:
- validate `profile_id` against the generated-id charset (`[A-Za-z0-9_-]{1,64}`)
with `re.fullmatch` and 404 on mismatch (covers the `f"{profile_id}.wav"`
render path);
- read only `os.path.join(VOICES_DIR, os.path.basename(name))` so a stored/derived
filename is always a direct child of VOICES_DIR (covers the read + the taint
flowing into archetypes.py's torchaudio save).
Drop the helper. Test now asserts a traversal/separator/NUL profile_id 404s at
the guard. Same security property, recognized by CodeQL.
* fix(profiles): inline realpath+commonpath containment for CodeQL (CWE-22)
CodeQL didn't recognize the earlier sanitizers — neither the helper (barrier
hidden behind a function return) nor os.path.basename / a cross-function regex
guard cleared the 5 path-injection alerts. Use the canonical, CodeQL-recognized
form INLINE at each file-op site: resolve the path with os.path.realpath (which
collapses any `..`) and confirm os.path.commonpath((base, path)) == base before
the read / the render, returning 404 / raising on escape. Same property the
helper had, now in a shape CodeQL's taint tracking follows. Keeps the profile_id
charset guard as defense-in-depth.
* fix(profiles): route design-sample path through shared _voices_path guard (#476)
The inline realpath+commonpath containment in get_profile_audio and
_materialize_design_sample wasn't recognized by CodeQL as a path-injection
sanitizer (5 new high-severity py/path-injection alerts at the file-op sites,
incl. archetypes.py mkdir via the rendered Path). Both now reuse the existing
_voices_path() helper, which applies the os.path.basename() barrier plus
symlink-resolved containment — the same guard the consent endpoint uses and
that CodeQL already accepts. Behavior is unchanged: the DB columns only ever
hold bare {profile_id}.wav filenames, so basename() is a no-op here.
Tests: tests/test_profile_design_save_decouple, test_profile_unification,
test_profile_consent, test_archetype_blank_guard — 25 passed.
---------
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
docs(conventions): add "Fix quality" + "Keep main green" hard rules (#493)
Owner-set 2026-06-16. (1) Fix issues properly/future-maintenance-proof — fix the
whole class, add a regression test, harden against recurrence; extra effort, not
extra verbosity. (2) A merge must never break main's CI — verify the full CI
matrix (every .github/workflows/* AND deploy/Dockerfile) before landing, with
explicit guidance that frontend/ is a bun workspace monorepo whose root bun.lock
must be regenerated on any frontend/package.json change (Docker uses
--frozen-lockfile; plain bun install in ci.yml tolerates drift). Motivated by the
#485 bun.lock incident that reddened main's Docker workflow.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(ui): clone popover/CTA clipping + textarea resize (#481, #476) (#489)
Bug #481 — Clone "+Insert" popover was clipped offscreen and the script
textarea couldn't be resized:
- Apply the existing `.clone-panel--overflow-visible` helper to the script
`.studio-panel` so the upward-opening popover escapes the panel's
`overflow:auto` box instead of being shoved into its scroll region.
- Cap the popover at `max-width: min(360px, calc(100vw - 16px))` so the
14-chip grid can never spill past the viewport edge.
- Re-enable the textarea corner grip (`resize: vertical`, matching the base
`textarea.input-base`) and lift the ⊕ Insert button off the bottom-right so
it no longer physically covers the drag handle.
Bug #476 — the design-mode "Synthesize Audio" CTA dropped below the fold on
narrow shells:
- Replace the raw `@media (max-width: 900px)` reflow rules with the app's
shell-width classes (`.shell-narrow` / `.shell-mini`, set in App.jsx from
`app-container.clientWidth`). The shell scales via `zoom`, so a viewport
media query fired at the wrong threshold whenever `--ui-scale ≠ 1`.
- When stacked, let `.studio-with-history__main` grow (drop its `overflow:hidden`
clip) and pin the action bar `position: sticky; bottom: 0` so the Synthesize
CTA stays on-screen.
Pure CSS + one className; no component restructuring. Added a regression test
guarding the shell-class reflow + sticky CTA against the viewport-`@media`
anti-pattern. typecheck:ci clean; vitest 506/506 green.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(dub): auto-assign per-speaker voices in multi-speaker dubbing (#486) (#490)
Multi-speaker dubs detected speakers and built per-speaker clones (Voice
dropdown showed "From Video → Speaker N"), but most segments stayed on
"Default" voice and had to be set by hand — inconsistently across runs.
Root cause: after diarization, dub_core stamped each long line (the
default-on per-segment-ref path) with `auto-seg:{id}` as its profile_id.
The dub editor's Voice <select> (and the Cast panel) only render `auto:`
options, so an `auto-seg:` value matched no <option> and silently showed
"Default". Short lines (<3s) fell through to `auto:{speaker}`, which DID
render — hence "sometimes the cloned voice is picked".
Fix: bind every segment to the UI-visible `auto:{speaker}` whenever its
detected speaker has a clone; only fall back to `auto-seg:{id}` when the
speaker has no per-speaker clone at all. The per-segment-ref quality win
is preserved: dub_generate's `auto:` branch now transparently prefers
THIS segment's own per-segment ref (segment_clones[seg_id]) when present,
else the per-speaker clone. Manual overrides and the no-clone path are
untouched; existing jobs that persisted `auto-seg:` ids still resolve.
Tests: tests/test_dub_multispeaker_voice_486.py — assignment binds to
auto:{speaker} (not auto-seg:), never clobbers manual overrides, falls
back to auto-seg: only when the speaker has no clone; generate-time
resolution prefers per-segment ref then per-speaker clone. Green
alongside the existing dub generate/incremental/segmentation suites.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(deps): re-sync root bun.lock after #485 (fixes main Docker red) (#492)
* fix(deps): re-sync root bun.lock after #485 frontend floor bumps (main Docker red)
#485 bumped ~25 dependency floors in `frontend/package.json` but didn't
regenerate the repo-root `bun.lock` (this is a bun *workspace* monorepo — the
lockfile lives at root and embeds the frontend member's ranges). The Docker
workflow runs `bun install --frozen-lockfile`, which failed on the drift
("lockfile had changes, but lockfile is frozen") — turning main red on commit
4bcbc74. `ci.yml` uses a plain `bun install`, so it tolerated the drift and went
green, which is why only Docker caught it.
Regenerate `bun.lock` so its embedded frontend snapshot matches the manifest;
`bun install --frozen-lockfile` now passes (verified locally, bun 1.3.14, the
same version Docker uses). Lockfile-only change.
Follow-up (separate): switch `ci.yml`'s frontend `bun install` to
`--frozen-lockfile` so this drift class fails fast in CI, not only in Docker.
* ci: use --frozen-lockfile for frontend install so lockfile drift fails fast
Recurrence-proofing for the #485 incident: ci.yml's plain `bun install` silently
tolerated the root bun.lock drifting from frontend/package.json, so CI went green
while only the Docker build (which already uses --frozen-lockfile) caught it and
reddened main. Both frontend install steps now use --frozen-lockfile, so a
package.json change that forgets to regenerate root bun.lock fails in CI fast.
Verified `bun install --frozen-lockfile` passes from frontend/ against the
re-synced lockfile (bun 1.3.14).
---------
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
chore(deps): conservative refresh — backend, UI, Tauri (#485)
* chore(deps): refresh backend HTTP/cert/security leaf packages
Conservative, targeted refresh (no blanket re-resolve). Bumps only low-risk leaf
packages — yt-dlp 2026.3.17→2026.6.9 (extractor currency), aiohttp, requests,
urllib3, idna, certifi, charset-normalizer, pillow (HTTP/cert/security). No
major bumps, no downgrades, no transitive removals; the 91-package full
`--upgrade` was rejected because it downgraded numpy/pandas/av and pulled a
starlette 1.x major that broke WS route introspection. Full backend suite: 1674
passed.
* chore(deps): bump UI deps to within-major latest
`bun update --latest` floors raised to current within-major releases — react
19.2.7, react-dom 19.2.7, vite 8.0.16, tailwindcss/@tailwindcss/vite 4.3.1,
@tanstack/react-query 5.101, @radix-ui/* minors, lucide-react 1.18, zustand
5.0.14, i18next 26.3.1, plus dev tooling (vitest 4.1.9, eslint 10.5, playwright
1.61, @tauri-apps/cli 2.11.2). Verified no major version crossings. typecheck:ci
clean; vitest 503/503.
* chore(deps): cargo update Tauri crates within range
`cargo update` — 77 crates locked to latest semver-compatible versions (patch/
minor: bitflags, chrono, hyper, reqwest, regex, rustls-native-certs, etc.; one
in-range 0.x bump global-hotkey 0.7→0.8). No Cargo.toml range changes. `cargo
check` compiles clean (0 errors).
---------
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(deps): bundle `openai` so Cinematic/LLM features work out of the box (#484)
Cinematic dub refinement, glossary auto-extract, and LLM-based translation all
`from openai import OpenAI` (services.llm_backend / translator / dub_translate /
glossary), but `openai` was declared nowhere in pyproject — not in dependencies,
not in any optional extra, and no setup script installed it. So a fresh `uv sync`
never installed it, and these features were dead-on-arrival on every source
install: picking Cinematic showed "Cinematic needs an LLM" even with Ollama
running and correctly configured, because `OpenAICompatBackend.is_available()`
returned "openai package missing". The UI's "pip install openai" hint is a trap
on a managed venv — users (Discord report) installed it into system Python, not
the app's `.venv`, so it still didn't take.
Add `openai>=1.40` to dependencies (resolves to 2.41.1; verified the code's
`OpenAI(...)` + `chat.completions.create(model=, messages=)` call shapes are
unchanged in 2.x). Pure-Python, no native deps → identical on macOS/Windows/Linux
(default-parity rule). Cinematic + any OpenAI-compatible endpoint (OpenAI, Ollama,
LM Studio, vLLM) now work after `uv sync`, no manual package install.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(settings): make in-app models dir authoritative over launcher-injected env (#480) (#483)
Changing the model download location in Settings had no effect: after the
prompted restart, new downloads still went to the old folder and "Effective
location" stayed stuck on it.
Two stores hold the models dir. The in-app Settings panel writes the new path to
the durable per-user env file (`~/.config/omnivoice/env`, OMNIVOICE_CACHE_DIR),
but the desktop launcher injects the OLD value from its own Tauri config into the
backend's environment before startup — and main.py loaded the per-user file with
`override=False`, so the launcher's stale value always won. main.py then maps
OMNIVOICE_CACHE_DIR → HF_HOME/HF_HUB_CACHE/TORCH_HOME, pointing downloads at the
old dir; `_effective_models_dir()` reads that live env, so the UI faithfully
reported the old path as if the change had failed.
Fix: load the per-user env file with override so it beats launcher-injected
defaults — restoring this file's documented "values written here take effect on
the next backend launch" contract. Centralized as `user_env.load_into_environ()`
(the file is the in-app Settings source of truth) and called from main.py. Both
keys this file can hold (OMNIVOICE_CACHE_DIR, HF_ENDPOINT) are the user's explicit
Settings choice and should beat the launcher default, so the override is correct
for both (this also fixes the same latent bug for a Settings-set HF mirror).
HF_TOKEN isn't launcher-injected, so its behavior is unchanged.
Follow-up (separate PR): add a Tauri `set_models_dir` command so the launcher's
config.json stays in sync, covering the reset-to-default edge too.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(asr): decode WhisperX audio via validated ffmpeg, not bare PATH lookup (#479) (#482)
WhisperX transcription called `whisperx.load_audio()`, which shells out to a
literal `"ffmpeg"` resolved against the OS PATH. On Windows that resolves to a
WindowsApps alias stub or a corrupt/wrong-arch binary — passing `which` but
exploding at spawn with `[WinError 193] %1 is not a valid Win32 application`.
whisperx only catches `CalledProcessError`, so the spawn-time `OSError` escaped
and the dub/batch path reported the opaque "Transcription produced no segments".
#377 added ffmpeg validation but only for the dub-export path; the transcription
path never went through the validated resolver. Since WhisperX is a default ASR
engine, this is a P0 platform-parity break (works on mac/Linux, fails on Windows).
Fix: decode the audio ourselves in `WhisperXBackend.transcribe` via
`find_ffmpeg()` (which `-version`-probes each candidate and returns the bundled
imageio-ffmpeg / Tauri sidecar) and hand WhisperX the array — bypassing the bare
PATH lookup entirely. This is more robust than a PATH-prepend, which couldn't
fix the imageio case (its binary is named `ffmpeg-<plat>-vN.exe`, not `ffmpeg`).
If no runnable ffmpeg exists, raise a clear, locale-independent error instead of
"no segments". Fixes both the dub and batch transcription paths.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(translate): guess source language from text instead of defaulting to "en" (#478)
When neither the request nor the job carries a detected source language,
_resolve_source_lang() silently fell back to "en". For non-English audio
(e.g. Korean) this produced en -> en, which has no Argos package and failed
every segment — even though WhisperX had detected the language correctly
(e.g. "Detected language: ko (0.98)").
Add a last-resort script-based guess (ko/ja/zh/ru/ar) from the segment text
so the bare "en" fallback no longer breaks non-English dubbing.
Co-authored-by: stronghamjji <289942360+stronghamjji@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
fix(bootstrap): stall watchdog so a stuck backend isn't a buttonless dead-end (#474) (#475)
The entire main UI is gated behind `bootstrapStage === 'ready'` (App.jsx) — until
the Python backend reports ready, only the BootstrapSplash shows. If the backend
hangs in a non-terminal stage and never reaches ready (e.g. a failed from-source
backend spawn on Windows: uv/Python not on PATH), useBootstrapStage polled
forever, trapping the user on a splash with no Settings / Start / Clone / Extract
buttons — which is exactly what #474 reports (verified: no backend-startup
regression; every startup-imported router imports cleanly).
- useBootstrapStage: add a per-stage stall watchdog. Track when (stage,message)
last changed; if a non-terminal stage sits past its budget (installing_deps
gets 20 min since it legitimately runs 5–10 min; everything else 120 s), flip
to the existing `failed` state — which already surfaces actionable hints, the
live log panel, and Retry / Clean-&-Retry. Any change resets the clock, so a
live install never trips it.
- detectHints + bootstrap.hint_stuck: a targeted hint for the stuck case
(run `uv sync`, check uv/Python on PATH, read the log / Settings → Logs).
- CONTRIBUTING.md: document `bun run desktop-prod` (the prod desktop command the
reporter typo'd as `desktop=prod`), note both desktop scripts auto-run
`uv sync` + start the backend, and add a "stuck on the setup splash" pointer.
No backend code change. Frontend suite green (503); CJK guard green.
feat(audiobook): durable crash-resume for interrupted longform renders (#470)
* feat(audiobook): durable crash-resume for interrupted longform renders
Chapter WAVs were already content-addressed (a re-run reused finished chapters),
but resume only worked if the user could re-submit the EXACT script — impossible
for Stories, whose plan is compiled from cast+lines. This persists the plan
itself so an interrupted render is resumable without the original input.
- New services/longform_resume.py (pure file/JSON): on render start, write a
resume.json manifest (compiled plan + render params + title) into the job work
dir, atomically; clear it on successful completion. read/has/clear/build
helpers, schema-versioned (a foreign/corrupt manifest is ignored, never
resumed).
- _render_longform_sse: accepts an optional job_id + resume flag (resume reuses
the original job row + cached chapters instead of creating a new one); writes
the manifest at start, clears it on done. Both front doors (/audiobook,
/longform/render) unchanged for callers.
- GET /audiobook/jobs — lists interrupted renders (running/failed longform jobs
that still have a manifest; a job left "running" across an app restart is
interrupted by definition), with title + total/done chapter counts for the UI.
- POST /audiobook/resume/{job_id} — rebuilds the plan from the manifest and
replays _render_longform_sse under the original job_id; the content-addressed
cache makes finished chapters instant, so only the unrendered ones synthesize.
404 on unknown id / missing manifest.
Resume durability is best-effort — a manifest failure never blocks the render.
The resume UI affordance is a follow-up (the endpoints are ready for it).
Tests: tests/test_longform_resume.py (7, pure manifest round-trip / version &
corrupt rejection / atomic write — monkeypatches OUTPUTS_DIR, no global
core.config stub so the shared tests/ session isn't polluted) +
backend/tests/test_audiobook_resume_api.py (6, config-stub: jobs-list with
progress, failed-included, done/manifestless/non-longform excluded, resume
404s). 13 passed. CJK green. Stale module docstring updated.
* fix(audiobook): confine resume paths — py/path-injection (CodeQL) + quality
The default-setup CodeQL (security-and-quality suite) flagged the crash-resume
work: longform_resume built filesystem paths from job_id, which on the
POST /audiobook/resume/{job_id} endpoint is a request-supplied path param →
py/path-injection (10 high-severity sinks: open/replace/remove/makedirs/isfile).
- longform_resume.work_dir now confines like profiles._voices_path: reject an
unknown job_type or an id that isn't a bare safe token (^[A-Za-z0-9_-]{1,64}$),
then realpath + startswith(OUTPUTS_DIR + os.sep) — a crafted id (`../`, NUL,
separators) can never escape OUTPUTS_DIR. Returns None on violation; all
callers (manifest_path/read/write/clear/has) degrade gracefully.
- The resume endpoint also gates the path-param id up front (404 on a bad
token) — barrier at the source as well as the sink.
Also cleared the quality alerts the same diff introduced:
- py/repeated-import: the 4 inline `from services import longform_resume` calls
collapse to one module-top import (it's pure, no torch).
- py/empty-except: the best-effort manifest blocks now logger.debug instead of
a bare `pass`.
13 resume tests still pass; all job ids in tests are safe tokens.
* fix(audiobook): sanitize resume job_id at the source (path + log injection)
The first CodeQL pass wasn't enough: resume made job_id request-controlled, so
it tainted not just the manifest paths but the EXISTING work-dir join and the
progress log lines too (py/path-injection + py/log-injection, ~14 alerts).
Fix at the source so the whole dataflow is clean:
- _render_longform_sse strips job_id to a safe token (`re.sub` removing anything
but [A-Za-z0-9_-], capped 64) right after it's resolved — no path separator,
no CR/LF can survive, whether the id came from the resume path param or a
fresh uuid.
- The work dir now routes through longform_resume.work_dir, which adds the
proven os.path.basename(seg)==seg barrier (the shape CodeQL accepts in
_voices_path) on top of the realpath+startswith confinement — so the join and
every path derived from it (meta/concat/out) is sanitized.
- The best-effort manifest-write log no longer interpolates the raw exception
(uses exc_info); clear_manifest's OSError handler returns instead of bare pass
(py/empty-except).
13 resume tests still pass.
* fix(audiobook): launder resume job_id via trusted FS scan (CodeQL path/log-injection)
The custom realpath/regex barriers weren't in CodeQL's recognized sanitizer set,
so the request-supplied resume job_id kept tainting the work-dir/manifest paths
and the progress logs. Switch to the pattern CodeQL does accept — launder the id
through a trusted filesystem enumeration:
- longform_resume.scan_resumable() lists resumable jobs by scanning OUTPUTS_DIR
for <type>_<id>/resume.json; every id it returns is sourced from os.listdir
(never request input).
- POST /audiobook/resume/{job_id} now only resumes an id that scan_resumable()
reports (membership match), and uses the (job_type, job_id) pair FROM that
trusted list for everything downstream — so nothing request-controlled reaches
a filesystem path or a log line.
- GET /audiobook/jobs lists from scan_resumable() too (filesystem-sourced ids).
work_dir keeps the realpath+startswith+basename confinement as genuine defense;
the render path's job_id is now always either a fresh uuid or a laundered id.
13 resume tests still pass.
* fix(audiobook): exact-match allowlist on the work-dir name (CodeQL path-injection)
The remaining 4 path-injection alerts were inside work_dir: I validated job_id
with an anchored regex but then joined a DIFFERENT f-string (`{job_type}_{job_id}`),
so CodeQL didn't carry the sanitization to the joined value. Mirror the pattern
the repo's _safe_cover_path uses (which CodeQL accepts): validate the WHOLE
joined component against an exact-match allowlist regex (_SAFE_SEG_RE), then
confine with os.path.commonpath containment (the recognized barrier) instead of
startswith. 13 resume tests still pass.
* fix(audiobook): basename-sanitize the work-dir name for CodeQL path-injection
The exact-match regex alone wasn't credited; route the joined value through os.path.basename() first — the sanitizer CodeQL recognizes (mirrors _safe_cover_path) — then the regex + commonpath. Functionally identical (no separator in the name) but clears the 4 remaining alerts. 13 tests pass.
* fix(audiobook): allow-list membership guard launders resume job_id (CodeQL)
The next(... if pair[1]==job_id) comparison-select didn't sanitize for CodeQL. Build a dict of resumable ids from the trusted scan and gate with 'if job_id not in resumable' — the membership barrier CodeQL recognizes — then use job_id directly downstream. 13 tests pass.
* fix(audiobook): eliminate request→path flow in resume (definitive CodeQL fix)
Five rounds of recognized path-injection barriers (regex, basename, exact-match,
commonpath, membership-guard) still left CodeQL flagging the resume job_id →
work-dir/manifest/log flow. Remove the flow entirely instead of guarding it:
- scan_resumable() now returns {job_type, job_id, manifest_path} where
manifest_path is built from the os.listdir dir name (trusted), plus
load_manifest_file(path) / discard_manifest_file(path) that operate on those
trusted paths. The request job_id is used ONLY to *select* a scan entry, never
to build a path.
- POST /audiobook/resume/{job_id} reads the manifest via the trusted scan path
and renders under a FRESH server uuid (job_id=None). The chapter cache is
content-addressed (keyed by chapter content, not the job id), so finished
chapters still hit instantly — resume works, but the request's id never names
a work dir, output file, or log line.
- The interrupted job's manifest is discarded (trusted path) once the fresh-id
resume kicks off, so it stops showing as resumable.
Net: no request-controlled value reaches any file operation or log on the
render path (job_id there is always a server uuid). work_dir keeps its
confinement barriers as defence-in-depth. 13 resume tests pass.
perf(omnivoice): cache voice-clone prompt embeddings (#427) (#473)
Every cloned generation re-encoded the reference audio from scratch — a fixed
per-request latency that compounds on batch / long-form / dataset workloads that
reuse one saved voice across many calls.
The OmniVoice model already exposes the fast path (create_voice_clone_prompt →
VoiceClonePrompt, generate(voice_clone_prompt=)); the Studio backend just wasn't
using it. OmniVoiceBackend.generate now:
- builds a VoiceClonePrompt once per reference and caches it (bounded LRU, max 8,
keyed by ref path + mtime + ref_text; thread-safe — generation runs in a GPU
thread pool), then passes voice_clone_prompt= to skip the re-encode;
- falls back to the inline ref_audio/ref_text path on ANY cache miss or error,
so output is identical either way (the model documents the two as equivalent)
— this is purely a latency optimization, never a behaviour change;
- the design/instruct path (no ref_audio) is untouched.
- unload() clears the cache so a flush / engine-switch frees the prompt tensors.
tests/test_clone_prompt_cache.py: 6 cases (encode-once-then-hit, ref_text +
mtime invalidation, LRU eviction at the cap, encode-failure → None fallback,
clear). 6 passed.
Closes#427.
feat(stories): global reading-speed control (#415) (#472)
The Stories editor only had a per-track speed slider; long scripts had no way to
set one speed for the whole thing. Add a global speed control that applies to
every line WITHOUT its own per-track override (the per-track slider still wins).
- storyToSpans(tracks, cast, globalSpeed): per-track speed wins, else the global
speed, else engine default. 1.0× (and null) is treated as "no override" so a
resting control never stamps an explicit speed on every span. Builds on the
#27 default_speed plumbing already in the canonical parser.
- StoriesEditor: a global speed slider in the toolbar (0.5–2.0×, with reset),
persisted to localStorage (UI preference — no project-state/slice migration).
- i18n: stories.global_speed / global_speed_hint in en.json.
Tests: storyToSpans.test.js +2 (global applies to un-overridden lines, per-track
wins; 1.0×/null/default-arg = no override). 17 file / 120 suite pass; CJK green.
Closes#415.
fix: actionable errors for non-executable engine binary (#437) + unreachable backend (#438/#454/#466) (#471)
Two reliability bugs from open issues, both first-run papercuts where the error
told the user the wrong thing.
#437 — `[Errno 13] Permission denied: bin/omnivoice-tts-linux-x86_64`: a git
clone / zip extract on POSIX can drop the bundled binary's execute bit. It only
surfaced at spawn time, and the generic synth handler then mislabeled it as
"ran out of memory" and told the user to flush the model.
- omnivoice_gguf.is_available() now self-heals: after the SHA check confirms the
binary is the right file, it adds +x (best-effort) on POSIX; if it can't, it
returns a clear "isn't executable — run chmod +x <path>" message instead of a
spawn-time crash. No-op on Windows.
- generation.py classifies PermissionError / EACCES / "Permission denied" as its
own case ("a bundled binary lost its execute bit — reinstall or chmod +x"),
so it never again masquerades as OOM.
#438/#454/#466 — bare "Failed to fetch" / "NetworkError": when the local backend
is still starting, crashed, or the dev server dropped, fetch() throws a TypeError
that propagated raw to the user.
- client.ts apiFetch now catches the thrown fetch and raises an ApiError with an
actionable message ("Can't reach the local OmniVoice backend — it may still be
starting up… restart the app or check Settings → Logs"), status:0 to mark a
transport failure vs an HTTP error.
Tests: client.test.ts +1 (thrown fetch → ApiError status 0 + actionable text);
3 pass. CJK guard green.
test(settings): de-flake the at-rest-encryption assertion (#469)
test_stored_value_is_encrypted_not_plaintext asserted `"hf_" not in raw`, but
the stored value is Fernet URL-safe base64 whose alphabet includes `_`, so a
random ciphertext occasionally contains the substring `hf_` by chance — a
false failure that bit unrelated PRs on CI (~1 in N runs).
Replace the 3-char-prefix substring check (weak AND flaky) with stronger,
deterministic guarantees:
- the full token is absent from the raw column (kept),
- a 16-char leading chunk is absent (no partial leak; 62^16 ≈ never collides),
- and the value round-trips via get_hf_token() — proving it's genuinely
encrypted, not merely absent/empty.
Verified non-flaky: the target test passed 8/8 consecutive runs.
feat(longform): JS canonical port + frontend convergence (#27 slice B) (#467)
Mechanically-mirrored JS twin of the Python parser, verified byte-for-byte against the shared golden corpus. See PR body.
docs(longform): retire the hand-sync comment now the corpus enforces parity (#27 slice C) (#468)
The SSML-lite client port header said "keep in sync with
backend/services/ssml_lite.py" — a manual contract with no test behind it.
After #27 the canonical longform grammar (incl. SSML-lite via the
longformParser.js → storyToSpans path) is asserted byte-for-byte against the
Python parser through the shared golden corpus
(tests/fixtures/longform_parser_cases.json), so drift between the two SSML impls
now fails CI. Update the comment to point at that enforcement.
No user-facing docs document the marker dialect (verified by grep: only the
internal competitive-analysis planning doc references it), so no docs-sync
update is required for the converged behaviour.
feat(longform): canonical Python parser + golden corpus (#27 slice A) (#465)
The longform marker dialect (# heading / [voice:] / [pause] / SSML-lite) was
parsed by three independent code paths that already disagreed (client vs server
on [pause] units, [voice:] empty, H1-only chapters). This lands the single
canonical Python parser; the JS port + cross-impl test follow in slice B.
- New backend/services/longform_parser.py — parse_script_to_spans(text, *,
default_voice, default_speed) + _parse_chapter_body (the reusable voice→pause
→SSML layering the JS twin mirrors). Moves the H1/voice regexes verbatim from
audiobook.py (already CodeQL-cleared), reuses parse_pause_markers + ssml_lite
unchanged. Coerces None→"" and normalizes CRLF/CR→LF at entry (cross-platform
parity so Windows-authored scripts never carry a stray \r). Adds default_speed
plumbing (inline SSML speed overrides the per-line default).
- audiobook.py: parse_audiobook_script is now a thin wrapper that wraps the
canonical span dicts in Span/Chapter/AudiobookPlan — public return type and
.to_dict() shape unchanged, all four router call sites untouched. Deleted
_parse_spans / _HEADING_RE / _VOICE_RE and the now-dead `import re` +
parse_pause_markers import.
- tests/fixtures/longform_parser_cases.json — 78-case golden corpus (≥40
required) covering §A–I: H1-only chapters (H2–H6 + `# ` no-title → body), the
full pause dialect incl. the NO-MATCH boundary, banker's-rounding ties
([pause 0.5]→0, [pause 1.5]→2), [voice:] empty→default, [voice:[nested]]
literal, SSML nesting/spell/unknown-tag, speed override, CRLF, combined
precedence. Generated from actual parser output (the truth the JS port must
match).
- tests/test_longform_parser.py — parametrized over the corpus + None-input +
ReDoS-linearity (5000× repeats < 1 s).
130 passed (corpus + test_audiobook + test_pause_markers + test_ssml_lite all
green); CJK guard green.
docs(persona): document the .ovsvoice portable format (#29 slice D) (#464)
- docs/persona-format.md: export (privacy/include-reference, watermarked
preview), import (consent/verification non-forgeability rule), the ZIP layout
table, SPDX-license semantics (metadata only), and the local-first / zero-
network guarantee. Notes legacy .omnivoice compatibility.
- CHANGELOG.md: [Unreleased] → Added entry for portable personas.
Satisfies the docs-sync hard rule for the new bundle format.
feat(persona): export/import UI for .ovsvoice bundles (#29 slice C) (#462)
* feat(persona): .ovsvoice build/parse core + embed_watermark(force=) (#29 slice A)
Extends the merged persona-bundle nucleus (constants, normalize_spdx,
build_manifest, build_consent_json) with the model-coupled core that the
export/import router (next slice) will sit on:
- `build_persona_bundle(profile, *, license_spdx, tags, include_reference,
embed_fn, …)` → assembles the .ovsvoice ZIP in memory: a watermarked
preview.wav (24 kHz mono 16-bit, downmixed + resampled + trimmed ≤8 s),
manifest.json, a legacy-shaped metadata.json (so an older OmniVoice can still
import the ref audio), optional consent.json, and the raw ref/locked/consent
members unless include_reference=False (privacy / preview-only, A12). Raises
NoPreviewSource (router → 503) when no source clip is readable (A2-A5).
- `parse_persona_bundle(bytes)` → validates the ZIP, prefers manifest.json and
falls back to legacy metadata.json, resolves audio members by prefix
(last-wins, B9; member names never build paths — zip-slip safe), normalizes
the SPDX id, flags preview-only / future-schema_version. Raises
BundleError(400|413) for B1-B11. No DB, no file writes.
- `ParsedPersona` dataclass with `extract_member(prefix, dest_path)` — the
router derives dest_path from the server-generated id, never the member name.
- `embed_watermark(..., *, force=False)`: keyword-only flag that bypasses the
user's invisible-watermark preference for the mandatory persona preview, but
still no-ops without AudioSeal. All existing positional call sites are
unchanged (default force=False) — default cross-platform behaviour identical.
All heavy imports (torch/torchaudio/watermark/audio_io) are lazy so the module
stays model-free at collection (avoids the local torch/Triton segfault).
tests/test_persona_bundle.py: +31 cases — parse validation (manifest/legacy
selection, preview-only, future-schema, missing/malformed/no-audio → 400,
oversize → 413, bad-SPDX normalize, last-wins dup, advisory consent), build
round-trip (identity fields, metadata sibling, no-source → NoPreviewSource,
include_reference=False, stereo/off-rate downmix+resample), and the force=
unit (D1/D3). 25 pure cases pass locally; the 6 torchaudio-coupled cases run on
CI (local torch+pytest segfault is pre-existing). CJK guard green.
* feat(persona): /personas export·import·inspect router + wiring (#29 slice B)
Thin HTTP layer over the persona_bundle service (slice A), registered in main.py
next to the legacy marketplace router:
- POST /personas/export/{id} → builds the .ovsvoice off the event loop
(run_in_executor) and streams it (application/zip, .ovsvoice filename;
empty name → persona_<id>). 404 when the profile is missing;
NoPreviewSource → 503 (no readable source audio); any other build error → 503
with a generic message (no raw exception text in the body).
- POST /personas/import → parse (BundleError → its HTTP status), extract audio
members to server-named files ({id}{ext}/{id}_locked{ext}/{id}_consent{ext} —
never the member name, zip-slip safe via profiles._voices_path), 17-column
INSERT (legacy 13 + the 4 consent columns), event_bus emit after commit.
Verified-own-voice is granted ONLY with a real recording ≥ floor AND non-empty
consent_text AND consent.json present (forgery guard, B12-B16). Rollback:
every written file is deleted on any extraction/INSERT failure; id-collision
retries once (renaming the on-disk files to the new id). Accepts legacy
.omnivoice too (case-insensitive extension guard).
- POST /personas/inspect → manifest + consent summary with NO DB row and NO
file extracted (import-preview UI).
backend/tests/test_personas_api.py: 13 cases (config-stub pattern → mounts only
the router, no main/torch import) — export 404; import bad-ext/non-zip/missing-
manifest 400; round-trip row+file under server name; case-insensitive ext;
forgery-unverified; verified-with-recording; short-recording-unverified;
preview-only-as-ref; legacy .omnivoice; inspect no-write + consent summary.
13 passed locally. CJK guard green.
* feat(persona): export/import UI for .ovsvoice bundles (#29 slice C)
Wires the persona endpoints (slice B) into the voice UI:
- api/profiles.ts: exportPersona (blob download, builds the license/tags/
include_reference query), importPersona, inspectPersona; PersonaImportResult
+ PersonaBundleMeta types in types.ts.
- VoiceProfile.jsx: "Export persona" toolbar action + a privacy "Include voice
clip" checkbox (default ON; off → preview-only bundle, no raw reference clip).
Triggers a blob download named <voice>.ovsvoice; distinct toast for the 503
no-audio case vs a generic failure.
- VoiceGallery.jsx (My Imports): an Import-persona button next to Upload, accept
".ovsvoice,.omnivoice", that POSTs to /personas/import and refreshes the
voice list. Surfaces the 413 too-large case distinctly; flags an unverified
import in the success message.
- i18n: voice_profile.persona_* + gallery.persona_*/import_persona keys in
en.json only (fallbackLng=en covers other locales).
Tests: frontend/src/api/profiles.persona.test.ts (7 cases — export query
construction incl. include_reference omitted-when-true, non-ok → throws status,
blob passthrough; import/inspect post FormData to the right path). Full suite
408 passing; en.json valid; CJK guard green. No new tsc errors in the changed
files (pre-existing errors elsewhere are unaffected).
feat(persona): /personas export·import·inspect router + wiring (#29 slice B) (#461)
* feat(persona): .ovsvoice build/parse core + embed_watermark(force=) (#29 slice A)
Extends the merged persona-bundle nucleus (constants, normalize_spdx,
build_manifest, build_consent_json) with the model-coupled core that the
export/import router (next slice) will sit on:
- `build_persona_bundle(profile, *, license_spdx, tags, include_reference,
embed_fn, …)` → assembles the .ovsvoice ZIP in memory: a watermarked
preview.wav (24 kHz mono 16-bit, downmixed + resampled + trimmed ≤8 s),
manifest.json, a legacy-shaped metadata.json (so an older OmniVoice can still
import the ref audio), optional consent.json, and the raw ref/locked/consent
members unless include_reference=False (privacy / preview-only, A12). Raises
NoPreviewSource (router → 503) when no source clip is readable (A2-A5).
- `parse_persona_bundle(bytes)` → validates the ZIP, prefers manifest.json and
falls back to legacy metadata.json, resolves audio members by prefix
(last-wins, B9; member names never build paths — zip-slip safe), normalizes
the SPDX id, flags preview-only / future-schema_version. Raises
BundleError(400|413) for B1-B11. No DB, no file writes.
- `ParsedPersona` dataclass with `extract_member(prefix, dest_path)` — the
router derives dest_path from the server-generated id, never the member name.
- `embed_watermark(..., *, force=False)`: keyword-only flag that bypasses the
user's invisible-watermark preference for the mandatory persona preview, but
still no-ops without AudioSeal. All existing positional call sites are
unchanged (default force=False) — default cross-platform behaviour identical.
All heavy imports (torch/torchaudio/watermark/audio_io) are lazy so the module
stays model-free at collection (avoids the local torch/Triton segfault).
tests/test_persona_bundle.py: +31 cases — parse validation (manifest/legacy
selection, preview-only, future-schema, missing/malformed/no-audio → 400,
oversize → 413, bad-SPDX normalize, last-wins dup, advisory consent), build
round-trip (identity fields, metadata sibling, no-source → NoPreviewSource,
include_reference=False, stereo/off-rate downmix+resample), and the force=
unit (D1/D3). 25 pure cases pass locally; the 6 torchaudio-coupled cases run on
CI (local torch+pytest segfault is pre-existing). CJK guard green.
* feat(persona): /personas export·import·inspect router + wiring (#29 slice B)
Thin HTTP layer over the persona_bundle service (slice A), registered in main.py
next to the legacy marketplace router:
- POST /personas/export/{id} → builds the .ovsvoice off the event loop
(run_in_executor) and streams it (application/zip, .ovsvoice filename;
empty name → persona_<id>). 404 when the profile is missing;
NoPreviewSource → 503 (no readable source audio); any other build error → 503
with a generic message (no raw exception text in the body).
- POST /personas/import → parse (BundleError → its HTTP status), extract audio
members to server-named files ({id}{ext}/{id}_locked{ext}/{id}_consent{ext} —
never the member name, zip-slip safe via profiles._voices_path), 17-column
INSERT (legacy 13 + the 4 consent columns), event_bus emit after commit.
Verified-own-voice is granted ONLY with a real recording ≥ floor AND non-empty
consent_text AND consent.json present (forgery guard, B12-B16). Rollback:
every written file is deleted on any extraction/INSERT failure; id-collision
retries once (renaming the on-disk files to the new id). Accepts legacy
.omnivoice too (case-insensitive extension guard).
- POST /personas/inspect → manifest + consent summary with NO DB row and NO
file extracted (import-preview UI).
backend/tests/test_personas_api.py: 13 cases (config-stub pattern → mounts only
the router, no main/torch import) — export 404; import bad-ext/non-zip/missing-
manifest 400; round-trip row+file under server name; case-insensitive ext;
forgery-unverified; verified-with-recording; short-recording-unverified;
preview-only-as-ref; legacy .omnivoice; inspect no-write + consent summary.
13 passed locally. CJK guard green.
feat(persona): .ovsvoice build/parse core + embed_watermark(force=) (#29 slice A) (#460)
Extends the merged persona-bundle nucleus (constants, normalize_spdx,
build_manifest, build_consent_json) with the model-coupled core that the
export/import router (next slice) will sit on:
- `build_persona_bundle(profile, *, license_spdx, tags, include_reference,
embed_fn, …)` → assembles the .ovsvoice ZIP in memory: a watermarked
preview.wav (24 kHz mono 16-bit, downmixed + resampled + trimmed ≤8 s),
manifest.json, a legacy-shaped metadata.json (so an older OmniVoice can still
import the ref audio), optional consent.json, and the raw ref/locked/consent
members unless include_reference=False (privacy / preview-only, A12). Raises
NoPreviewSource (router → 503) when no source clip is readable (A2-A5).
- `parse_persona_bundle(bytes)` → validates the ZIP, prefers manifest.json and
falls back to legacy metadata.json, resolves audio members by prefix
(last-wins, B9; member names never build paths — zip-slip safe), normalizes
the SPDX id, flags preview-only / future-schema_version. Raises
BundleError(400|413) for B1-B11. No DB, no file writes.
- `ParsedPersona` dataclass with `extract_member(prefix, dest_path)` — the
router derives dest_path from the server-generated id, never the member name.
- `embed_watermark(..., *, force=False)`: keyword-only flag that bypasses the
user's invisible-watermark preference for the mandatory persona preview, but
still no-ops without AudioSeal. All existing positional call sites are
unchanged (default force=False) — default cross-platform behaviour identical.
All heavy imports (torch/torchaudio/watermark/audio_io) are lazy so the module
stays model-free at collection (avoids the local torch/Triton segfault).
tests/test_persona_bundle.py: +31 cases — parse validation (manifest/legacy
selection, preview-only, future-schema, missing/malformed/no-audio → 400,
oversize → 413, bad-SPDX normalize, last-wins dup, advisory consent), build
round-trip (identity fields, metadata sibling, no-source → NoPreviewSource,
include_reference=False, stereo/off-rate downmix+resample), and the force=
unit (D1/D3). 25 pure cases pass locally; the 6 torchaudio-coupled cases run on
CI (local torch+pytest segfault is pre-existing). CJK guard green.
feat(audiobook): PDF ingest for /audiobook/import (ebook-in core value) (#459)
The audiobook importer accepted .txt/.md/.epub but not PDF — the single most
common "ebook in" format. Add a pure `pdf_to_chapter_script(data)` that
extracts the text layer page-by-page and runs it through the existing
chapterizer, so PDFs land in the same `# Heading` + body grammar EPUB and
plaintext already produce (one front door onto the unchanged render pipeline).
- Dep: `pypdf>=4.0` — pure-Python, MIT, zero native deps, so PDF import behaves
identically on macOS/Windows/Linux (default-feature cross-platform rule).
EPUB + plaintext stay stdlib-only; only PDF needs a real parser.
- Robustness, surfaced as actionable 400s rather than silent empty imports:
corrupt file, password-protected (empty-password decrypt attempted first),
scanned/image-only (no text layer → clear "scanned PDF" message), and a
page-count ceiling. A single unparseable page is skipped, not fatal.
- Route: `.pdf` branch in audiobook_import; frontend accept filter +
api-client doc updated to `.txt,.md,.epub,.pdf`.
tests/test_longform_import.py: 5 PDF cases (extract+chapterize, no-marker
single chapter, corrupt, image-only, page-cap) using a hand-built in-memory
PDF — no PDF-authoring test dep, mirroring the in-memory-EPUB approach.
16 passed; frontend suite 401; CJK guard green.
feat(dub): wire second-pass timing QC into the dub editor UI (#458)
The Wave 3.3 QC backend was complete but unreachable from the UI: the
`POST /dub/qc/{job_id}` route (re-recognizes the dubbed audio, scores per-line
drift vs the target text, annotates segments with qc_drift/qc_flagged/
qc_recognized/qc_measured_start-end), the `dubQc()` API client, and the
DubSegmentRow "Verify" badge all existed — but nothing ever called the route,
so the badge never lit and the measured timings were never surfaced.
Add a "Verify dub timing" action to the dub editor header (shown once
dubStep === 'done'):
- Calls `dubQc(jobId, lang)` for the currently-previewed language.
- Merges the returned per-segment scores back onto dubSegments by id, so
flagged lines light their re-listen badge and carry the measured onsets.
- Toast summary: "{flagged} of {total} lines may need a re-listen", or a
clean-pass success when nothing drifted. Loading + error states handled;
non-destructive (generated text untouched).
i18n: dub.qc_btn / qc_running / qc_result / qc_clean / qc_failed in en.json
(fallbackLng=en covers other locales). Frontend suite green (401).
feat(capture): opt-in LLM refinement on REST /transcribe (parity with live dictation) (#457)
The live-dictation socket (capture_ws) already runs the final transcript
through the configured local LLM (disfluency/self-correction/punctuation
cleanup, Wave 2.1). The REST /transcribe endpoint — the MCP / CLI / file-upload
surface — only did the always-on hallucination-loop collapse, so agentic and
batch callers couldn't get the same cleaned output.
Add an opt-in `refine` form flag that runs the identical `maybe_refine`
pipeline off-thread:
- OFF by default → existing MCP/CLI callers keep raw-only output and pay no
LLM latency (backward-compatible).
- Honours the user's Settings → Dictation-refinement config and silently
passes through when no LLM backend is configured (cross-platform default
parity — identical no-op everywhere with no LLM).
- Raw `text` is always returned; `refined_text` is added only when the LLM
actually changed the text — same contract the socket emits.
tests/test_capture_refine.py: 13 cases — flag-off no-call, refined_text on
change, no-op/identical omission, and flag parsing. maybe_refine is patched at
its source module since the handler imports it lazily.
chore(issues): structured GitHub Issue Forms (bug / install / feature) + config (#456)
Replace the two flat markdown templates with validated YAML Issue Forms and a
chooser config, so reports arrive with the diagnostic fields triage actually
needs and "how do I…" traffic routes to chat instead.
- `bug_report.yml` — dup-search + latest-version checkboxes; required
what/repro/expected; OS / install-method / version / compute-device dropdowns
(incl. ROCm + XPU); active-engine; logs (render: text) with the diagnostic-
bundle + `--diagnose` tip up top.
- `install_problem.yml` — NEW, for the "first-run that just works" core value:
a failure-stage dropdown (launch / uv-bootstrap / model-download / engine-
install / first-synth), required error + OS/install/version, and a
network-conditions dropdown (proxy / restricted-region / offline) since
restricted networks are a known bootstrap failure mode.
- `feature_request.yml` — problem/solution/alternatives + an Area dropdown, with
a local-first/cross-platform constraints note so proposals fit.
- `config.yml` — `blank_issues_enabled: false`; contact links to Discord,
Discussions, and the private security policy.
Removes bug_report.md / feature_request.md (superseded). Forms validated (yaml
parse); SECURITY.md backs the security link; CJK guard green.
feat(longform): two-pass loudnorm measure orchestrator + wiring (#28 slice 2) (#455)
* feat(longform): two-pass loudnorm measure orchestrator + wiring (#28 slice 2)
Completes accurate ACX/podcast mastering end-to-end (builds on the pure builders
from #28 slice 1).
- `services/loudness.py` — `measure_loudness(ffmpeg, concat, preset, *, job_id)`:
runs ffmpeg's measure pass, parses the loudnorm JSON → MeasuredLoudness.
**Never raises** — skip / non-zero rc / rc None / asyncio.TimeoutError / spawn
OSError / empty or unparseable stderr / silent program all WARN + return None
→ single-pass fallback (a slow/broken measure degrades the master, never
aborts the render). Logs rc + a static message only, never the raw stderr
(path-safe / local-first). UTF-8 decode with replacement (Windows-cp safe).
- `_render_longform_sse` (audiobook.py): between the concat write and the mux,
when `loudness` is a known preset (acx/podcast; same `.lower()`/no-strip gate
as the builders) → emit a `mastering` event, measure, and pass `measured` into
`build_render_cmd` (two-pass apply; `None` → single-pass). `done` gains a
`loudness` block {preset, target_i, target_tp, two_pass, measured_i} ONLY for
a requested preset — off/None paths keep the byte-identical legacy `done`
shape. Both front doors (/audiobook + /longform/render) get it via the shared
generator. Chapter cache key is deliberately untouched (loudness-agnostic →
acx/off reuse the same cached WAVs; no re-render, no cache-layout break).
Tests: `test_loudness.py` (14 — happy fixture, skip-without-spawn for off/
unknown/whitespace/None, non-zero/None rc, timeout-not-propagated, OSError,
empty/unparseable stderr, non-UTF-8 stderr, job_id+argv forwarding) + 2 e2e
cases (mastering event + done.loudness present for acx; absent for off). Orch
tests run locally (stubbed run_ffmpeg, no torch); e2e on CI.
* fix(loudness): lazy-import run_ffmpeg so the measure stub survives sys.modules purges
test_loudness monkeypatched services.loudness.run_ffmpeg, but the route-shape
fresh_app fixture purges services.* from sys.modules, so under the full-suite
ordering the patch missed the re-imported module → real ffmpeg ran → 3 failures.
Lazy-import run_ffmpeg inside measure_loudness and patch it at its source
(services.ffmpeg_utils.run_ffmpeg) so the stub is always picked up at call time.
Verified by running the purging suite + test_loudness together (31 pass).
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat(persona): .ovsvoice manifest + SPDX + consent core (#29 / parity §R3 G1, pure) (#453)
The model-free nucleus of the portable .ovsvoice persona-bundle format: format
constants, SPDX normalization, and the manifest/consent builders — all pure (no
torch, no I/O), fully locally testable. The audio preview + ZIP pack/unpack +
watermark `force=` param + router + frontend are follow-on slices.
- Constants: OVSVOICE_FORMAT/SCHEMA_VERSION, MAX_BUNDLE_BYTES (100 MB),
DEFAULT_LICENSE (`LicenseRef-OmniVoice-Personal`), the SPDX allowlist.
- `normalize_spdx()` — membership + `LicenseRef-` prefix; junk/None/injection →
DEFAULT_LICENSE, never raises/400s. No regex over the SPDX string (CodeQL-clean).
- `build_manifest()` — mirrors the legacy `_bundle_metadata` persona fields into
the manifest + format discriminator + normalized license + tags + engine /
preview / members blocks. seed/vd_states pass through (None-safe; vd_states is
a JSON string, never re-parsed). `BundleError(status, detail)` for the router.
- `build_consent_json()` — designed-synthetic for `kind='design'`, self-recorded
for an attested clone, None when nothing to attest; `recorded_at` coerced.
Fields are advisory by design — real verification needs the actual consent
audio member, so verified-own-voice can't be forged by editing a manifest.
Tests: 14 cases (SPDX allowlist/prefix/junk/strip, manifest schema + field
mirror + None-passthrough + bad-license-normalized, consent design/clone/none/
coerce). Backend pytest green; CJK guard green.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(ui): app-shell scales via zoom and always fills the viewport — permanent black-band fix (#452)
Root cause: uiScale DEFAULTS to 1.3, so the shell's `width: calc(100vw/--ui-scale)`
+ `transform: scale(--ui-scale)` path is active for every user. On WebKitGTK
(the Linux webview) the transform wasn't magnifying the shrunk shell, so
`calc(100vw/1.3)` left ~⅓ of the window black — on EVERY view, by default.
(The earlier #445 fix addressed the responsive breakpoints, not this — wrong
layer.)
Permanent fix: scale via `zoom` and keep the shell at full `100vw × 100vh`
(drop the `calc(…/scale)` shrink + the `transform`):
- Chromium (mac/win): `zoom` magnifies AND fills (standard browser zoom — same
mechanism the bootstrap/wizard wrappers already use).
- WebKitGTK (Linux): `zoom` is a no-op → UI renders at 1.0× but the shell is a
plain 100vw×100vh element → it FILLS, no band. A missed magnification now
degrades to "unscaled but full", never "shrunk + black band".
Regression-proofed: `src/test/appShellScale.test.js` fails CI if anyone
reintroduces `width: calc(100vw/var(--ui-scale))` or
`transform: scale(var(--ui-scale))` on the shell, or drops the zoom/100vw/100vh
contract — so a future change can't silently bring the band back. The fix +
guard are documented inline in the `.app-container` rule.
Full vitest green (398, incl. the 3-case guard); typecheck:ci + vite build clean.
fix(realtime): probe auth-exempt /health, not gated /model/status (#450) (#451)
The cold-start health probe added in #439 used a raw fetch() to
/model/status. Raw fetch does not carry the LAN PIN / remote API-key
headers that apiFetch attaches, and /model/status is not in the backend
_SHELL_PATHS allowlist, so it is gated by NetworkAccessMiddleware and
BearerKeyMiddleware. In LAN-share / remote-API mode the probe gets 401,
rejects forever, and the realtime-events WebSocket never opens.
Probe /health instead — the auth-exempt liveness endpoint (in
_SHELL_PATHS) that returns 200 as soon as Uvicorn is up. Using
apiUrl('/health') also avoids a double-slash when the API base has a
trailing slash. Default loopback desktop use is unaffected.
Adds a regression test asserting the probe targets /health (not a gated
path) and only opens the WebSocket after the probe succeeds.
Fixes#450
Co-authored-by: mergetest <hashduch@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
feat(longform): two-pass loudnorm builders + parser (#28 slice 1 — pure) (#449)
Groundwork for accurate ACX mastering: the pure, ffmpeg-free pieces of the
two-pass loudnorm upgrade, layered over the existing single-pass builders
(which stay). The async measure orchestrator + SSE wiring into the render path
is slice 2.
- `MeasuredLoudness` (frozen dataclass: the 5 measure-pass floats).
- `build_loudnorm_measure_filter(preset)` — first pass (+print_format=json);
mirrors build_loudnorm_filter's lookup (no strip) so the same inputs map to
"no filter".
- `parse_loudnorm_measure(stderr)` — extracts the LAST balanced {...} via a
linear brace-depth scan (NO regex → CodeQL-safe), json.loads + coerces the 5
keys to finite floats; returns None on the full failure matrix (absent/empty/
unbalanced/malformed/missing-key/non-numeric/non-finite "-inf"/array/scalar).
Rejecting "-inf" is the silent-clip path → single-pass fallback.
- `build_loudnorm_apply_filter(preset, measured)` — second pass feeding
measured_*/offset back in with linear=true; None for off/unknown OR measured
is None.
- `build_loudnorm_measure_cmd(ffmpeg, concat, filt)` — exact 16-element argv,
input segment byte-identical to build_render_cmd (measured == muxed),
portable `-f null -` sink (no /dev/null or NUL).
- `build_render_cmd` gains `measured: Optional[MeasuredLoudness] = None`: apply
two-pass when present, else single-pass; off-render still emits no -af. The
`measured=None` default keeps every existing caller + argv byte-identical.
Loudness stays opt-in (default None) → default cross-platform behavior unchanged.
Tests: 28 cases — measure-filter goldens + off/unknown/whitespace; parser
success (last-block-wins, ignores extra keys) + full failure matrix +
non-finite rejection; apply-filter golden + None cases; exact measure argv;
build_render_cmd two-pass/single-pass/off branches. Backend pytest green (71).
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat(longform): TranscriptionPicker + shared reader util (#23 slices 1–2) (#448)
Groundwork for "import from a past dictation": the shared store reader + the
reusable picker modal, fully unit/RTL-tested. The two-tab wiring (Audiobook
Replace/Append prompt + Stories split-panel routing) is slice 3 — deferred for
visual verification.
Slice 1 — shared reader (`utils/transcriptionsStore.js`):
- `loadTranscriptions()` (parse + Array.isArray guard, [] on
absent/empty/malformed/non-array/blocked-storage) + `TRANSCRIPTIONS_KEY` /
`TRANSCRIPTION_EVENT` consts. Kills the third copy of the localStorage parse.
- Refactored `Transcriptions.jsx` + `Projects.jsx` onto it (behavior-preserving;
the Array.isArray guard is a superset that only hardens against corrupt
blobs). Storage key/shape/200-cap unchanged → no migration.
Slice 2 — `components/TranscriptionPicker.jsx`:
- Controlled modal wrapping the shared `ui/Dialog` (Radix → focus trap, ESC,
backdrop, ARIA inherited). Reads on open, subscribes to the add-event only
while open. Per-row display normalization, hides empty-text rows, distinct
empty vs empty-search states, case-insensitive `String.includes` search (no
RegExp → no ReDoS surface), keyboard-activatable `<button>` rows, Invalid-Date
guard. `onPick` gets the original un-normalized entry. Every string via t().
Tests: util edge matrix (3) + picker RTL (7: empty, list+hide-empty,
click→onPick+onClose, keyboard rows, search filter + empty-search, bad-timestamp
chip omitted, live-refresh on event). Full vitest green; typecheck:ci clean;
CJK guard green (new files i18n-only).
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix: poll backend HTTP before WebSocket connect to avoid startup ECONNREFUSED (#439)
The frontend mounts faster than the Python backend (which takes ~14s to
import torch/fastapi before Uvicorn starts). useRealtimeEvents was
creating a WebSocket immediately, which always failed with code 1006
on the first attempt, triggering an unnecessary exponential-backoff
reconnect.
Fix: poll /model/status via HTTP fetch before creating the WebSocket.
Once the backend responds 200, proceed to open the WS. If the health
check fails, schedule a reconnect using the same backoff — but without
the noisy 'closed (code=1006)' log.
The /model/status endpoint is chosen because it's already polled by the
TanStack Query hooks and always returns 200 once Uvicorn is running,
even before models are loaded.
fix(ui): scale-aware shell breakpoints — no more cramped/black layout at narrow widths (#445)
The app shell is sized `width: calc(100vw / --ui-scale)` then `transform:
scale(--ui-scale)` (the WebKitGTK fix, #407), so its grid lays out against
`100vw / scale`. But the responsive collapse used viewport `@media (max-width)`
queries, which fire on raw `100vw` — so at any `--ui-scale ≠ 1` they trip at the
wrong threshold. In a narrow window the 3-column grid was kept, the sidebar's
`min 180px` crushed the main column toward 0, and the content ended up jammed
into a left sliver with a black band filling the rest.
Fix: drive the breakpoints off the shell's OWN width. A ResizeObserver on the
app-container reads `el.clientWidth` (= the pre-transform layout width =
100vw/scale; transforms don't change the layout box) and toggles `shell-narrow`
(≤1100) / `shell-mini` (≤600) classes; the `@media` queries become equivalent
`.app-container.shell-*` rules. Correct on every engine and at every UI scale.
Observer fires on both window resize and scale change (the calc width changes).
Needs a visual check in the running app at a couple of window sizes + UI scales.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat(longform): Story⇄Audiobook convert transforms (#24 slice 1 — pure utils) (#447)
The render-faithful interchange between the two long-form editors, as pure,
unit-tested functions (no UI/store yet — that's slice 2). The store seam for
this (convertMode/projectMode) already shipped in #31a.
- `storyToScript(tracks, cast, {projectName})` → `{script, defaultVoice,
metadata}`. Emits **profile-id** `[voice:]` tags (the backend resolver keys on
id, not display name) so the script renders identically through
/longform/render from either door. Most-used effective voice → defaultVoice
(no tag), deterministic earliest-occurrence tie-break; tags emitted only on
voice change; single-# un-indented headings; inline markup ([pause], SSML-lite,
emotion) passes through verbatim — never re-tokenized (no drift vs the backend
parser). Respects the three client/server divergences (heading depth,
[voice:default] semantics, [pause] dialect): it never synthesizes a pause and
never emits [voice:default].
- `scriptToStory(text, profiles)` → `{tracks, cast}` (persisted StoryTrack shape;
cast always ≥ a narrator clone). One physical line = one track; a leading
[voice:id] becomes the track override + a cast member (named from profiles or
the raw id, which is kept as profileId so it round-trips); mid-line markup +
body text preserved byte-for-byte; CRLF normalized; slug-collision-safe cast
ids; sequential numeric ids.
- No new regex over user input (leading-voice detection is string ops) —
CodeQL-clean; render output stays identical across both doors (the invariant).
Tests: 19 cases incl. the edge matrix + **round-trip equivalence** both
directions (script→story→script reproduces; story→script→story preserves spoken
text + voice mapping). Full vitest green; typecheck:ci clean; CJK guard green.
Deferred (slice 2, needs visual verify): the two UI buttons, store prefill
fields, mount read-clear effects, AppMode 'audiobook' fix, i18n.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat(audiobook): use shared VoiceSelector for the default-voice picker (#22 migration 1/N) (#446)
First call-site migration onto the shared <VoiceSelector> (#22): the Audiobook
default-voice <select> becomes the searchable, grouped picker. Value contract is
unchanged ('' = engine default | profileId), already store-bound (#31b), so no
behavior or data change — just search + clone/designed grouping. `defaultLabel`
preserves the existing "engine default" row label.
Stories cast / per-line track / Dub segment pickers are intricate live layouts
(custom select CSS, row composition) — deferred to follow-up migrations that can
be visually verified, rather than blind-swapped.
vitest green (357); typecheck:ci clean; vite build clean.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat(audiobook): persist book metadata/script/prefs via LongformProject store (#31b) (#444)
Audiobook's script, default voice, output format, loudness, book metadata
(title/author/narrator/genre/year/description) and pronunciation lexicon now
bind to the unified store (#31a) instead of component useState — so they
**survive a tab switch / reload** (previously all lost). The headline #31 win.
- text→script, defaultVoice, format→outputFormat, loudness, meta→setProjectMeta,
bound to store selectors. `meta` is default-filled so an empty record never
flips a controlled input to uncontrolled.
- Lexicon rows stay LOCAL (half-typed rows aren't junk-persisted); the filtered
dict flushes to the store on change and hydrates back into rows on mount.
- Transient state (plan, generating, progress, output, chapter previews) stays
component-local — correctly NOT persisted.
Deferred (noted): coverRef persistence (a File/blob can't go to localStorage);
the "Save as named project" affordance + Projects-list card + App `onOpenStory`
mode-aware routing (criterion 4 — re-open from Projects). This slice lands the
working-state persistence (criterion 3); save/reopen is the next slice.
Full frontend vitest green (357); typecheck:ci clean; CJK guard green.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat(ui): shared VoiceSelector component + SearchableSelect grouping (#22) (#442)
A single searchable, grouped voice picker to replace the per-tab <select>s
across Stories / Audiobook / Dub. This slice ships the COMPONENT + the two
backward-compatible SearchableSelect extensions it needs; the call-site
migrations are a follow-up slice (component lands first, tested in isolation).
- `SearchableSelect` gains two opt-in, back-compat props (the two existing
call sites are untouched, both render-identically):
- `renderGroupHeaders` (default false) — emits a `.ss-group-label` header on
the first MAIN row of each new `option.group` with a non-empty `groupLabel`
(pinned recent/popular rows never trigger one; empty groups never emit a
stray header).
- `isRecentable` (default `() => true`) — gates which committed values get
recorded as recents.
- `VoiceSelector` builds a group-ordered options array (default → fromVideo →
clone → designed → preset) over the EXISTING value contract
('' | id | preset:<id> | auto:<slug>) — byte-identical to what every call
site already sends, so project data stays compatible. Clone-vs-designed
splits on the runtime `.instruct` string (matching VoicePreview), not
`.kind`. Renders optional preview / gallery-jump / create adornments (the
component owns no audio and makes no API call — it only emits the value and
fires the parent's callbacks). A deleted-but-referenced voice renders a
"Voice not found (re-pick)" ghost row WITHOUT auto-clearing the value.
`isRecentable` excludes '' / preset: / auto: so only real voices are recents.
- i18n keys under `voiceSelector.*` (en.json; other locales fall back via
fallbackLng, matching the project's established pattern).
Tests: 9 RTL cases — grouping/headers, value contract for id/preset/auto,
from-video slug parity, ghost row (no auto-clear), recents guard (sentinels
excluded, real ids kept), preview button presence/value/loading. Full frontend
vitest green (362); typecheck:ci clean; CJK guard green.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat(store): unified LongformProject store + v4→v5 migration (#31a) (#443)
Introduces one project concept both long-form editors bind to: Stories
(cast+tracks) and Audiobook (raw script + book metadata), discriminated by a
`projectMode`. Store-only, no UI behavior change — Audiobook is not yet bound
(its inputs still use local state; that's the #31b follow-up). Ships the data
model + migration + the `convertMode` seam #24 will consume.
- `storiesSlice.ts` → `longformSlice.ts`: `StoryProject` → `LongformProject`
(gains mode/script/meta/lexicon/coverRef/outputFormat/loudness/defaultVoice);
new working fields + actions (setScript, setProjectMeta [merge], setLexicon
[replace], setOutputPrefs [merge], setCoverRef, convertMode). `loadProject`
restores the FULL surface default-filled (old records never surface undefined
to a controlled input); `newProject(mode?)` clears it. `SLICE_DEFAULTS` +
`genProjectId` exported (the migrate fn imports genProjectId). Deprecated
aliases (`StoryProject`/`StoriesSlice`/`createStoriesSlice`) re-exported so the
rename breaks no import.
- **Field names kept** (`storyProjects`/`storyTracks`/`cast`) so all 6 consumers
and every existing localStorage blob keep working with zero change — the
persisted KEY is unchanged; only the per-project SHAPE is enriched.
- The project-mode working field is named **`projectMode`**, NOT `mode` — `mode`
is already the app navigation field (uiSlice/AppMode); the spec's `mode` would
collide (TS error + duplicate partialize key). The stored
`LongformProject.mode` (nested) keeps its name.
- persist `version: 4 → 5` + a `version < 5` migrate branch (the localStorage
analog of an alembic upgrade): enriches each saved project with defaults
(spread `...sp` last so id/name/cast/tracks/updatedAt win), drops malformed
entries, never throws. v4 users see the same projects, same names/cast/tracks.
Tests: ported the back-compat suite (Stories unchanged) + new coverage —
default-fill on a v4-shaped record, no-stale-carryover, merge-vs-replace
semantics, convertMode idempotency/guard, snapshot+restore of the new fields.
Full frontend vitest green (357); typecheck:ci clean; CJK guard green (new
slice scanned). No app version-file change (the persist version is the
localStorage schema, not the release).
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat(routing): synth-time no-silent-fallback gating at all TTS entry points (#21 follow-up) (#440)
Closes the last #21 gap: a per-request engine=/model= override bypasses the
/engines/select host-gate, so an engine that can't use this host's GPU could
still be triggered at synth time and silently fall back to CPU (or die mid-
synth). Now enforced at every TTS synth entry point, reusing the SAME probe +
resolver — never re-deriving routing.
Shared helpers (services/engine_routing.py):
- `routing_notice(result)` → (status, reason) to surface, or None. Fires for
cpu_fallback (always) and accelerated-with-caveat (driver/arch); silent for
cpu_only / clean-accelerated / n/a.
- `header_safe_reason(reason)` → scrubbed + ASCII-sanitized (headers are
latin-1; a non-ASCII device name would 500 otherwise) + ≤256 chars. No regex.
Entry points:
- REST `POST /generate` (generation.py): after engine resolution, resolve
routing once; `unavailable` → 400; cpu_fallback / accelerated-caveat → 200 +
`X-OmniVoice-Routing` + `X-OmniVoice-Routing-Reason` headers on the WAV
StreamingResponse; benign → no headers. Covers OmniVoice + adapter branches.
- OpenAI-compat `POST /v1/audio/speech` (openai_compat.py): same gate + same
headers; the tts-1/tts-1-hd alias inherits the active engine's routing.
- WebSocket `/ws/tts` (tts_stream.py): no headers → frames. `unavailable` →
`{"type":"error",...}` + skip stream; cpu_fallback / caveat → one
`{"type":"routing","status","reason"}` frame before any audio.
- `select_engine` response now echoes routing_status / effective_device /
routing_reason (PR #432 added the gate; this adds the fields so the UI can
warn on a cpu_fallback pick). New fields on SelectEngineResponse.
Frontend: `useTTS` reads the X-OmniVoice-Routing header and shows a one-time,
non-blocking toast (in-memory de-dup by status — a 50-clip batch fires once,
no localStorage). i18n keys `tts.routingFallback`/`tts.routingCaveat`.
Tests: routing_notice + header_safe_reason (ASCII/length/scrub) unit tests;
REST synth gate (unavailable→400, cpu_fallback→headers, cpu_only→none) via the
fake-engine harness with a mocked host; select response routing fields.
Deferred (small follow-up): dub-pipeline ASR routing note on the preflight_error
SSE channel — separate path, not a TTS synth entry point. No frontend /ws/tts
client exists today (the routing frame serves external API consumers).
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
test(longform): real-ffmpeg + stub-TTS e2e for the chapterized renderer (#34) (#441)
#34 runtime-verify Layer 1 — the cheap regression net over the audiobook /
stories convergence. Drives the REAL `_render_longform_sse` generator + REAL
ffmpeg with a stub CPU-tone synth (no GPU/model), and ffprobes the muxed output.
Covers happy m4b (full SSE sequence + 2 tagged chapters), mp3 container,
per-chapter partial failure (chapter_error isolates ch.0, surviving chapter
still muxes), total failure (error + NO file), empty plan, and the no-ffmpeg
branch. Gated on ffmpeg present (skip otherwise; runs in CI).
Like the other endpoint tests it imports the app+torch stack, so it's validated
on CI (local pytest segfaults on the pre-existing torch/Triton import).
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat(ui): Engine Compatibility Matrix routing display (#21 PR 5/5) (#434)
Surfaces the /engines routing data (PR 3) in the matrix so users see the
device each engine will actually use on THIS machine.
- The chip matching `effective_device` is highlighted (accent ring + bold),
with a "Runs on X on this machine" tooltip.
- A status-toned routing badge: accelerated→success "GPU active",
cpu_fallback→warn "CPU fallback" (reason in tooltip), cpu_only→neutral
"CPU". The badge is SUPPRESSED for unavailable rows (the availability badge
already says so) and for legacy payloads with no routing_status (renders
exactly as before). An unknown/future status falls back to a neutral
"Unknown" badge.
- LLM rows (routing 'n/a') render a single neutral "Remote" badge instead of
device chips — no false GPU claim.
- types.ts: EngineBackend gains effective_device / routing_status /
routing_reason; GPUTarget gains `xpu`; new EffectiveDevice + RoutingStatus
unions. Corrected the stale "only TTS migrated" comment (all 3 families now
emit the full shape).
- i18n keys in en.json (other locales fall back to en via fallbackLng until
translated — no key-parity gate). xpu chip color in the matrix CSS.
Tests: 5 new RTL cases (accelerated highlight+badge, cpu_fallback badge,
unavailable suppression, legacy no-badge, LLM Remote). Full frontend vitest
green (350); typecheck:ci clean.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat(routing): active-engine GPU verdict in preflight + diagnose (#21 PR 4/5) (#433)
Surfaces a routing verdict for the CURRENTLY-SELECTED TTS engine in the two
system-health surfaces, so a CPU fallback / unavailable-GPU is heard about
before a slow or failed synth — the no-silent-fallback contract, read-only.
- `tts_backend.active_routing()` + `gpu_routing_verdict()`: the active engine's
routing derived from list_backends() (byte-identical to the matrix) plus the
host compute summary (family + VRAM from the canonical probe). Never raise.
- `/system/diagnose` gains a `gpu_routing` check: accelerated→ok,
accelerated-with-caveat / cpu_fallback→warn (+ actionable hint), cpu_only→ok
(no-GPU host is the expected normal state — never noise-warns), unavailable→
fail, no-engine→warn. ASCII-safe detail strings (the text dump enforces ASCII).
- `/setup/preflight` gains an "Active engine routing" check + an explicit
`gpu_routing` object on PreflightResponse (a real field — the response has no
extra="allow", so it would otherwise be dropped). `device` gains `gpu_family`
(ROCm-vs-CUDA aware) + `vram_gb`. New `GpuRouting` schema.
Tests: gpu_routing_verdict (host + active-engine + degraded), diagnose status
mapping across all 6 states + never-raises, preflight gpu_routing object +
check + device.gpu_family. Existing diagnose/preflight tests stay green (checks
are additive; the report's top-level key set is unchanged).
Deferred (documented): synth-time routing headers/WS-frames at the 3 synth
entry points. Selection is already hard-gated (PR 3 select_engine), and the
matrix (PR 5) + this preflight/diagnose verdict surface the situation — the
synth-time signal is incremental belt-and-suspenders for the env-var-pinned
edge and is best validated interactively. Tracked as a #21 follow-up.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat(routing): wire effective-device into /engines + select gate (#21 PR 3/5) (#432)
* feat(routing): wire effective-device + routing_status into /engines (#21 PR 3/5)
Surfaces the PR-1 probe + resolver through the engine registries so the
matrix UI (PR 5) and the no-silent-fallback gates can consume it.
- `engine_routing.routing_fields()`: shared helper returning the three
serialization-ready keys, centralizing the scrub rule — routing_reason is
scrubbed via `core.scrub.scrub_text` only when truthy, so a None reason
stays JSON `null` (never coerced to "").
- TTS/ASR `list_backends()` each gain `effective_device` / `routing_status` /
`routing_reason`, computed from a SINGLE `detect_host_caps()` call per
request (host caps are constant per process). ASR is brought to full TTS
parity: it now also carries `install_hint` / `last_error` / `isolation_mode`
and a SCRUBBED `reason` (closing a pre-existing ASR token-leak gap) — an
identical 11-key shape across families. ASR also gains the same
is_available()-raises resilience TTS has (degrade to available:false, never
500).
- LLM `list_backends()` reaches 11-key parity too but emits literal
`effective_device:"network"` / `routing_status:"n/a"` / `routing_reason:null`
(NOT via resolve_routing — LLM runs no local GPU model). `LLMBackend.gpu_compat
= ()`. "network" is a label, not a probe — nothing here touches the network.
- `select_engine` host-routing gate: refuses a pick whose `routing_status` is
`unavailable` on this host (400 with an actionable detail), while ALLOWING
`cpu_fallback` (it runs, just slower). LLM is never gated. Defensive `.get`
so legacy payloads still select. New typed `SelectEngineResponse`.
Tests: 11-key shape across all 3 families, well-formed tts/asr routing keys
(+ None-not-"" contract), LLM network/n/a labels, select gate (block
unavailable / allow cpu_fallback / never-gate LLM). Updated the registry
exact-shape test for the 3 new keys.
* test(cjk): allowlist docs/specs/ in the hardcoded-CJK guard
PR #429 merged the longform design specs, which legitimately quote functional
CJK (test-fixture descriptions, CosyVoice speaker IDs, multilingual sample
text). The CJK guard scans every tracked file, so those docs turned main red.
Specs are documentation, not shipped UI strings — allowlist the docs/specs/
prefix, matching the individually-allowlisted docs already in the set.
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
docs(longform): implementation specs for the 14 roadmap tasks (#21–#34) (#429)
* docs(longform): implementation specs for the 14 roadmap/integration tasks (#21–#34)
Per-task implementation specs under docs/specs/longform/ for the remaining
longform + #346-roadmap work: GPU compat matrix, shared VoiceSelector,
Transcriptions import, Story⇄Audiobook export, inline Create Voice, gallery
handoff, parser unification, two-pass ACX, .ovsvoice format, Dub→Stories,
unified LongformProject store, phone calls, cue-sheet, runtime-verify.
Authored by a draft + iterative-refinement workflow (codebase-grounded: exact
file:line anchors, API/data shapes, test plans, constraints, deps, risk, PR
slices). NOTE: the 10-round refinement was cut to ~rounds 4–5 by an account
session limit; rounds 5–10 (incl. the final de-bloat/polish pass) are pending —
the specs carry per-round revision-note preambles that the polish round trims.
* docs(longform): strip accreted (this-revision) note preambles from specs
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(engines): MLX platform gate (#390) + ASR gpu_compat + IndexTTS2 (#21 PR 2/5) (#431)
Builds on the device probe from PR 1. Backend-only; the routing keys are
wired into /engines in PR 3.
- #390 closed: MLXAudioBackend / MLXWhisperBackend now call the shared
`core.device_caps.mlx_supported()` gate FIRST, before importing the
package. On Linux/Windows/mac-Intel they report unavailable and never
advertise a usable `mps` route, even with a stray mlx wheel installed.
Replaces the ASR backend's ad-hoc inline MPS check with the one shared
rule. (The Wave-4.4 OSError/RuntimeError import-guard is preserved — it
now lives behind the platform gate; its test forces the gate open so the
guard stays the path under test.)
- `ASRBackend` ABC gains `gpu_compat: tuple[str, ...] = ("cpu",)` mirroring
TTSBackend, and each subclass declares its real targets:
whisperx/faster-whisper → (cuda,cpu); mlx-whisper → (mps,cpu);
pytorch-whisper → (cuda,mps,cpu); nemo/funasr → (cuda,cpu);
moonshine → (cpu,). Inert until PR 3 serializes them.
- IndexTTS2 declares `gpu_compat = ("cuda","cpu")` so it stops advertising
the inherited CPU-only default.
- ROCm is deliberately NOT claimed for any ASR engine (or for IndexTTS2):
CTranslate2 has no upstream HIP build, and an unverified `rocm` claim
would route ROCm hosts to a broken GPU path — strictly worse than the
honest `cpu_fallback` the resolver already emits ("declares CUDA only;
ROCm not in its compat set"). The per-engine TTS ROCm audit is a tracked
follow-up that will verify each path before claiming it.
Tests: MLX gate regression (both backends, on/off Apple), ASR gpu_compat
tuples + no-false-rocm invariant, IndexTTS2 override; existing MLX
import-guard test updated for the new gate ordering.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
refactor(longform): share the SSE stream consumer across Stories + Audiobook (#436)
Stories and Audiobook are two authoring frontends over one server-side
renderer (_render_longform_sse), emitting the same chapter-progress events.
Both hand-rolled the identical read/decode/splitSSEBuffer/parseSSELine loop.
Extract utils/longformStream.consumeLongformStream(res, onEvent, {isAborted}):
one place owns the SSE protocol; each editor keeps only its own per-event state
handling (Stories: export %; Audiobook: {current,total,title,assembling,done}).
Behaviour unchanged — Audiobook keeps its abort check via isAborted.
The rest of the two editors stay distinct on purpose (cast/dialogue vs
manuscript/EPUB authoring), per docs/specs/2026-06-13-stories-audiobook-maturity.
Tests: frontend/src/test/longformStream.test.js (chunk-boundary parsing, abort,
no-body). Full vitest: 348 passed; typecheck:ci clean.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat(dub): dedicated Dub home (projects/history) + project rename (#435)
The dub Projects + History rail (WorkspaceProjects/WorkspaceHistory) used to
sit beside the editor at all times. Now it's a landing: shown only when no
project is being edited (dubStep === 'idle'); opening/creating one switches to
a full-width editor. (The global Sidebar is already hidden in dub mode, so the
studio-right rail is the only surface — no Sidebar change needed.)
Adds project rename:
- backend: PATCH /projects/{id} updates just the name (400 on empty, 404 on
missing) — lighter than PUT which rewrites the whole state blob.
- api: renameProject(id, name); App.jsx renameProject handler (updates the
active-project label + refreshes the list).
- UI: inline rename on each project card (pencil → edit → Enter/Save / Esc).
Verified: PATCH create→rename→list / 400 / 404; frontend typecheck:ci clean.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat(routing): host device probe + routing resolver (#21 PR 1/5) (#430)
* feat(routing): canonical host device probe + routing resolver (#21 PR 1/5)
Foundational, backend-only slice of the GPU compatibility matrix (#21).
No API or UI change — wiring lands in PRs 3–5.
- `core/device_caps.py`: single source of truth for host accelerator
capability. `detect_host_caps()` distinguishes ROCm from CUDA (unlike
the gguf hardware_probe), never raises, makes no network call, stays
kernel-free on cold start, and caches per process. Enumerates the full
degradation contract (torch-unimportable→probe_ok=False, CUDA-init
raises, device_count==0, multi-GPU, mem_get_info failure, arch
mismatch, MPS, XPU, DirectML). Plus shared `mlx_supported()` gate
(#390 groundwork) — exact-string platform check, no regex.
- `services/engine_routing.py`: pure `resolve_routing(gpu_compat, caps)`
→ `{effective_device, routing_status, routing_reason}`; deterministic
and byte-identical across OSes. Rules for accelerated / cpu_fallback
(the no-silent-fallback signal) / cpu_only / unavailable, incl. the
ROCm-not-in-set, DirectML-neutral, and XPU edges.
- `get_best_device()` delegates its family decision to the probe so the
loader and probe can never disagree; keeps the ROCm HSA env override
and DirectML device-string return (probe reads, loader writes). String
contract unchanged.
- 39 unit tests (probe / resolver / mlx gate / reason-scrub contract);
no new regex (CodeQL-clean), English-only (CJK guard green).
The gguf hardware_probe rebase is a deliberate follow-up: it has its own
torch-mocked suite and a VRAM-driven quant table unaffected by the family
rename, so it stays out of this zero-risk slice.
* fix(routing): address review — full available_families + empty-except comments
CodeRabbit / CodeQL review on PR 1:
- `available_families` no longer drops secondary accelerators on hybrid hosts
(e.g. NVIDIA + Intel-iGPU-via-IPEX). The probe now detects every accelerator
independently and picks `family` by priority at the end, instead of
short-circuiting after the first hit. Routing is unaffected (it keys off
`family`), but the field is now honest. + hybrid-host test.
- Annotated every `except: pass` in device_caps with an explanatory comment
(CodeQL py/empty-except).
- Removed the unused `_MIN_NVIDIA_DRIVER` constant — the driver-version check
stays in wizard preflight (no subprocess on the probe path); documented why.
- `get_best_device()` now checks MPS before DirectML, mirroring the probe's
family-priority order so loader and probe never disagree.
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
refactor(models): model-management v2 cleanup (mm2, all tiers) (#428)
One coherent lifecycle surface over the in-process model, diarization, and
subprocess sidecars; fixes the engine-switch VRAM leak; tightens download
robustness. Backend-only, response shapes preserved, no new deps.
Tier 1 — correctness:
- MM2-01: get_active_tts_backend() caches one instance per backend id and
unload()s the outgoing engine on switch (fixes the VRAM leak behind #278);
adds reset_active_backend().
- MM2-02: OmniVoiceBackend.unload() releases the shared model_manager singleton
+ free_vram(); SubprocessBackend.unload() -> unload_sidecar(self.id),
inherited by all sidecar engines. Idempotent + preload-safe.
- MM2-03: /model/loaded ASR row reports the real device + a note explaining the
disabled unload button.
Tier 2 — single surface:
- MM2-04: new services/model_lifecycle.py owns list_loaded/unload/unload_all/
free_vram; system.py routers are thin delegations (shapes unchanged).
- MM2-05: idle timeouts (in-process + sidecar) resolve via prefs.resolve
(env wins, no restart); removed the duplicated _IDLE_TIMEOUT_SECONDS.
Tier 3 — robustness/observability:
- MM2-06: _install_cooldowns swept (1h TTL) + cleared on success — bounded.
- MM2-07: per-extension weight floors (onnx 64KB, tensors 5MB) OR the original
>=5MB catch — small ONNX no longer false-flagged, #352 still caught.
- MM2-08: indextts GPU sidecar self-reports vram_mb in pong; parent surfaces it
in list_live_sidecars (0 = CPU/unmeasured).
- MM2-09: is_cached scan_cache_dir->disk fallback logs WARNING w/ exc type
(#117/#118), was invisible at DEBUG.
Tests: tests/test_mm2_lifecycle.py (15). Full suite: 1379 passed.
Plan/summary: .planning/quick/260613-mm2-clean-model-management-v2/.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Fast model downloads: Xet fast path + accurate progress (FDL W0–W2 + W4) (#424)
* feat(downloads): Xet fast path + accurate progress (FDL W0–W2)
Make model downloads fast and show accurate downloaded/remaining/speed.
Research confirmed hf-xet already implements the IDM/uGet technique
(content-defined chunking, parallel byte-range gets, dedup, resume), and
the spike found all 25 catalog repos are Xet-backed — so the win is
driving Xet well + accurate progress, not a custom downloader.
W1 — maximize + guarantee Xet:
- pin huggingface_hub>=1.7 + hf-xet>=1.1 (was transitive); no hf_transfer
- drive snapshot_download with explicit tqdm_class + max_workers + endpoint
- opt-in HF_XET_HIGH_PERFORMANCE / HDD sequential-write knobs (default off)
- /system/info reports fast_download {xet_enabled, xet_version, high_perf}
W2 — accurate progress:
- dry_run preflight -> install_plan event (exact total/cached/remaining)
- utils/download_aggregator.py: one overall bar; byte bars (by id) vs the
"Fetching N files" count bar; windowed rate; emits one 'aggregate' event
- frontend overall bar (speed/remaining/ETA), cached-skip, ⚡ fast badge
Known limit (verified live): under Xet+hf_hub 1.7.2 per-file byte bars
never advance/close via tqdm, so mid-download the bar is file-granular and
bytes flush to the exact total on completion. Classic-LFS/mirror repos get
true byte progress (W4).
Drive-by: download.py used os.walk without importing os (latent NameError
in _validate_snapshot_has_weights on every install) — fixed.
Tests: tests/backend/setup/test_download_preflight.py (10). Spike + plan
under .planning/quick/260613-fdl-fast-model-downloads/.
* feat(downloads): opt-in mirror + cancel + docs (FDL W4)
- mirror (FDL-10): snapshot_download(endpoint=) honours prefs hf_endpoint /
env HF_ENDPOINT on preflight + download (per-call, no process-wide env).
Documented as the classic-LFS path (no Xet) for restricted networks.
- cancel (FDL-11): POST /models/install/cancel {repo_id} stops further
retries at the next boundary, emits install_cancelled, clears the cooldown
(cancel is intent, not failure). Frontend treats it as a terminator.
- docs (FDL-12): docs/downloading-models.md (Xet fast path, progress
semantics + byte-speed limitation, opt-in tuning, mirror, cancel,
troubleshooting) + README pointer. Docs-sync rule satisfied.
* docs(planning): model-management v2 cleanup plan (mm2)
GSD plan for cleaning the model-management subsystem: registry unload-on-
switch + per-engine unload() (fixes VRAM leak), model_lifecycle facade,
unified idle/timeout config, bounded cooldowns, sidecar VRAM self-report,
cache-fallback logging. Planning artifact only — no code.
* fix(downloads): reconcile with main's HF_HUB_DISABLE_XET; honest status
Rebasing onto main surfaced that main forces HF_HUB_DISABLE_XET=1 (classic
LFS) because Xet progress bypasses the tqdm hook — the same limitation found
here. Reconcile instead of fight:
- /system/info fast_download now reports runtime truth: xet_installed +
xet_active (installed AND not HF_HUB_DISABLE_XET) + xet_enabled alias. The
⚡ badge only shows when Xet actually runs; startup log says
"downloads: Xet disabled → legacy LFS".
- complete(): clear the rate window before the final flush so crediting the
full size in one step can't emit an absurd instantaneous rate.
- docs/downloading-models.md rewritten: default is legacy LFS for accurate
progress; Xet is opt-in via HF_HUB_DISABLE_XET=0. hf-xet pin stays (ready
for a future Xet progress hook).
W2 (preflight total/remaining + aggregate bar + exact completion) is the
value on either path; W1's "maximize Xet" is dormant by main's design.
* feat(downloads): opt-in segmented multi-connection accelerator (FDL W3)
Since main forces Xet off (HF_HUB_DISABLE_XET=1), the default path is
single-stream legacy LFS — so a segmented downloader is the way to get BOTH
parallel speed and live byte progress.
- services/segmented_download.py: async multi-connection Range downloader for
one file — parallel byte-ranges, resume (.part + manifest), per-segment
short-read truncation guard, optional sha256/etag verify, cancel, and a
single-stream fallback when the server won't range. Auth-safe: the HF
Authorization header is sent only to huggingface.co/hf.co and never
forwarded to a CDN host on redirect (unit-tested).
- dispatch (download.py): opt-in via prefs segmented_downloader / env
OMNIVOICE_SEGMENTED_DOWNLOAD (default off). When on and Xet inactive,
fetches each file into the HF cache mirroring hf_hub_download (blobs +
snapshot symlinks + refs/main), feeding real bytes to the aggregator. Any
failure falls back to snapshot_download — never breaks a correct install.
- fix: complete() was adding a full total on top of accumulated segmented
bytes (2x); now replaces byte bars so the sum is exactly total.
Verified live (accelerator on): real byte progress to ~16.6 MB/s, final
bytes==total, /models installed=True, delete frees correctly.
Tests: test_segmented_download.py (7) + aggregator double-count regression.
* test(downloads): relocate FDL tests to top-level; loop-isolate segmented test
CI runs the full suite, which exposed a pre-existing test-isolation leak:
several tests/backend/** fixtures purge core.*/services.* from sys.modules
under a temp OMNIVOICE_DATA_DIR and never restore, leaving core.config/core.db
bound to a dead temp dir. It only bites when collection order puts a purging
test ahead of a real-DB reader (test_longform_jobs). Adding tests under
tests/backend/setup/ reordered collection and tripped it.
Fix without touching the shared (fragile) fixtures or risking class-identity
breakage from a blanket sys.modules restore:
- move the two FDL test files to top-level tests/ (tests/test_fdl_*.py) so
tests/backend/** collection order is identical to main — longform passes.
- rewrite the segmented test to run each case under asyncio.run() (fresh loop)
instead of asyncio.get_event_loop(), which an earlier async test can leave
closed in the full suite.
Full suite green locally: 1364 passed, 0 failed.
---------
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat(longform): cohesion quick-wins — Audiobook launchpad card + Stories in Projects (#426)
* fix(ui): align Audiobook + Stories controls to the design tokens
The hand-written tabs used a bare `.btn` class (which has NO CSS rule → bright
white browser-default buttons) and an unstyled `.field-label`, so the buttons,
labels, and selects looked off-theme. (Other tabs use the Button/ui-btn system,
which is why only these looked wrong.) Found via a design-token audit workflow.
AudiobookTab:
- Import / Preview plan / Add word / Add cover / Download → `ui-btn ui-btn--subtle`;
Create → `ui-btn ui-btn--primary`; cover-remove / lexicon-remove / chapter-play
→ `ui-btn ui-btn--icon` (the app's themed button variants from ui/Button.css).
- AudiobookTab.css: define `.audiobook-tab .field-label` (chrome mono/uppercase
via --chrome-* tokens) + header serif title / muted subtitle (--font-serif,
--text-xl, --color-fg/-muted). Selects/inputs already used `.input-base` (the
canonical chrome look) — left as-is.
StoriesEditor:
- Format `<select>` now uses `.input-base` (canonical chrome select + arrow);
trimmed the bespoke `.stories-editor__format` rule to just the toolbar sizing.
Build clean; 345 frontend tests green.
* feat(longform): cohesion quick-wins — Audiobook launchpad card + Stories in Projects
Make Stories/Audiobook feel wired into the app (integration-map plan, quick-win tier):
- Launchpad: an Audiobook ActionCard (was NavRail-only; Stories already had one).
- Projects/OmniDrive: saved Stories projects now appear as a "Stories" category
(line + voice counts) and open via onOpenStory → loadProject + setMode('stories'),
mirroring onOpenDub. App.jsx reads storyProjects/loadProject from storiesSlice.
- Live profile sync (QW1) confirmed already working: both tabs map the `profiles`
prop in render (no mount snapshot), so a voice cloned/designed/imported anywhere
shows up live in the cast/default pickers — no code needed.
Deferred (no trigger yet): QW4 create-voice handoff to these tabs needs an inline
create/gallery "use here" affordance first (QW3/M3).
Build clean; 345 frontend tests green; en.json valid.
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(ui): align Audiobook + Stories controls to the design tokens (#425)
The hand-written tabs used a bare `.btn` class (which has NO CSS rule → bright
white browser-default buttons) and an unstyled `.field-label`, so the buttons,
labels, and selects looked off-theme. (Other tabs use the Button/ui-btn system,
which is why only these looked wrong.) Found via a design-token audit workflow.
AudiobookTab:
- Import / Preview plan / Add word / Add cover / Download → `ui-btn ui-btn--subtle`;
Create → `ui-btn ui-btn--primary`; cover-remove / lexicon-remove / chapter-play
→ `ui-btn ui-btn--icon` (the app's themed button variants from ui/Button.css).
- AudiobookTab.css: define `.audiobook-tab .field-label` (chrome mono/uppercase
via --chrome-* tokens) + header serif title / muted subtitle (--font-serif,
--text-xl, --color-fg/-muted). Selects/inputs already used `.input-base` (the
canonical chrome look) — left as-is.
StoriesEditor:
- Format `<select>` now uses `.input-base` (canonical chrome select + arrow);
trimmed the bespoke `.stories-editor__format` rule to just the toolbar sizing.
Build clean; 345 frontend tests green.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(longform): evict oldest chapters from the render cache (review fast-follow) (#423)
The content-addressed longform_cache/ accumulated uncompressed chapter WAVs
across every render with no bound (a review finding). Add prune_cache_dir() —
LRU-by-mtime eviction down to a 2 GB ceiling (OMNIVOICE_LONGFORM_CACHE_MAX_GB);
best-effort, never raises. Called at the start of each render job, before its
chapters are written, so the fresh ones are never the eviction target.
Tests: under-cap no-op, evicts-oldest-keeps-newest, missing-dir safe. 38 green.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat(audiobook): pronunciation editor + markup reference UI (#422)
Makes the lexicon backend (#419) and SSML-lite markup (#421) usable from the tab.
- A "Pronunciation" editor in the full-width side pane: add/remove {word → say
it as…} rows, compiled to a lexicon dict sent with both the full render and
per-chapter preview (so previews match the final output).
- A collapsible "Markup reference" listing the script syntax (# chapter,
[voice:], [pause], [slow]/[fast]/[emphasis]/[spell]).
- api/audiobook.ts: lexicon field on the generate + preview bodies.
Build clean; 345 frontend tests green.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat(longform): SSML-lite prosody markup — [slow]/[fast]/[emphasis]/[spell] (PR 8b) (#421)
Inline delivery hints within a narration line, wired into BOTH front doors so
Audiobook and Stories behave identically.
- services/ssml_lite.py (parallel-built, 18 tests): parse_ssml_lite splits a
line into {text, speed, spell, emphasis} segments — nesting (innermost wins),
unclosed-to-EOL, stray-close ignored, adjacent-merge; ReDoS-safe literal
alternation. + spell_out().
- _parse_spans (audiobook script path) now applies SSML-lite as the innermost
layer (precedence: [voice:] → [pause] → SSML); each segment becomes a Span
with its speed (threaded to the renderer) and spelled-out text for [spell].
Trailing pause attaches to the run's last segment.
- frontend/src/utils/ssmlLite.js: client port (kept in sync with the .py) +
storyToSpans applies it per chunk — inline speed OVERRIDES the per-line slider,
falls back to it otherwise.
Tests: parse_ssml_lite (18 py + 10 js), script-level prosody parse, Stories
SSML compile (override + spell). 70 backend + 345 frontend green; build clean.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(ui): full-width/height Audiobook + Stories layouts (match other tabs) (#420)
Both tabs rendered as narrow centered columns (Audiobook maxWidth:860, Stories
max-width:1040 margin-auto) while the rest of the studio is full-bleed.
- AudiobookTab: rebuilt into a full-height two-pane layout (new AudiobookTab.css)
— header with the action buttons, a left script editor that grows to fill the
window height, and a right settings+results pane (voice/format/loudness, cover
& metadata, progress/output/plan) that scrolls independently. Collapses to one
column under 900px. Removed the inline 860px cap.
- StoriesEditor: dropped the `max-width:1040px; margin-inline:auto` cap → fills
edge-to-edge like the dub/projects/transcripts tabs.
Build clean; 334 frontend tests green.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat(longform): pronunciation lexicon — per-render word respelling (PR 8a) (#419)
Lets a render correct hard-to-say words (e.g. {"GIF":"jiff","Dr":"Doctor"}).
Backend wiring; the editor UI folds into the full-width Audiobook redesign.
- services/pronunciation.py (parallel-built, 19 tests): apply_lexicon —
whole-word, case-insensitive, longest-first, word-boundary, single ReDoS-safe
re.sub pass; + normalize/load/save_lexicon (JSON).
- synthesize_chapter gains a `lexicon` kwarg, applied to each span's text before
chunk splitting (None/empty = no-op → backward compatible).
- _render_chapter_cached folds the normalized lexicon into the chapter cache key
(a lexicon edit re-renders); threaded through _render_longform_sse + the
/audiobook, /audiobook/preview, /longform/render request models.
Tests: synthesize_chapter respells via lexicon; pronunciation module (19);
75 related backend tests green.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(longform): correctness + robustness fixes from adversarial review (#418)
* fix(longform): correctness + robustness fixes from adversarial review
Fixes the confirmed findings from a multi-agent review of the convergence:
HIGH (correctness/output):
- MP3 + cover produced a corrupt file (-map 2:v -c:v copy is invalid for mp3).
Cover art is now embedded for M4B only; mp3 skips it (m4b is the cover format).
- Chapter cache key omitted ref_text — editing only a profile's ref_text served
stale audio. ref_text is now part of the voice signature.
- Preview wrote audiobook_cache/ but the render reads longform_cache/ (rename
missed in PR 5) → cache-warming silently broke. Unified to longform_cache/.
Robustness (DoS/OOM guards):
- /audiobook/import caps upload at 64 MB; epub_to_chapter_script bounds per-entry
(25 MB) and cumulative (300 MB) uncompressed reads (zip-bomb guard).
- /longform/render rejects > 10,000 chapters (422).
Frontend leaks:
- StoriesEditor.removeTrack revokes the line's preview blob URL.
- AudiobookTab revokes the cover blob URL on replace/unmount.
Deferred fast-follows (also from review): render-cache disk eviction; restoring
the standalone chapter cue-sheet export (needs chapter times in the done event).
Tests: mp3-drops-cover, epub entry/total caps, import + chapter-count limits;
updated the cache-hit test for the 4-field voice sig. 70 backend + 334 frontend green.
* test(longform): pass EPUB caps as params, not monkeypatch (CI import-path fix)
The cap tests monkeypatched module constants, but in the full-suite CI context
the module loads under a different import path so the patch missed the function
(it used the real 300 MB cap → tests failed). epub_to_chapter_script now takes
max_entry_bytes/max_total_bytes kwargs (default to the constants); tests pass
small values directly — deterministic regardless of import path.
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat(longform): job library — finished books/stories in Projects (PR 7/8) (#417)
Surfaces finished Audiobook + Story renders so they're re-downloadable from the
Projects view — closing the resume/history loop of the convergence.
Backend (new, no migration — reads existing job_store rows):
- routers/longform_jobs.py: GET /longform/jobs lists finished audiobook/story
jobs newest-first, recovering output/chapters/duration from each job's
persisted 'done' SSE event. Pure build_longform_library() over the job_store
callables; defensive (skips unparseable jobs, never 500s). Registered in main.py.
Frontend:
- Projects.jsx: new "Audiobooks" category fed by /longform/jobs; each row opens
the rendered file (/audio/<output>) with type/chapters/duration. Offline-safe
(empty on fetch failure). en.json keys added.
Built via parallel worktree agent; backend tests/test_longform_jobs.py (9) green;
334 frontend tests + build clean.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(stories): thread per-line speed through the shared renderer (PR 6/8) (#416)
PR 5 moved Stories' full export to /longform/render but dropped per-line
**speed** — the old client export sent each line's speed to /generate; the
converged path silently ignored it. This restores it end-to-end.
- Span gains an optional `speed`; synthesize_chapter passes it to the injected
synth (signature now `synth(text, voice_id, speed)`); both engine paths
(OmniVoice model + generic TTSBackend) forward it to generate(speed=…).
- chapter_cache_key now includes speed (a speed change re-renders; tuples accept
an optional 4th element so existing 3-tuple callers/tests still work).
- LongformSpan + /longform/render carry speed; storyToSpans emits each line's
speed onto its spans.
Emotion note: per-line tone is already model-native via inline tags
([laughter] etc.) inserted into the text, so no separate emotion→instruct
plumbing is needed — the dead `emotion` store field stays unused/superseded.
Tests: storyToSpans speed passthrough (8); cache-key speed sensitivity; synth
stubs updated for the 3-arg signature. 65 backend + 334 frontend green; build clean.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat(stories): full export → shared server-side renderer (PR 5/8) (#413)
* feat(stories): full export → shared server-side renderer (PR 5/8)
The convergence core. Stories' full export no longer stitches audio in the
browser (Web Audio, capped by RAM, no resume/loudness/markers) — it compiles
cast + lines into a chapter/span plan and streams through the same chapterized
renderer the Audiobook tab uses.
Backend:
- Extracted the audiobook SSE job into a shared `_render_longform_sse(plan, …)`
generator (resume cache, per-chapter fault isolation, mux). /audiobook is now
a thin caller.
- New POST /longform/render — accepts a pre-built {chapters:[{title,spans:
[{voice_id,text,pause_ms_after}]}]} plan (+ format/loudness/cover/metadata) and
renders it. Pause-only spans (empty text) are kept as silence. job_type=story.
- Shared content-addressed cache renamed longform_cache (one render per unique
chapter across both front doors).
Frontend:
- storyToSpans(tracks, cast) — pure compiler: `# ` lines → chapters; each line
resolves its cast/override voice; inline [voice:]/[pause] split into spans;
pauses fold into the previous span.
- StoriesEditor.generateAll now posts via longformRender and downloads the
server file (chaptered M4B / MP3). Single-line preview stays client-side;
stems export unchanged. Format select WAV→M4B.
Deferred to PR 6 (with the component split): per-line regenerate, emotion→instruct.
Tests: storyToSpans (7) — cast resolution, chapters, per-line + inline voice,
pause folding, empty-drop. 64 backend + 333 frontend green; build clean.
* fix(audiobook): confine cover_path to OUTPUTS_DIR + don't leak exception text (CodeQL)
- _safe_cover_path() restricts the user-supplied cover to OUTPUTS_DIR before it
reaches ffmpeg (py/path-injection).
- SSE error events now emit a generic message and log the detail server-side
(py/stack-trace-exposure); empty best-effort excepts annotated.
* fix(audiobook): cover path via basename+fixed dir (clears CodeQL py/path-injection)
CodeQL didn't recognize realpath+startswith as a barrier; os.path.basename is a
recognized sanitizer. Covers only come from /audiobook/cover (OUTPUTS_DIR/
audiobook_covers), so rebuilding from the basename onto that fixed dir is both
CodeQL-clean and strictly tighter — no caller path can escape it.
* fix(audiobook): regex-allowlist cover filename (clears CodeQL py/path-injection)
basename alone wasn't a barrier CodeQL credits. Restrict the cover name to the
exact pattern /audiobook/cover emits (12 hex + jpg/jpeg/png) before joining onto
the fixed covers dir — an anchored-regex guard CodeQL recognizes as sanitizing,
and strictly tighter than before.
* fix(audiobook): commonpath-confine resolved cover path (CodeQL py/path-injection)
Add an os.path.realpath + os.path.commonpath containment check on the resolved
cover path (the barrier static analysis recognizes), on top of the regex
allowlist + basename. Defense in depth; the path provably cannot escape
OUTPUTS_DIR/audiobook_covers.
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(ci): make Docker Hub description sync non-fatal (#414)
The image build+push succeeds, but the "Update Docker Hub description" step
403s (Forbidden) — DOCKERHUB_TOKEN can push yet lacks description-edit scope, a
common limitation of fine-grained Docker Hub tokens. That cosmetic overview
sync was failing the whole Docker (GHCR) run on main.
Mark the step continue-on-error so a creds-scope mismatch no longer reds-out an
otherwise-successful build. To actually sync the overview, the token needs
read/write (incl. description) scope, or use the account password.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat(audiobook): text + EPUB import → auto-chapter (PR 4/8) (#412)
Spec PR 4. A front door onto the existing chapter parser: import a file, get a
chapter-delimited script in the editor.
Backend (new services/longform_import.py — pure, stdlib only, no new dep):
- chapterize_plaintext(text): inserts `# ` headings ahead of short standalone
chapter-title lines (Chapter/Part/Prologue/…); no-op if the text already has
H1s; long "Chapter …" sentences stay prose. ReDoS-safe (anchored, per-line).
- epub_to_chapter_script(bytes): parses EPUB (zipfile + ElementTree +
html.parser) in spine order → `# Title` + stripped body per document; skips
empty/nav pages; the heading becomes the chapter title (not narrated). Raises
ValueError on a malformed EPUB. ET.fromstring annotated `# nosec B314` (local
user file, no external-entity expansion).
- POST /audiobook/import (UploadFile) → {text, chapters}.
Frontend: an Import button (.txt/.md/.epub) that fills the script editor.
Tests: tests/test_longform_import.py (9) incl. an in-memory synthetic EPUB
(spine order, empty-doc skip, tag stripping, bad-zip). 64 backend + 326 frontend
green; build clean; en.json valid.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
docs(docker): maintain Docker Hub overview in-repo + auto-sync on main (#410)
The hub.docker.com/r/palashdeb/omnivoice-studio overview was managed by
hand and had gone stale (stuck at the sha-f86beb0 era, missing the tag
table, audiobook/long-form, Supertonic-3, server-mode networking notes).
Add deploy/dockerhub-overview.md as the source of truth and a
peter-evans/dockerhub-description step in docker.yml that pushes it to
Docker Hub on main pushes. Gated identically to the image push: only when
DOCKERHUB_TOKEN is set, so forks / GHCR-only runs are unaffected.
Overview adds the :latest=preview / :stable=release tag semantics (matching
docs/install/docker.md), the current feature set, server-mode + LAN
networking notes, and shields badges. Short description is 98/100 chars.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat(audiobook): per-chapter preview + resume + chapter fault-isolation (PR 3/8) (#411)
* feat(audiobook): per-chapter preview + resume + chapter fault-isolation (PR 3/8)
Builds on the shared core (#408) and metadata UI (#409). Chapter-level control,
the spec's PR 3.
Shared core:
- chapter_cache_key(spans, sr, engine_id, voice_sig) — deterministic content
hash of a chapter's audio inputs. Same inputs → reuse; any change (text,
voice, order, pauses, sr, engine, resolved-voice signature) → re-render.
Backend (audiobook router):
- Chapter WAVs are now content-addressed in OUTPUTS_DIR/audiobook_cache. A
re-run after a failure/interruption reuses already-rendered chapters and only
synthesizes the missing/changed ones (resume). Job emits `cached` per chapter
and `cached_chapters`/`failed_chapters` on done.
- Per-chapter fault isolation: a chapter that throws emits `chapter_error` and
the job continues; the m4b assembles from the successful chapters. Re-running
retries only the failed (un-cached) chapters.
- POST /audiobook/preview — render a single chapter to audition it; shares the
same cache so a preview warms the full run and a re-preview is instant.
- _build_synth now exposes resolve + engine_id; _prepare_synth unifies the
omnivoice/generic paths for both the job and preview.
Frontend:
- Plan view: a ▶ preview button per chapter with inline playback.
- Done panel: "reused N chapters" + "N failed — click Create to retry" notes.
Tests: chapter_cache_key determinism + sensitivity (8); preview validation +
cache-hit-skips-synth (3). 55 backend + 326 frontend green; build clean.
* fix(audiobook): mark cache-key SHA1 usedforsecurity=False (bandit B324)
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat(audiobook): metadata, cover art, format + loudness UI (PR 2/8) (#409)
Surfaces the shared-render-core capabilities (PR 1, #408) in the Audiobook tab.
Backend:
- POST /audiobook/cover — multipart cover upload (jpg/png, 8 MB cap), returns a
server-side path passed back as cover_path. Unit-tested via the handler
directly (no main+torch import).
Frontend:
- api/audiobook.ts: AudiobookGenerateBody (format/loudness/cover_path/metadata)
+ audiobookUploadCover(file).
- AudiobookTab: format select (M4B/MP3), loudness select (off/ACX/podcast,
default off), and a "Cover & details" panel — cover picker with preview +
title/author/narrator/year/genre/description. On create, the cover uploads
first, then the job runs with metadata + format + loudness.
- en.json: audiobook.* keys for the new controls.
Tests: tests/test_audiobook_cover.py (4) green; frontend vitest 326 green; prod
build clean; CJK + i18n-parity gates pass.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat(longform): shared render core — loudness, metadata, cover art (PR 1/8) (#408)
First slice of the Stories+Audiobook convergence (spec:
docs/specs/2026-06-13-stories-audiobook-maturity.md). Both features will compile
to one server-side chapterized renderer; this lands the shared pure builders and
wires them behind Audiobook.
New `backend/services/longform_render.py` (all pure, unit-tested without
ffmpeg/torch):
- build_ffmetadata(chapters, global_meta) — FFMETADATA1 with an optional global
tag block (title/author→artist/narrator→composer/year→date/genre/description→
comment) + chapter table.
- build_loudnorm_filter(preset) — `-af loudnorm` for ACX (~-19 LUFS, -3 dBTP) or
podcast (-16 LUFS); off/unknown → None. Opt-in, so default behavior stays
platform-identical.
- validate_cover_image — jpg/png + 8 MB cap guard.
- build_render_cmd — generalizes the m4b mux: m4b|mp3, optional cover
(attached_pic) + loudness, bitrate validated.
- build_concat_list — moved here.
`services/audiobook.py`: build_chapter_ffmetadata / build_m4b_cmd / build_concat_list
are now backward-compatible wrappers over the core (existing imports + tests
unchanged).
`POST /audiobook`: now accepts optional `format` (m4b|mp3), `loudness`,
`cover_path`, and `metadata` and passes them through — backend-complete; the UI
for these lands in PR 2.
Tests: tests/test_longform_render.py (28) + existing test_audiobook.py (11) green.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(ui): UI scale via transform:scale, not zoom — fixes WebKitGTK black bands (#407)
CSS `zoom` is honoured by Chromium (the macOS/Windows webview) but IGNORED by
WebKitGTK (the Linux webview). The shell sized itself to `100vw/scale` ×
`100vh/scale` expecting `zoom` to magnify it back to full size; on Linux the
magnification never happened, so at the default uiScale of 1.3 the whole app
rendered at 1/1.3 ≈ 77% of the window, leaving black bands on the right and
bottom (a cross-platform default-parity P0 — 1.3 ships out of the box).
Switch to `transform: scale(var(--ui-scale))` + `transform-origin: top left`,
which scales identically on every engine and doesn't alter how vw/vh resolve,
so `declared (100vw/scale) × scale` fills the viewport exactly. Drop the inline
`zoom` (keep setting the `--ui-scale` CSS var the transform reads).
Verified on the real WebKitGTK webview (Tauri debug build, localStorage
uiScale=1.3): shell now fills edge-to-edge — header, content, and logs footer
all reach the window edges; no black bands.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat(engines): on-demand unload of subprocess-engine sidecars (Action 13) (#406)
Completes the dynamic engine load/unload slice. The idle reaper (#401) frees
sidecar VRAM after 5 min; this adds a user-initiated "free VRAM now" path so
multi-engine users don't have to wait:
- subprocess_backend: `list_live_sidecars()`, `unload_sidecar(id)`,
`unload_all_sidecars()` via a shared `_force_reap(predicate)` — busy-guarded
exactly like the idle reaper (non-blocking lock; a sidecar mid-synth is
skipped, never interrupted; next request respawns it).
- system.py: `/model/loaded` now surfaces live sidecars as unloadable rows;
`/model/unload/{sidecar:<id>|sidecars}` frees one or all. The existing
generic flush panel picks these up with zero frontend change.
Also refresh CLAUDE.md stale version notes: main is 0.3.6 (latest release
v0.3.5 + 1 patch); the v0.3.0-as-unreleased framing in the project/cadence
notes is corrected to the v0.3.x continuous-to-main reality.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(persona): preserve design kind + vd_states across share/import (Wave 5 §R3) (#405)
The persona-gallery surface already exists (VoiceGallery Community zone +
community.py manifest + marketplace .omnivoice bundles). The blocker for §R3's
'synthetic-only' gate was data integrity: a *designed* persona lost its
kind='design' (and vd_states) when imported from the community gallery or
round-tripped through a bundle — silently demoting it to a clone.
- community.py /use: a 'preset' (rendered from instruct) imports as
kind='design'; a 'voice' (real reference clip) as 'clone'.
- marketplace.py: extract a pure _bundle_metadata() (dedupes export+publish)
that captures kind + vd_states; import restores them. Old bundles without
the keys import as 'clone' (backward-compatible).
This makes 'accept only designed/synthetic voices' enforceable instead of
everything defaulting to clone. No new persona-gallery feature was built — that
would duplicate the existing community/marketplace surface.
4 torch-free tests (isolated DB): _bundle_metadata captures design + defaults
to clone; import round-trip preserves design kind+vd_states; legacy bundle →
clone. docs §R3 status updated.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
feat(audiobook): Audiobook tab — script → plan → m4b (Wave 5 UI) (#404)
Frontend for the audiobook backend (#402/#403): a dedicated Audiobook tab.
- pages/AudiobookTab.jsx: script textarea + default-voice picker (reuses the
app's profiles), 'Preview plan' (POST /audiobook/plan → chapter list) and
'Create' (POST /audiobook → reads the SSE stream, shows per-chapter progress
+ assembling, then an <audio> player + m4b download via the /audio mount).
- api/audiobook.ts: typed plan() + generate() (returns the raw streaming
Response).
- utils/sseParse.js: pure splitSSEBuffer/parseSSELine helpers for reading the
POST event-stream (EventSource is GET-only) — unit-tested (the buffer/line
handling is the easy thing to get subtly wrong).
- NavRail + App.jsx wiring (lazy tab, hideSidebar); i18n keys in en.json.
All strings via i18n (CJK gate green). 7 new SSE tests; full vitest 326 +
vite build green. Runtime-unverifiable here (Tauri webview) — wants an in-app
pass. docs §R3 updated.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
feat(audiobook): synth job → chapterized m4b, SSE progress (Wave 5) (#403)
Completes the audiobook backend: POST /audiobook renders each chapter through
the active TTS engine (synthesize_chapter + chunked_tts), writes per-chapter
WAVs, then muxes a chapterized m4b (FFMETADATA1 chapters via build_m4b_cmd +
concat demuxer). Progress streams as SSE (started/chapter/assembling/done/
error), recorded to job_store. ffmpeg-gated — emits an error event and stops
when ffmpeg is absent (m4b is the only output).
- services/audiobook.build_concat_list: pure ffmpeg concat-list builder with
proper single-quote escaping (no arg injection). Unit-tested.
- router: voice resolution (compact form of generation.py's locked/design/
clone cases) cached per id; OmniVoice native model path + generic TTSBackend
path; chapter synthesis runs on the GPU pool, ffmpeg via run_ffmpeg.
Reuses the tested building blocks from #402 (parser, synthesize_chapter,
FFMETADATA + m4b argv builders) — the new router glue is thin and
import-checked by CI. Deferred: epub/pdf ingest, ACX loudnorm mastering,
crash-resume, UI. 15 audiobook tests (added concat-list); docs §R3 updated.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
feat(audiobook): chapterized audiobook core + plan preview (Wave 5) (#402)
* feat(audiobook): chapterized audiobook core + plan preview (Wave 5)
First cut of the long-form vertical (parity §R3). Engine-agnostic core in
services/audiobook.py:
- parse_audiobook_script: pure parser. Markdown '# H1' headings → chapters;
inline [voice:NAME] switches the narrator; [pause …] is delegated to the
shared omnivoice.utils.text.parse_pause_markers so audiobooks and single-shot
synthesis keep one pause dialect. Returns a chapter/span plan.
- synthesize_chapter: orchestration via an injected synth(text, voice) callable
(reuses chunked_tts split + crossfade, stitches inter-span silence) — so it's
unit-testable with a stub backend, no model/GPU.
- build_chapter_ffmetadata + build_m4b_cmd: pure FFMETADATA1 [CHAPTER] builder
and faststart-m4b concat-demux argv (bitrate-validated, no injection).
POST /audiobook/plan returns the parsed plan (no TTS/ffmpeg, no side effects).
Deferred (follow-ups): the streaming synth job + chapterized-m4b run, epub/pdf
ingest (new dep), ACX loudnorm mastering, crash-resume, UI.
14 tests: parser (chapters/voice/pause/intro/empties/to_dict), FFMETADATA
offsets+escaping, m4b argv + bitrate guard, and stub-synth orchestration
(span+silence stitching, voice threading). docs §R3 status updated.
* fix(audiobook): linear-time regexes (CodeQL ReDoS)
CodeQL flagged polynomial backtracking on user-provided input in three
regexes reachable from the new POST /audiobook/plan endpoint:
- _VOICE_RE: \s*(...)\s* → single [^\]]* class, stripped in code.
- _HEADING_RE: trailing [ \t]* removed; title captured greedily + stripped.
- _PAUSE_RE (omnivoice/utils/text.py): the numeric spec is now an atomic
group (?>…) so its leading \s+ can't backtrack against the trailing \s*.
Behavior-preserving (Python >=3.11 already required); 14 pause tests + 14
audiobook tests green.
* fix(audiobook): require non-space heading title start (CodeQL ReDoS)
The previous _HEADING_RE '[ \t]+(.+)' still let the leading whitespace class
and the title '.+' both match the same tab run (overlap → polynomial). Anchor
the title capture with \S so the two can't overlap. 14 audiobook tests green.
* fix(audiobook): exclude '[' from voice-tag content (CodeQL ReDoS)
[^\]]* still matched '[', so a run of nested [voice: prefixes produced
overlapping finditer match attempts → O(n^2). Excluding both brackets
([^\]\[]) makes matches non-overlapping and linear. A voice name never
contains a bracket. 14 audiobook tests green.
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
feat(engines): idle-reap subprocess-engine sidecars to free VRAM (Wave 13) (#401)
Parity Action 13 (dynamic load/unload), subprocess-engine half. A subprocess
engine's sidecar holds a process — and, for GPU engines, VRAM — for the life
of the backend, even after the user switches engines. The default in-process
OmniVoice model already idle-unloads (model_manager.idle_worker); this gives
the subprocess engine class the same treatment.
subprocess_backend gains a background reaper (lazy daemon thread, started on
first spawn) that shuts down sidecars idle past OMNIVOICE_SIDECAR_IDLE_TIMEOUT_S
(default 300 s; <= 0 disables). The next request transparently respawns one via
the existing dead-process relaunch. Safety: the reaper only acts while holding
the per-backend lock acquired NON-blockingly, so it can never run mid-op — if
an op holds the lock it skips that backend this round. Reuses the idempotent
shutdown() (which doesn't take the lock, so no re-entrancy). Each backend tracks
last-use and registers in a weak live-set.
Scope: subprocess engines only (the heavy, VRAM-holding, process-isolated
class). In-process non-default engines and cross-engine VRAM preemption remain
TODO — get_active_tts_backend returns a fresh instance per call, so those need
an instance-tracking refactor.
6 reaper tests via the stdlib echo sidecar (no torch): kills idle, respawns,
skips busy (lock held), recent-use kept, disabled at <=0, ignores dead. The 3
subprocess suites pass together (24).
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
feat(dictation): wire AEC end-to-end in the frontend (Wave 8, opt-in) (#400)
Completes Action 8: dictate-over-playback echo cancellation now works
end-to-end, gated behind a new off-by-default 'aecEnabled' pref so the
standard dictation + playback paths are untouched when off.
- utils/aec/{pcm,farEndBus,micCapture,playbackTap}.js + public/aec-worklet.js:
AudioWorklet captures the mic as raw int16 PCM; a player tap routes playback
output through Web Audio to a singleton far-end bus. Pure framing/encode
helpers are unit-tested.
- CaptureWidget: when aecEnabled, opens /ws/transcribe?aec=1, streams tagged
PCM (0x00 mic / 0x01 far-end) instead of MediaRecorder/WebM. Default path
unchanged; no POST fallback in AEC mode (the WS is the sole channel).
- WaveformPlayer: while actually playing AND aecEnabled, taps its decoded
output as the echo reference. Gated on isPlaying so only the one active
player holds an AudioContext (well under the browser cap); audio stays
audible (source always reconnected to destination).
- Settings → Capture: AecPanel toggle. prefsSlice: aecEnabled (persisted).
Runtime-unverifiable here (jsdom has no Web Audio); needs in-app testing in
the Tauri shell. 7 new pure-helper tests; full vitest (319) + vite build green.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
feat(dictation): opt-in NLMS AEC for dictate-over-playback (Wave 8b) (#399)
* feat(dictation): opt-in NLMS AEC for dictate-over-playback (Wave 8b)
Dictating while OmniVoice plays audio (TTS preview, dub, video) leaks the
loudspeaker signal into the mic, and the streaming ASR transcribes that
bleed. Browser echoCancellation varies per platform/webview — it can't be a
cross-platform default — so this adds a server-side canceller that behaves
identically everywhere.
services/aec.py ports Patter's NlmsEchoCanceller (MIT): a time-domain NLMS
adaptive filter with a Geigel double-talk detector, warm-up step ramp, and
far-end staleness pass-through. /ws/transcribe gains an opt-in '?aec=1[&sr=]'
mode: frames are raw int16 mono PCM tagged with a 1-byte prefix (0x00 mic,
0x01 playback reference); the mic is cleaned against the reference before
buffering, and the cleaned PCM is muxed via stdlib wave (not ffmpeg). Without
the param the protocol and behaviour are byte-for-byte unchanged.
Backend ships dark (no new deps — numpy already pinned); frontend far-end
streaming is a follow-up. Tests cover echo attenuation, double-talk
preservation, cold/stale pass-through, param validation, and the framing
helpers — all pure-numpy/stdlib so they skip the torch ASR stack.
* test(capture_ws): stubs accept the new pcm_sr kwarg
_transcribe_buffer/_transcribe_buffer_full gained an optional pcm_sr kwarg
for the AEC PCM path; the protocol-test stubs had fixed signatures and
raised TypeError on it, so the handler sent 'error' instead of 'final'.
Accept **kw in the stubs.
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
feat(asr): crash-isolated faster-whisper subprocess backend (Wave 4.2) (#393)
* feat(asr): crash-isolated faster-whisper subprocess backend (Wave 4.2)
Native ASR engines (faster-whisper / CTranslate2) can segfault on GPU
teardown — a process-level crash that kills the whole backend. Running the
engine in a child process turns that into a failed job: the sidecar dies,
the parent raises a decorated error (engine id + device), and the next
request respawns a fresh sidecar.
- services/subprocess_asr.py: SubprocessASRBackend reuses
SubprocessBackend's wire protocol + lifecycle — including
respawn-on-dead-process (_spawn relaunches when the child isn't alive) and
GPU-slot acquire/release — adding a 'transcribe' op (the TTS 'generate'
surface is stubbed). IsolatedFasterWhisperBackend wraps faster-whisper
using the PARENT venv (already a dep — only the process boundary is new);
opt-in via OMNIVOICE_ASR_BACKEND=faster-whisper-isolated.
- engines/_asr_sidecar/main.py: the faster-whisper runner (stdlib wire
protocol; torch/CT2 import lazily so the ready handshake fits the timeout).
- engines/_echo/main.py: a 'transcribe' echo op so the round-trip + crash
recovery are testable without a real engine.
- asr_backend._REGISTRY is now a lazy dict (mirrors the TTS registry) so the
isolated backend lists/resolves without importing the subprocess stack
unless selected.
Tests (echo sidecar, stdlib-only): round-trip, single long-lived sidecar
across calls, crash-mid-transcribe → decorated error + backend healthy +
next call respawns, registry exposure, generate-not-supported.
Spec 7 / parity program Wave 4.2.
* fix(asr): deterministic crash test + drift marker for lazy ASR registry (Wave 4.2 CI)
CI surfaced two issues:
- The echo crash test relied on the crash-AFTER-reply hook, whose reply
may still reach the parent (timing-dependent) — and a leaked
OMNIVOICE_ECHO_CRASH from a sibling subprocess test poisoned the
non-crash tests. Fix: a deterministic OMNIVOICE_ECHO_CRASH_NO_REPLY hook
that exits BEFORE replying (guaranteed dead pipe → decorated error), and
the asr fixture clears both crash envs so the round-trip/two-call tests
can't inherit a leak.
- check-docs-drift's _ASR_MARKER didn't match the new lazy registry line
(_LazyASRRegistry({); updated the marker + the self-test fixture.
Verified the no-reply crash hook by driving the sidecar directly
(reply=None, exit 1); drift self-test + real-repo check green.
* fix(asr): allowlist the 'segments' op so transcribe replies aren't dropped (Wave 4.2 CI)
The parent's PARENT_INBOUND_OPS frozenset gated inbound sidecar frames but
never included 'segments' — the ASR transcribe reply op. _recv() dropped the
frame as disallowed, tail-recursed, hit EOF, and returned None, so every
transcribe surfaced as a bogus 'sidecar crashed mid-transcription'. TTS
('audio') was allowlisted; ASR ('segments') was missed. Add it (and list
'transcribe' in the informational SIDECAR_INBOUND_OPS), update the exact-shape
allowlist test.
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
docs(spec): Voice Console 10/10 polish spec (#394)
* docs(spec): Voice Console 10/10 — pinned action bar, two-kicker hierarchy, unified presets, identity-first right rail
* fix(spec): ASCII '+' in wireframes — clears the CJK gate
---------
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
fix(layout): rail-right + hidden-sidebar left a phantom 48px gap (#398)
The 2-column sidebar-hidden template still sent the nav rail to
grid-column 3 — it overflowed into an implicit column and the reserved
48px slot rendered as a dead black band beside it. Rail now maps to
column 2 under that combo (and history-panel to column 1 under
rail-right+collapsed). Verified in WebKit: main/footer edges meet the
rail exactly.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
feat(studio): Voice Console 10x P4 — contrast, radiogroups, focus rings, reduced motion (#397)
Craft pass per docs/specs/voice-console-10x.md §3 (a11y gate, 8-pt
rhythm) and §4 acceptance:
- Contrast: solarized --chrome-fg-muted #657b83 (2.92:1 on --chrome-bg)
→ #899da4 (4.59:1, same hue); all other themes already pass. Readable
kickers/labels that rode the decorative dim token (identity-line
kicker, starting-points label, wv active kicker, slider kicker,
describe hint) switch to the muted token. --chrome-fg-dim itself
stays decorative-only.
- Radiogroups: design category chip groups are role="radiogroup"
(aria-label = category name) with role="radio" + aria-checked chips,
roving tabindex, and ArrowLeft/ArrowRight selection. The shared
Segmented control already ships radio semantics via Radix ToggleGroup
(role="radio" items + RovingFocusGroup) — left untouched.
- Focus: one shared :focus-visible rule (outline 2px chrome-accent,
offset 1px) for the 10x controls; verified none of them suppressed
outlines without replacement.
- Reduced motion: dub-skel-shimmer / dub-pulse / dub-stepper-spin,
heart-glow / logs-spin, wf-spin, and the FloatingPill dot-pulse /
progress-sweep now stop under prefers-reduced-motion (FirstRunSetup's
frs-alarm / frs-hw-pulse coverage verified pre-existing).
- aria-live: FloatingPill already carries role="status"
aria-live="polite" (verified); the action bar gains a persistent
sr-only polite status region announcing generation start/finish.
- 8-pt audit (CloneDesignTab.css): 5→4 gap, 5px 10px→4px 10px and
3px 9px→4px 8px paddings, 7→8 grid gap.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
feat(studio): Voice Console 10x P3 — identity recipe line, Active-voice card, empty-state verbs (#396)
- category chips collapse behind an 'Identity' recipe line (male · elderly
· …) that the describe box rewrites live; all-Auto starts expanded
- right rail leads with an ACTIVE VOICE card: name, kind badge, recipe,
identity sample player, + New; empty card carries verbs
- empty saved-voices states point at the action ('Describe one in Voice ←')
- script column stacks naturally (no void before VOICE)
Spec: docs/specs/voice-console-10x.md §1.5, §2.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
feat(studio): Voice Console 10x — P1 pinned action bar + P2 hierarchy/presets/insert popover (#395)
P1 (fold): language, steps, and the overrides disclosure move into a
pinned action bar with SYNTHESIZE — the primary CTA is visible at every
window size (verified 1280×720 and 1400×900 in WebKit); Cmd/Ctrl+Enter
synthesizes from anywhere; overrides expand upward above the bar.
P2 (hierarchy/consistency): two kickers only (SCRIPT, VOICE — method
toggle inline); the four redundant headers removed; the old PROMPT preset
chips merge with personalities into one edge-faded scrollable 'Starting
points' lane; the 14-chip tag wall becomes a ⊕ Insert popover at the
script corner (click-outside dismiss).
Spec: docs/specs/voice-console-10x.md §1.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
feat(settings): Hugging Face mirror (HF_ENDPOINT) for restricted networks (Wave 4.3) (#391)
The model manager already lists/deletes cached models; this adds the
remaining high-value slice — an in-app HF mirror setting so users behind
restricted networks (e.g. the Great Firewall) can route downloads through
hf-mirror.com or any HF_ENDPOINT. Persisted to the durable per-user env
(survives Tauri/Finder launches); HF reads HF_ENDPOINT at import, so the
override applies on restart (surfaced in the UI).
- GET/PUT /api/settings/hf-mirror (loopback-gated): presets (official +
hf-mirror.com), http(s) validation, empty clears to official.
- Models-tab panel with quick-picks + free-text field + restart note.
(Skipped 'hf cache verify' — version-fragile across huggingface_hub
releases and low value vs the mirror, which the China/Russia network
research flagged as the real gap.)
3 endpoint tests (default, set+trim+clear, non-http rejection).
Spec §R4(c) / parity program Wave 4.3.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
fix(mlx): harden import guards against PyInstaller dylib failures (Wave 4.4) (#390)
MLXWhisperBackend / MLXAudioBackend is_available() caught only ImportError.
In a PyInstaller bundle mlx's native dylib/metallib can fail to load even
when the package imports, raising OSError/RuntimeError — which would
propagate and crash the registry scan instead of reporting the backend
unavailable. Broaden to (ImportError, OSError, RuntimeError) so the picker
falls back cleanly. 6 tests across all three exception types.
The capture ASR path already prefers MLX Turbo on Apple Silicon
(get_capture_asr_backend), so this hardening is the remaining slice of
Spec 6 / Wave 4.4.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
fix(history): real 2-line title clamp + de-noised display + click-to-expand (#389)
The old max-height:3em guillotined the third line mid-glyph. Now a true
-webkit-line-clamp with ellipsis, leading [tag] control tokens stripped
from the display (full text stays in the tooltip and restore flows), and
clicking the title toggles the full prompt.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
docs(engines): uv dedupe + sidecar torch-pin disk-usage policy (Wave 4.5) (#392)
Explain why dedicated-venv engines (IndexTTS2) add disk (a second torch +
CUDA libs: Linux cu128 ~0.83 GiB, Windows ~3.2 GiB), and how uv's link-mode
dedup (clone on macOS/Linux, hardlink on Windows) shares identical wheels
for free — provided UV_CACHE_DIR and the venvs are on the same filesystem.
Key policy: pin the same torch build as the parent whenever the engine
allows, since only identical wheels dedupe; UV_LINK_MODE=hardlink on Linux
ext4. Linked from the IndexTTS engine doc.
Spec §R4(a) / parity program Wave 4.5.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
docs: official Docker Hub image palashdeb/omnivoice-studio (#388)
Link the published Docker Hub repo (https://hub.docker.com/r/palashdeb/
omnivoice-studio) as an official image alongside GHCR in the README install
list and docker.md header. Same images, same tags.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
perf(dub): retime batches seek to their window instead of decoding from frame 0 (#387)
Each batch now uses input seeking (-ss before -i, frame-accurate under
re-encode) plus a bounded read (-t window+0.5s), with chunk times shifted
into window-relative coordinates — long-video Smart Fit exports drop from
O(n²) decode cost to O(n).
Fixes#382
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
chore(i18n): backfill 36 studio-overhaul keys into en.json + all 20 locales (#386)
Canonical English added from the t() defaultValues introduced by the
overhaul PRs (#374-#381); 20 parallel translation passes added each key
to every locale (placeholders, product names, and existing per-locale
terminology preserved). All locale files parse; CJK gate + 312 frontend
tests green.
Fixes#383
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
fix: stale-chunk preload recovery (#380) + surface unsupported-GPU-arch in notifications (#284) (#385)
- #380: vite:preloadError (old hashed assets after an update) triggers a
one-time reload to pick up the fresh manifest; session flag prevents loops
- #284: check_device_compatibility's warning (e.g. Blackwell sm_120 on a
pre-cu128 torch) now appears in the notification panel as an error with
the pip fix — a log line never reached affected users while synthesis
silently produced noise. Cached once per process.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
fix(player): WaveformPlayer paused itself on play — idempotent claim + hard listener teardown (#384)
Live-debugged in Playwright WebKit with a pause() stack hook: the media
'play' event fired twice (a stale WaveSurfer instance's listeners survive
a destroy() that throws mid-teardown under StrictMode double-mount), so
the second claimPlayback stopped the current owner — this very element.
play → instant self-pause → 'click does nothing'.
- 'play' handler only claims when it doesn't already own the slot
- per-instance stale flag inert-izes leaked handlers
- cleanup detaches handlers (unAll) BEFORE destroy so a throwing destroy
can't leak them
Verified in WebKit: paused=false, currentTime advancing, 0 stray pause calls.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
fix: issue-triage batch — timeline box flicker, truncated-model detection, stale history pruning (#381)
- #373: drop will-change:transform on the segment lane (persistent
compositor layer made the semi-transparent boxes vanish during
playback/drag on some Windows GPUs) + raise region alpha 0.30→0.45
- #352: validate a finished snapshot actually contains weights (>5 MB
file) so interrupted downloads fail at install time with a re-download
hint; loader translates the opaque transformers error into the same
guidance
- GET /history prunes rows whose audio file is gone instead of serving
dead 404 players forever
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
fix(player): WaveformPlayer click did nothing — media element never got a src (#379)
With an external `media`, wavesurfer's `url` option only fetches for peak
decoding and never assigns the element's src — so the waveform drew but
play() had nothing to play. Set src on the in-DOM <audio> via JSX (same
pattern as WaveformTimeline) and stop passing `url`. Also surface
playPause() rejections instead of swallowing them.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
feat(studio): consolidate Clone + Design into one Voice workspace (spec P4) (#378)
One 'studio' navigation mode replaces the clone/design pair; the split
lives on as a 'Define voice' toggle (From audio / By design) at the top
of Voice Source. Selecting a saved profile sets the method from its kind.
- uiSlice: AppMode + 'studio'; defineMethod ('audio'|'design') persisted
- legacy shims: localStorage mode + restoreHistory map clone/design →
studio + method; history mode VALUES unchanged
- NavRail/Header: single Voice entry (Fingerprint, #d3869b)
- CloneDesignTab/WorkspaceVoices/useTTS/useProfiles/Gallery/Launchpad/
Sidebar: definition-method semantics moved off the navigation mode
Build clean; 312/312 tests; tsc clean; no setMode('clone'|'design') left.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
fix(dub+win): dialect↔cinematic guidance loop + WinError 193 ffmpeg validation (#377)
* fix(dub): break the dialect↔cinematic guidance loop (#372, #373)
- Cinematic toggle refuses the pick when no LLM endpoint is configured,
pointing at Settings → Credentials → LLM endpoint
- backend Fast fallback now syncs the quality toggle to 'fast'
- the dialect warning no longer fires alongside the cinematic-no-LLM
warning (the pair formed the loop), and both messages point at the
LLM endpoint settings instead of each other
Fixes#372
* fix(ffmpeg): validate resolved ffmpeg/ffprobe actually runs — fall through on WinError 193 (#360, #361, #362)
A corrupt or wrong-arch imageio-ffmpeg download (and WindowsApps alias
stubs) passes os.path.isfile/shutil.which but explodes at spawn with
'[WinError 193] %1 is not a valid Win32 application', killing
transcription with an opaque 500. Every resolution step now probes the
candidate with '-version' (cached per process), logs the rejected
basename, and falls through to the next source.
Fixes#362Fixes#361Fixes#360
---------
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
feat(profiles): unified profile model — kind discriminator + stored design params (spec P3) (#376)
Migration 0005_unified_profiles (0004 taken by mcp bindings):
- voice_profiles.kind TEXT DEFAULT 'clone' ('clone' | 'design'), backfilled
- voice_profiles.vd_states TEXT NULL — JSON of design category picks
- mirrored in _BASE_SCHEMA; idempotent _has_column guards; downgrade drops
POST /profiles:
- ref_audio now optional; kind + vd_states form fields with validation
(clone requires audio; design requires vd_states JSON object + instruct)
- design profiles render a deterministic identity sample (seed 42) through
the shared archetype renderer — one TTS code path
POST /generate:
- profile resolution branches on profile.kind (authoritative) instead of
the brittle is_locked/instruct inference; legacy pre-0005 rows keep the
old inference as fallback; history.mode records profile.kind
Frontend:
- 'Save design as profile' in the Design tab (vd_states + buildDesignInstruct)
- selecting a design profile restores its sliders (vd_states) for re-editing
Also unforks the alembic chain (0004_mcp + my 0004 both revised 0003 →
multiple heads broke alembic upgrade head and the 0003 migration tests).
Tests: tests/test_profile_unification.py — validation, design-create with
mocked renderer, migration up/backfill/downgrade. 18/18 profile tests,
312/312 frontend, related backend suite green.
Note: docs/specs/voice-studio-unification.md (on feat/studio-ux-overhaul)
still says 0004 — renumber to 0005 when branches meet.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
feat(studio): workspace UX overhaul — right-side panels, shared waveform player, dub pipeline UX, setup polish (#374)
* feat(studio): workspace UX overhaul — right-side panels, shared waveform player, dub pipeline UX, setup polish, UI-wide fixes
Voice workspace (specs: docs/specs/voice-studio-unification.md, workspace-connectivity.md):
- Right-side panels replace the left sidebar for clone/design and dub:
WorkspaceVoices (saved profiles), WorkspaceHistory (scoped history with
All/Clone/Design filters), WorkspaceProjects (dub projects)
- Prompt restacked over Voice Source in one definition column (spec §1)
- Gallery "Use voice" now hands off via pendingProfileId and lands in clone
- Shared <WaveformPlayer> (wavesurfer + in-DOM media element for Tauri
WebKit, blob routing via preview endpoint, 404 -> "audio file missing")
replaces every bare <audio controls>; lazy-mounted via IntersectionObserver
Dub:
- Pipeline stepper (Upload -> Prepare -> Transcribe -> Edit -> Generate -> Export)
- Multi-language preview switcher pills (Original + per-track, ElevenLabs-style)
- Batch multi-language generation via langOverride loop
- FloatingPill: bottom-center, suppressed on its homeMode tab (no dup progress)
- Transcript skeleton shimmer (no fake data), progress overlays the video,
exports demoted behind Generate, empty right-panels collapse
Chrome/layout:
- Nav rail is full-window-height; content yields to the logs footer via
padding-bottom; footer joins the rail edge (no overlap at any UI scale)
- UI scale 60–175% slider with zoom-compensated container sizing
- LogsFooter: merged single Logs tab when collapsed, per-source tabs on
expand; Updates chip lives with the logs tabs
- Gallery: three independently scrollable filter lanes, uniform 26px controls
- Font picker as live-preview grid; double-click titlebar maximize fixed
(single mousedown detail-2 handler)
First-run:
- Setup wizard: pinned action row + scrollable content at every window size,
one-line head-ellipsized paths, height budget for short windows, library
rows back to one-line grammar, raw i18n key + duplicate host fixed
Performance/i18n/consistency sweep (10-agent scan, 47 fixes):
- i18n locales lazy-loaded per language (i18n chunk 1.84 MB -> 76 kB)
- Undefined CSS vars replaced with real tokens across 8 stylesheets;
hardcoded hexes tokenized; emoji swept to lucide icons app-wide
- Poll throttling (sysinfo subscription scoped to Header, logs 45s when
collapsed, rAF only during playback), hardcoded strings moved to t()
Build clean; 312/312 tests pass.
* fix(studio): re-flow clone/design columns (grid rows collapsed in restack) + strip placeholder emoji across locales
The base .studio-column grid (minmax(0,1fr) rows) collapsed to 0 height
inside the new auto-height definition column, overlapping every panel in
design mode — found via Playwright visual pass. Columns now re-flow as
natural-height flex stacks. Also removed the leftover pencil emoji from
clone.prompt_placeholder in all 21 locales.
* feat(design): compact the design control stack — 2-up facet selects, scrollable tag row, tighter rhythm
English accent + Chinese dialect dropdowns share one row (full-width on
narrow), insertable tag chips collapse from three wrapped rows to one
scrollable line, and describe/personality spacing tightens — the whole
design stack now fits a single viewport.
* docs(spec): unification migration renumbered 0004 — upstream 0003 is voice-profile consent
* fix(ci): clear hardcoded-CJK gate — ASCII '+' in spec wireframes, reword voiceIcons comment
* docs(spec): migration is 0005 — 0004 taken by mcp bindings upstream
---------
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
ci(docker): also publish to Docker Hub palashdeb/omnivoice-studio (#375)
Push the same images (same tag set: :latest rolling main, :stable/:X.Y.Z
releases, :sha-) to docker.io/palashdeb/omnivoice-studio alongside GHCR.
Gated on DOCKERHUB_USERNAME/DOCKERHUB_TOKEN secrets — without them the
build still publishes to GHCR only. Docs-sync: docker.md mirror note.
Requires repo secrets: DOCKERHUB_USERNAME, DOCKERHUB_TOKEN.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
feat(dub): Smart Fit phase B — per-segment video retime export, drift absorption, fitted subtitles (#350)
* feat(dub): Smart Fit phase B — per-segment video retime export, drift absorption, fitted subtitles
Executes the video side of the Smart Fit plans persisted by Phase A
(job["fit_plans"], #347) at export and preview time.
Backend:
- services/video_retime.py (new, clean-room): two-tier retime executor.
≤48 chunks → the proven single-pass split/trim/setpts/concat
filter_complex; above → batches of 40 chunks rendered to intermediate
slices (identical libx264 medium/crf20 params, keyframe at t=0) joined
losslessly with the concat demuxer. Slices are CFR-resampled (fps=)
because setpts leaves VFR-ish timestamps that broke tpad and drifted a
frame per retimed chunk on ffmpeg 7.x. Temp slices cleaned on success
AND failure/abort.
- Drift absorption: fitted track longer than retimed video → freeze-frame
tail (tpad=stop_mode=clone) predicted into the last slice / single-pass
graph, with residual mux-side tpad; video longer → silence-pad the dub
audio chain (apad=whole_dur). ±50 ms tolerance.
- VFR guard: probe r_frame_rate vs avg_frame_rate; normalise with fps=
before trim/setpts; probe failure degrades gracefully.
- Plan resolution: _video_retime_plan_for spans legacy video_stretch_plans
(byte-identical resolution + command construction) and fit_plans, gated
on the track's own timing_strategy so stale plans never retime a track
re-generated under another strategy.
- Fitted subtitles: /dub/srt + /dub/vtt accept ?lang= and serve cue times
from fitted_segments for Smart Fit tracks; _write_burn_srt does the
same for burn-in. burn_subs+retime is now allowed for smart_fit (burn
runs AFTER the retime graph); still rejected for legacy stretch_video.
- /dub/preview-video resolves the same plan so in-app preview matches
export.
- Fallback ladder: batch encode failure/timeouts → un-retimed export with
a structured core.failure warning (X-Dub-Export-Warning header +
job["last_export_warning"]); concat join rejection → one single-pass
retry while ≤96 chunks; abort → 409 + proc kill via run_ffmpeg job_id
registration (/dub/abort reaches export encodes now) + temp cleanup.
Frontend:
- Export drawer passes ?lang= on subtitle exports and shows an i18n'd
re-encode cost note (~0.5–2× video length on CPU) when a retiming
strategy is active — translated in all 21 locales.
Tests: tests/test_smart_fit_export.py — plan resolution, batch math,
graph parity + new stages, fitted-cue SRT/VTT/burn selection, burn
policy, VFR detection; ffmpeg-gated integration renders both executor
tiers (batch size forced to 2) and the real /dub/download endpoint,
ffprobing durations within ±50 ms across both pad branches. All existing
dub export/subtitle/preview/timing tests pass unchanged.
Refs docs/competitive-analysis.md Action 1 (dub-length fitting v2);
completes Smart Fit (Phase A = #347).
* fix(security): sanitize Smart Fit retime work paths at every sink (CodeQL py/path-injection)
The job_id-derived retime work path (retimed_*.mp4 / preview_retimed_*.tmp.mp4)
flowed unguarded from dub_export into prepare_smart_fit_video /
render_retimed_video and their derived slice/concat paths and ffmpeg argv.
Apply the repo's proven inline realpath+startswith containment pattern
(helpers/commonpath are not recognized — see #309/#328/#329/#348):
- dub_export.py: validate work_path against DUB_DIR at both construction
sites (export + preview) and pass the validated realpath onward.
- video_retime.py: make both entry points self-defending — realpath +
DUB_DIR containment on out_path/work_path before any derivation, raising
RetimeError(stage="plan") on escape; slices_dir/slice_path/list_path and
RetimeDecision.file_path now all derive from the sanitized value. DUB_DIR
is read via module attribute so test fixtures reloading core.config work.
- ffmpeg_utils.py: document that all caller-assembled argv paths are
realpath-validated upstream.
- tests: sandbox DUB_DIR in the executor integration tests (tmp_path) so
the new guard sees the test workspace.
No behavior change for valid (server-built) paths — the guard only fires
on traversal.
* test(smart-fit): patch DUB_DIR on video_retime's own config ref — survives suite-wide reload
The retime guard reads video_retime._config.DUB_DIR at call time; the
sandbox fixture patched a fresh 'import core.config' instead. Another
test reloads core.config in the full suite, so the two module refs
diverged — the patch missed and the guard rejected the test's tmp paths
(green in isolation, red in CI's full run). Patch the exact ref the
guard dereferences.
* fix(dub): resolve DUB_DIR live at call time in retime guards — survive full-suite reload
The path-containment guards bound DUB_DIR via a module-level
'from core import config as _config'. Other tests importlib.reload()
core.config (sandboxing OMNIVOICE_DATA_DIR), after which the guard
checked containment against a stale DUB_DIR while dub_export built the
path under the reloaded one — every retime path then 'escaped the dub
workspace' (green file-alone, red full-suite: the 5 integration
failures CI hit). Re-import DUB_DIR locally in each guard so it always
reads the current sys.modules value; simplify the sandbox fixture to
patch the canonical module. Verified: full backend suite green on the
Smart Fit tests (the 2 remaining settings_store failures are
pre-existing on main, unrelated — local data-dir artifact).
* fix(security): clear CodeQL alerts on Smart Fit export — job_id allowlist, proc-registry decouple
- py/path-injection (8, video_retime.py): validate job_id with a strict
inline regex allowlist (re.fullmatch [A-Za-z0-9_-]{1,64}) at the entry
of dub_download and dub_preview_video, before it reaches any filesystem
path or ffmpeg argv. The existing realpath containment guards stay as
defense-in-depth; the regex barrier is the sanitizer CodeQL recognizes
through the service-module call chain.
- py/log-injection (4): newline-strip job_id inline at the logger calls
in ffmpeg_utils.run_ffmpeg and the two retime-fallback logger.error
sites in dub_export.
- py/empty-except (3): best-effort cleanup os.remove handlers now log
the OSError at debug instead of bare pass (video_retime + both
dub_export mux finally blocks; _discard_tmp too for consistency).
- py/cyclic-import (2): break the dub_pipeline <-> ffmpeg_utils cycle
for real — the subprocess registry (register_proc/unregister_proc/
kill_job_procs/has_active_procs + state) moves to a new stdlib-only
leaf module services/proc_registry.py. ffmpeg_utils now imports it at
module top (no lazy import); dub_pipeline re-exports every name so
dub_core aliases and tests keep working unchanged.
No behavior change for valid inputs; invalid job ids now get a clean
400 instead of a 404/containment error.
* fix(dub): address #350 review — cancelled-vs-failed retime, logged best-effort excepts, redacted probe logs, narrowed test assert
- rc<0 (killed by user cancel) now raises RetimeError(stage='aborted')
instead of reporting an ordinary render failure (CodeRabbit)
- best-effort cleanup/QC-event excepts log at debug instead of bare pass
(CodeQL empty-except x3)
- probe failure logs use basename, not full user paths (CodeRabbit/CodeQL)
- test_render_cleans_slices_on_failure asserts RetimeError, not Exception
Rebuttals (no change needed, see PR comment): fitted-cue subtitles track
the fitted AUDIO timeline which is correct even on retime fallback;
the planner only emits stretch ratios >1 so the early-exit guard is a
true no-op check; '\'' is ffmpeg's own utility quoting for concat lists;
has_active_procs is an intentional re-export (noqa'd).
---------
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
feat(dub): regenerate subtitle timeline on the fitted timeline (Wave 3.1) (#371)
Smart Fit Phase A (planner) + the export-side video retime + audio stretch
already shipped (#347 + dub_export stretch filter). The last piece of
Spec 1 was the subtitle timeline: under stretch_video the dubbed audio
plays at FITTED positions, but the standalone SRT/VTT export still used the
original segment times — so external subtitles drifted against the dubbed
video.
- services/fitted_subtitles.py (pure, tested): map_time_to_fitted() +
fitted_cues() remap original cue times onto the same per-chunk
{orig→new, stretch_ratio} plan the video stretch uses, with a
monotonicity guard.
- dub_export SRT + VTT endpoints: when a job used stretch_video, cues are
regenerated from the plan (subtitles track actual dub placement); no
plan → original times, unchanged. New optional ?lang= selects the track.
7 pure tests (chunk-bound mapping, linear interpolation, unit-rate tail,
fitted cues, monotonicity, empty-plan identity).
Spec 1 (remaining) / parity program Wave 3.1.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
feat(dub): second-pass ASR QC — flag lines whose dub drifts from target (Wave 3.3) (#370)
After a dub is generated, re-recognize the synthetic audio and compare what
the ASR heard against what we asked the TTS to say. Lines that drift are
flagged for the user to re-listen / re-dub — turning subtitle timing and
pronunciation from trusted math into measured truth, and doubling as an
automatic dub-quality check.
Design delta from pyvideotrans (which lets recognized text REPLACE the
subtitles wholesale): we keep the generated text authoritative and use the
second pass only for MEASUREMENT — a per-line drift score + measured
start/end that feed the incremental re-dub loop, never silently overwriting
the translation.
- services/dub_qc.py (pure, tested): word_error_rate (normalized token edit
distance, case/punct-insensitive, script-agnostic) + score_dub (matches
recognized segments to dub segments by time overlap, concatenates the
hypothesis, scores drift, derives measured bounds).
- POST /dub/qc/{job_id}: runs the active ASR backend on the dubbed track in
the GPU pool, annotates each segment with qc_drift/qc_flagged/
qc_recognized/qc_measured_start-end (non-destructive — content untouched),
persists, emits a qc_done job event. Opt-in, never fatal.
- Frontend: dubQc() API fn + a red 'Verify' badge on flagged segment rows
(en.json keys; other locales fall back).
12 pure scoring tests (identical/substitution/empty/no-overlap/multi-segment
matching/measured-timing); endpoint validated in CI.
Spec 5 / parity program Wave 3.3.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
feat(dub): per-segment clone references (Wave 3.2) (#369)
Cut each long-enough dub segment's clone reference from the isolated vocals
at that segment's own timestamps, so the dub of each line carries the
prosody/emotion of its source line — finer than one reference per speaker.
Reimplemented from the clean-room spec (pyvideotrans per-line ref idea); our
design delta is a quality floor with fallback.
- services/speaker_clone.py: extract_segment_refs() keyed by segment id;
reference transcript is the SOURCE text (text_original), since the vocals
slice is source-language audio. Floor at MIN_SEGMENT_REF_DURATION_S=3.0
(not the per-speaker 5.0, which most dialogue lines fall under) — shorter
lines are omitted and fall back to the per-speaker clone, so it's a strict
improvement, never a regression.
- dub_core: run extraction at transcribe (per_segment_refs query param,
default on), store job['segment_clones'], default each unassigned
segment's profile_id to 'auto-seg:{id}' when it has its own ref, else the
existing 'auto:{speaker}'. Forcing per-speaker (per_segment_refs=false)
is supported for long-form consistency.
- dub_generate _gen: resolve 'auto-seg:' from segment_clones, ahead of the
per-speaker 'auto:' path. profile_id is already a fingerprint field, so
flipping the mode re-dubs automatically (no _GEN_INPUT_FIELDS change).
7 pure tests over a synthetic vocals wav (own-ref for long lines,
short-line omission/fallback, source-text transcript, bounds clamping,
floor boundary). Pipeline wiring validated in CI.
Spec 4 / parity program Wave 3.2.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
feat(mcp): MCP server v1 — mount on /mcp, per-agent voice binding, stdio shim (Wave 2.2) (#368)
* feat(mcp): MCP server v1 — mount on /mcp, per-agent voice binding, stdio shim (Wave 2.2)
The FastMCP server (previously dead code, never mounted) is now mounted on
the main FastAPI app at /mcp via Streamable HTTP, with its session manager
composed into the app lifespan through an AsyncExitStack (best-effort: a
missing mcp package or OMNIVOICE_MCP_DISABLE=1 never breaks startup).
streamable_http_path set to '/' so the sub-mount lands at /mcp, not
/mcp/mcp. Adds the 'mcp' dependency (1.27.x).
Per-agent voice binding (Spec 2 headline): each MCP client sends an
X-OmniVoice-Client-Id header; generate_speech resolves the voice as
explicit arg > the client's binding > global default > app default. New
mcp_client_bindings table (alembic 0004 + _BASE_SCHEMA, additive/idempotent),
services/mcp_bindings.py (CRUD + resolve_voice + best-effort last_seen),
and a loopback-gated REST router (/api/mcp/bindings) the Settings panel
drives.
New transcribe tool (base64 audio in, 200 MB cap). Stdio shim
(backend/mcp_shim, httpx-only, ported from voicebox MIT) proxies stdio
clients to the mounted endpoint and forwards OMNIVOICE_CLIENT_ID as the
binding header. Settings → Sharing gains an MCP bindings panel. Docs:
docs/mcp.md (both connection modes + binding REST) and docs/mcp.json
updated to the shim form.
Tests: bindings service + resolution precedence + migration up/down (pure,
run locally); REST CRUD + mount-not-404 + disable-flag (main-importing,
validated in CI). MCP build + mount + initialize handshake verified
out-of-band (no torch).
Spec: docs/competitive-analysis.md Spec 2 / parity program Wave 2.2.
* test(mcp): assert /mcp mount via app.routes, not a lifespan client
The two main-importing mount tests ran the app lifespan, which now starts
the FastMCP session manager and binds asyncio queues to the test loop —
contaminating later lifespan-running tests ('bound to a different event
loop'). The mount happens at import time, so inspecting app.routes for the
/mcp Mount is the correct loop-free assertion. Same fix shape as the
Wave 0.2 consent tests.
* test(mcp): stop reload-main poisoning across the MCP test files
Root cause of the CI failure: the bindings REST fixture set
OMNIVOICE_MCP_DISABLE=1 and reloaded main but never restored it, so a
later 'from main import app' in test_mcp_mount saw /mcp un-mounted
({'/audio','/voice_audio'}). Reloading main mutates the shared module for
every subsequent test.
- REST fixture: drop the disable flag (the mount is harmless without a
lifespan), yield the client, and restore main (+ core.config/db) to the
default data dir in teardown so the global module is clean again.
- test_main_mounts_mcp_route: reload main with the disable flag cleared so
the assertion is independent of any earlier reload.
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
fix(settings): remove stray rebase conflict marker in settings.py (#367)
A '>>>>>>>' marker from the #365 rebase was committed at the tail of the
LLM-endpoint block, making the module unparseable. Strip it; settings.py
parses clean and the endpoint tests pass.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
feat(settings): remote LLM endpoint UI — Ollama/vLLM/LM Studio (Wave 2.4) (#365)
A focused Settings panel for the OpenAI-compatible LLM that powers
cinematic translate, glossary auto-extract, and dictation refinement
(Wave 2.1). Persistence reuses the existing TRANSLATE_BASE_URL /
TRANSLATE_MODEL / TRANSLATE_API_KEY env vars (already in system.py
PERSISTENT_KEYS, restored at startup), so llm_backend/translator
resolution is unchanged — vLLM is a verified drop-in, Ollama ignores the
key, vLLM/LM Studio require it.
- GET/PUT /api/settings/llm-endpoint (loopback-gated): read shape returns
base_url, model, masked key, and live availability; PUT treats a null
field as unchanged and an empty string as clear (so the key isn't wiped
by a base-url-only save). Key is masked to last-4 in the read path,
never echoed.
- Credentials-tab panel with one-click presets (Ollama/LM Studio/vLLM/
OpenAI), base URL + model + optional key fields, and a reachable/not
status badge.
6 endpoint tests (read shape, set+mask, null-unchanged, empty-clears,
local-url-no-key, short-key masking); availability assertions guarded on
openai being installed.
Spec: parity program Wave 2.4 / competitive-analysis §R2 rung 4.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
docs(agentic): OmniVoice as a TTS/STT provider for pipecat/LiveKit (Wave 2.5) (#366)
Agentic v1: OmniVoice is a provider, not the orchestrator. Its existing
OpenAI-compatible API already serves everything pipecat/LiveKit need
(POST /v1/audio/speech with pcm/wav, voice-profile id, speed; default
24 kHz output matching pipecat's OpenAITTSService) — so this is docs + an
example + a contract test, no new endpoint.
- docs/agentic-voice.md: the provider recipe for pipecat (base_url to
:3900/v1) and LiveKit, the remote-backend note (bearer from 2.3), the
consent-locked-voice nudge (0.2), and an explicit telephony-is-deferred
scope box.
- examples/agentic/pipecat_minimal.py: lazy-import skeleton wiring the
OmniVoice STT/TTS services (importable without pipecat installed).
- tests/test_agentic_provider_contract.py: pins the /v1/audio/speech
request shape pipecat sends (pcm + wav formats, voice-profile passthrough,
speed) so the documented recipe can't silently break. Validated in CI.
Spec: Action 15 / §R1 v1 / parity program Wave 2.5.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
feat(remote): backend URL + bearer key + Tailscale docs (Wave 2.3) (#364)
Run inference on a remote GPU box, drive it from the desktop app — opt-in,
off by default (loopback-only is unchanged when no key is set).
Backend:
- BearerKeyMiddleware (main.py): when OMNIVOICE_API_KEY is set, every
non-loopback HTTP + WebSocket request must present it (Authorization:
Bearer, ?api_key=, or the ov_key cookie set on first auth). Pure ASGI
(no response buffering), loopback always bypasses, SPA shell stays
reachable. Constant-time compare, never logged.
- ws_remote_authorized() in dependencies; capture_ws lets a keyed
non-loopback client through its inline loopback guard (the thin-client
dictation case: mic local, GPU remote).
Frontend:
- api/client.ts: ov_backend_url (localStorage) is the top-precedence base
override; new wsUrl() derives ws scheme + host from the API base (not
window.location, which lies in the Tauri webview) and appends ?api_key.
apiFetch attaches the bearer header. Both WS call sites (dictation,
events) routed through wsUrl; the HTTP transcribe fallback through
apiFetch.
- Settings > Sharing > Remote backend panel: URL + key fields, a
test-connection probe against {url}/health, save-and-reload.
Docs: docs/remote-gpu.md — the Tailscale recipe (MagicDNS + Serve, never
Funnel, headscale note, plain-HTTP-is-sniffable warning, PIN-vs-key split).
Tests: 10 bearer-middleware cases (inert without env, loopback bypass,
401 without/pass with key via header+query, wrong key, shell exemption,
plain-ASGI guard, WS handshake reject/accept). Validated in CI.
Spec: parity program Wave 2.3 / competitive-analysis §R2 rungs 1-3.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
feat(dictation): optional local-LLM refinement of finals (Wave 2.1) (#363)
Phase 2 of Spec 3, on top of Wave 1.1's deterministic collapse. Prompt
design ported from voicebox (MIT): 'text filter, not an assistant' base
instruction + three toggleable sections (smart_cleanup, self_correction,
preserve_technical) + 7 few-shot examples passed as STRUCTURED chat turns
(small local models echo inline examples). Runs through the user's own
Ollama/LM Studio/OpenAI-compat endpoint via llm_backend — new additive
chat_messages() on the adapter; chat() now delegates to it.
Pass-through is the contract: with no LLM configured (backend 'off'),
on any error/timeout, or on an empty reply, the raw transcript stands —
identical default behavior on every platform. Refinement runs off-thread
on FINALS only; the WS final dict gains optional refined_text and the
dictation pill pastes refined_text ?? text (raw kept in history).
Settings: GET/PUT /api/settings/dictation-refinement (loopback-gated,
persisted in the settings table) + a Capture-tab panel with the master
switch + per-flag toggles and a 'no LLM configured' hint.
15 new unit tests: prompt sections per flag, structured few-shot message
shape, and the full maybe_refine pass-through matrix (off backend,
disabled config, LLM failure, empty reply, empty input).
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
test(api): pin the /generate surface pyvideotrans's integration consumes (Wave 1.3) (#359)
pyvideotrans drives OmniVoice as a per-line clone backend (their
videotrans/tts/_omnivoice.py — being replaced upstream with a REST
integration against POST /generate). This contract suite pins the exact
multipart shape that integration sends (text + uploaded ref_audio +
ref_text + language name + num_step/guidance_scale/speed/denoise/
postprocess flags -> audio/wav with X-Audio-Duration) so a /generate
change that would silently break the 17.9k-star upstream fails our CI —
the engine-compat constraint extended to an external consumer.
Engine stubbed; validated in CI (local torch/Triton segfault on
main-importing tests, see project memory).
Spec 11 / parity program Wave 1.3 (our-repo half).
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
feat(stream): sentence-by-sentence /ws/tts via ported chunker (Wave 1.4) (#358)
Ports Patter's SentenceChunker (MIT, attribution header) behavior-identical
— all 61 upstream golden parity scenarios ship as fixtures and pass,
including documented quirks (current_behavior xfail semantics mirrored from
their parity runner). Terminator tables carry functional CJK; file added to
the test_no_hardcoded_cjk allowlist per convention.
/ws/tts now splits the request into sentences and synthesizes each in turn,
streaming the first sentence's PCM while later sentences are still
generating — the time-to-first-audio win on multi-sentence input.
Single-sentence requests behave exactly like the old single-shot path;
'start' metadata still waits for the first generation so lazy-loading
engines report their true sample rate. Italian comma-decimal guard
hard-disables aggressive first-clause flush per upstream.
Spec 8a (docs/competitive-analysis.md) / parity program Wave 1.4.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
feat(tts): unlimited-length generation — sentence-boundary chunking + crossfade (Wave 1.2) (#357)
Ports voicebox's chunked TTS (MIT, attribution header) with two deliberate
changes: the concat half is reworked for torch tensors (matching what our
inference helpers feed the effect chain, incl. multi-channel on the last
axis), and the sample rate comes from the engine's declared rate instead
of the first chunk (fixes a latent upstream bug).
Long text (> max_chunk_chars, default 800) splits at sentence boundaries
(abbreviation/decimal-aware, bracket tags atomic, fullwidth enders via
unicode escapes for the CJK gate) -> per-chunk generation with
deterministic seed variation (seed+i) -> linear crossfade join (default
50 ms, 0 = hard cut) -> effect chain + watermark once on the joined audio.
Wired into BOTH inference paths (OmniVoice-native _run_inference and the
engine-adapter _run_backend_inference) beside the existing [pause]
stitcher; [pause] inputs keep their dedicated path. Short text is
byte-for-byte the old single-shot path; max_chunk_chars=0 disables.
New /generate form params: max_chunk_chars (>=0, default 800),
crossfade_ms (0-1000, default 50).
Tests: 15 model-free unit tests (split priorities, abbreviation/decimal/
tag guards, crossfade math incl. multichannel + clamping) + 3 stubbed-
engine endpoint tests (long text fans out with no words lost, short text
single-shot, 0 disables). Endpoint tests validated in CI — this machine
has a pre-existing local torch/Triton segfault on any main-importing test.
Spec: voicebox deep dive 1 / parity program Wave 1.2 / #346
unlimited-length item.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
feat(dictation): collapse Whisper hallucination loops in final transcripts (Wave 1.1) (#356)
Deterministic pre-pass ported from voicebox (MIT, attribution header):
word-level (token repeated >=6x, punctuation-normalized) + character-level
(2-60-char unit repeated >=6x, catches multi-word and no-space-script
loops). Rhetorical repeats below 6 survive; no LLM involved; identical on
every platform. Applied to the FINAL text in /ws/transcribe and POST
/transcribe — segments keep raw recognition so timings stay truthful.
Phase 1 of Spec 3 (docs/competitive-analysis.md); the optional local-LLM
refinement pass (phase 2) lands with parity program Wave 2.1 in the same
module.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
feat(profiles): consent-locked voice profiles — verified_own_voice + spoken consent flow (Wave 0.2) (#354)
* feat(profiles): consent-locked voice profiles — verified_own_voice + spoken consent flow (Wave 0.2)
A profile becomes 'verified own voice' when its owner records themselves
reading a consent statement (spoken attestation, not a checkbox). Agentic
features and gallery sharing will gate on the flag; plain local synthesis
never does.
- alembic 0003 (additive, PRAGMA-guarded, downgrade supported) +
_BASE_SCHEMA columns: verified_own_voice, consent_text,
consent_audio_path, consent_recorded_at
- POST/DELETE /profiles/{id}/consent — stores the recording as provenance
in VOICES_DIR ({id}_consent.*), replaces on re-record, cleans up on
revoke and on profile delete; 422 on empty statement / too-short audio
- VoiceProfile page: Verified badge + Voice ownership panel (record via
the existing useRecording denoise flow, revoke with confirm); en.json
keys only (other locales fall back per the advisory i18n parity policy)
Spec: docs/competitive-analysis.md Action 22 / parity program Wave 0.2.
Prerequisite for agentic v2/v3 and the persona gallery.
* fix(profiles): harden consent paths against py/path-injection; drop lifespan in tests
- _voices_path(): resolve DB-stored filenames strictly inside VOICES_DIR
(bare-filename check + realpath containment); extension whitelist on the
uploaded consent filename (fallback .wav) so a crafted filename can never
steer the on-disk path. Applied to write, re-record cleanup, revoke, and
profile-delete cleanup. New test: malicious upload filename falls back.
- Test fixture no longer runs the app lifespan: startup/shutdown touched
module-level asyncio primitives bound to another module's event loop,
making the suite order-dependent in full-suite CI. init_db() is called
directly; endpoints under test need only the schema.
Fixes the CodeQL (3x py/path-injection high) and full-suite event-loop
failures on PR #354.
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
test(evals): LLM-judge eval tier — non-gating semantic suites (Wave 0.3) (#355)
Ports Patter's eval harness (MIT, attribution headers) into tests/evals/
with the judge transport swapped to services/llm_backend.py — the judge
runs against whatever local Ollama/LM Studio/OpenAI-compat endpoint the
user configured, keeping local-first. Both Patter hardening details kept
verbatim: verdict recomputed locally from the score (hallucinated
'passed: true' at score 0.2 fails), and tolerant JSON parsing (fences
stripped, invalid JSON -> fail-with-reasoning). Per-case containment:
agent exceptions keep the partial transcript and still judge it; a judge
failure records score 0 instead of aborting the suite.
HARD RULE preserved: LLM judges never gate CI. The scheduled workflow
(weekly + dispatch) is continue-on-error with the JSON report as artifact;
run_evals.py exits 0 always and skips cleanly when the active LLM backend
is 'off'. Deterministic probe judges remain the only gates; the harness
unit tests (10, no LLM needed) do run in gating CI.
First suite: dub translation naturalness v1 (4 cases) driving the real
cinematic_refine_sync reflect+adapt chain. The telephony-specific
session/assertions layers were deliberately not ported. The
dictation-refinement suite lands with Wave 1.1/2.1.
Spec: docs/competitive-analysis.md Spec 9b / parity program Wave 0.3.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
ci(docs): daily docs-drift job — canonical inventory vs README/docs/registries (Wave 0.1) (#353)
docs/features.yaml is the curated single source of truth (12 features,
11 TTS + 7 ASR engine ids, required install docs). scripts/check-docs-drift.py
diffs it against README.md, docs/, and the engine registries — parsing
registry keys from source so the CI runner never imports torch. The daily
workflow updates ONE rolling 'docs-drift' issue in place and auto-closes it
when clean (pattern adapted from Patter, MIT). Self-test includes a
real-repo-is-clean gate, so any PR that changes engines/features without
updating the inventory fails CI too.
Spec: docs/competitive-analysis.md Spec 9a / parity program Wave 0.1.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
docs(specs): ElevenLabs-parity program — waved implementation plan from #346 + #345 (#349)
Turns the discussion #346 roadmap and the competitive-analysis research (#345)
into an executable program of small PRs: 6 waves, dependency-aware, each item
citing its Spec/§R section with effort and acceptance criteria. Accounts for
Smart Fit Phase A (#347), the timeline editor (#348), and Scalar (#307) having
already shipped. Telephony explicitly deferred behind guardrails + two spikes.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
docs: competitive analysis v2 — second-tier landscape, source deep dives, action specs, market sentiment (#345)
* docs: expand competitive analysis — second-tier landscape, deep dives, action specs, market sentiment
Second research pass over PR #339's analysis (six parallel agents):
- Second-tier landscape: 13 projects surveyed, 7 profiled; KrillinAI/KlicStudio
promoted to direct-competitor status
- Source-level deep dives: voicebox + Patter (MIT, portable briefs) and
pyvideotrans (GPL, clean-room functional specs incl. the full _rate.py
decision tree with verified constants)
- pyvideotrans's OmniVoice integration verified broken (Gradio /_clone_fn vs
our FastAPI :3900) — Action 11 reframed as fix-the-bridge
- Implementation specs mapping all ranked actions onto our codebase
- User-sentiment + market-positioning research (issue clustering, ElevenLabs
pricing pressure, honest verdicts on our five differentiators, name-collision
risk, four positioning moves)
- Three stale matrix grades corrected (docs-drift CI, eval harness, MCP)
* docs: ground the #346 roadmap in research — agentic voice, remote GPU, audiobooks, persona gallery, model/env management
Third research pass (four agents + five verification sub-agents) adding a
'Roadmap directions' section that maps every item from discussion #346 to
either an existing spec or new research:
- Agentic voice workflow: pipecat (BSD-2) as the license-clean in-process
runtime; honest telephony constraints (no local PSTN path — opt-in carrier
creds only); FCC/TCPA, Texas SB 140, ELVIS Act, EU AI Act Art 50
(2026-08-02, OSS exemption does not cover it); six concrete guardrails;
v1/v2/v3 scope ladder
- Remote GPU/Tailscale/remote API: base-URL + bearer-token consensus pattern;
175k-exposed-Ollama cautionary tale; Tailscale rung (a) docs-only; vLLM
drop-in for llm_backend; Scalar already shipped (#307), remaining work is
OpenAPI hygiene
- Audiobook creator + persona gallery: ACX technical-spec mastering bar;
ebooklib/PyMuPDF/mobi AGPL/GPL parser traps with clean alternatives;
unoccupied consent-aware-gallery territory; .ovsvoice portable format
- Model/env + GPU compat: uv link-mode dedupe math (measured wheel sizes);
two-dimensional (torch x cuda-variant) -> sm_XX compat matrix; HF cache as
single source of truth (hf cache ls/rm/verify); preflight gate + loud
CPU-fallback banner vs the Ollama/voicebox silent-fallback antipattern
- Eight consolidated new actions (15-22)
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
feat(dub): timeline segment editor — drag, snap-to-onset, keyboard a11y (#280) (#348)
* feat(dub): full-track speech-onset detection + GET /dub/onsets/{job_id} (#280)
detect_speech_onsets() lists every speech rise across the track (frame RMS,
adaptive threshold, 150ms hysteresis) — powers the timeline editor's
snap-to-onset ticks. Route prefers the Demucs vocals stem, falls back to the
mix, and caches onsets.json per job (mtime-invalidated).
* feat(dub): timeline editor math core — windowing, snap, clamp, fingerprint-safe commit (#280)
Pure helpers for the segment track: binary-search windowing, snapTime with
deterministic ties, neighbour/min-duration clamps with Alt-overlap (<=200ms),
commitMoveResize with fingerprint parity (move touches only start/end; resize
sets speed exactly like the old Regions handler and DELETES the key at 1.0 so
_canon_value's missing-vs-1.0 hashing can't mark untouched segments stale),
and overlap detection.
* feat(dub): SegmentTrack editing lane replaces the Regions plugin (#280)
Custom DOM segment boxes (6px edge handles, body-drag move, speaker colors,
stale/fresh tint, hatched overlap warning) virtualized by time over a single
{pxPerSec, scrollLeft} alignment source read off WaveSurfer's wrapper.
Snap-to-onset ticks on a viewport-sized canvas light up in snap range;
Ctrl/Cmd-wheel zooms centered on the cursor; double-click plays the slot via
playRange (timeupdate watcher pauses at slot end). Roving-tabindex listbox
keyboard model (arrows / Enter / Shift / Alt / Delete / S) with polite
aria-live announcements. WebKit fallback keeps a self-scrolling lane at a
fixed px/sec. timeline.* strings translated in all 21 locales.
* feat(dub): wire timeline editor — per-gesture undo, id fix, table selection sync (#280)
segmentMoveResize() pushes undo ONCE per gesture (drag commits on pointerup;
keyboard nudges coalesce per focus session) and matches by String(id) — the
old parseInt('seg-3_a') path edited the wrong segment after a split. Commits
go through commitMoveResize for fingerprint parity, and the existing
recomputeIncremental effect picks up every commit. Clicking a timeline box
scrolls + highlights its row in DubSegmentTable; 'preview dub here' parks
the player at the slot start, then synthesizes the line.
* fix(dub): inline the onsets-cache containment guard — CodeQL can't track helpers
Same lesson as #328/#329: the realpath+startswith sanitizer must sit at
the sink, not behind a function return. Unused helper removed.
---------
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
feat(dub): Smart Fit timing strategy — planner, fingerprints, generate path (phase A) (#347)
* feat(dub): Smart Fit planner, fit fingerprints, shared ffmpeg stretch helpers
- services/fit_planner.py: pure, I/O-free planner for dub-length fitting
v2 — slack absorption (gap guard), audio-only band (<=1.2x), geometric
50/50 audio/video split capped at 1.5x / 2.0x, residual overflow
accounting, and a stretch_video-compatible video_plan + fitted timeline
cursor. Clean-room reimplementation from a published description.
- services/incremental.py: fit_fingerprint() over the fit params with the
same _canon_value canonicalisation as segment hashes (#281 class).
Fit params stay OUT of segment_fingerprint — a fit change re-mixes,
never re-TTSes.
- services/ffmpeg_utils.py: move _atempo_chain/_pitch_preserving_stretch
out of the dub_generate router (lazy torch/numpy imports) so the Phase B
export pipeline can reuse them; add probe_duration() ffprobe helper.
- schemas/requests.py: timing_strategy gains "smart_fit"; optional
fit_options knob overrides default server-side.
* feat(dub): smart_fit branch in the generate path
TTS loop unchanged (dur_s=None, natural-rate WAVs on disk). After the
loop, plan_fit() decides per segment; the mix loop applies audio_rate via
the pitch-preserving atempo pipe (linear-interp fallback), trims residual
overflow with the existing fades, and places audio at the planned
new_start on a fitted-length canvas. Truthful fit_status entries
(audio_rate / video_ratio / overflow_s) feed the row badges.
Persists job["fit_plans"][lang] = {plan (exact
_build_video_stretch_filter_graph shape), fitted_segments (cue times from
ACTUAL stretched sample positions), total/orig duration, params, fit_fp}
and mirrors fit_fp on dubbed_tracks[lang]. video_stretch_plans untouched.
Strategy-transition guard: job["seg_wav_kind"] records whether on-disk
seg WAVs are natural or slot-squeezed; a smart_fit partial regen over
slotted (or unknown) WAVs forces one full regen instead of
double-compressing. Old strategies and old persisted jobs are
byte-identical (all new reads via .get()).
* feat(ui): Smart Fit option in the dub timing picker (all 21 locales)
- prefsSlice: TimingStrategy union gains 'smart_fit'; optional FitOptions
overrides (null by default — backend defaults apply identically on
every platform); persisted alongside timingStrategy.
- DubTab: Segmented gains Smart Fit with i18n label + tooltip.
- useDubWorkflow: sends fit_options only when set and strategy is
smart_fit. Default strategy stays 'concise' — no default behaviour
change on any platform.
- locales: dub.timing_smart_fit{,_title} translated in all 21 languages.
* test(dub): fit planner unit + golden suites, smart_fit generate-path integration
- test_fit_planner.py: threshold boundaries (0.9/1.0/1.2/1.21/4.0), cap
saturation -> overflow, slack absorption incl. gap guard, last-segment
tail, cursor monotonicity, allow_video_retime=False, video_plan fed
straight into _build_video_stretch_filter_graph, fit_fingerprint
canonicalisation (int vs float, omitted vs default — the #281 class)
and a pinned stable digest.
- tests/fixtures/fit_planner/*.json: 4 golden FitPlans; algorithm drift
is a deliberate fixture diff, never a silent change.
- test_smart_fit_generate.py: hermetic end-to-end runs (mock TTS, no
ffmpeg) covering audio-only stretch, hybrid timeline growth +
persisted plan shape, fit_options override, strict_slot->smart_fit
forced regen then zero-TTS fit-only re-mix, and concise back-compat.
* docs(competitive): dub-length fitting row reflects Smart Fit Phase A
* fix(incremental): mark fingerprint hashes usedforsecurity=False — dedup keys, not security (Bandit)
---------
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
docs: model-source support policy — verifiable public sources only (#310) (#344)
* docs: model-source support policy — verifiable public sources only
Owner decision (issue #310): the local-loading mechanism stays, but
official support covers only models from verifiable public sources
(HF repos, official releases with license + checksums). Privately
distributed / paywalled model files are use-at-your-own-risk; never
run bundled executables. Mirrored in SECURITY.md as a supply-chain
note. Per the docs-sync rule, shipped alongside the policy decision.
* docs: firm up model-source policy — open, public, verifiable only; no private/paid models
---------
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
docs: community docs refresh — README, CONTRIBUTING, SECURITY, SUPPORT, Docker/macOS install (#341)
* docs: refresh community docs to match the project's current reality
- README: download badges now point to releases/latest (were frozen at
v0.2.7); Intel-Mac note (pre-built bundle is Apple Silicon; source
works on Intel; pre-built Intel tracked in #279)
- SECURITY: supported-versions table 0.2.x -> 0.3.x + 0.2.7 legacy row
- docs/install/docker.md: tag mapping matches docker.yml after #338 —
:latest is the rolling main preview, :stable (new) pins releases
- PR template: removed the abolished two-RC/48h-soak ceremony; documents
continuous-to-main
- CONTRIBUTING: new sections — what bot review looks like (CodeRabbit +
Greptile), conventional-commit + issue-link expectations, the quality
gates (cross-platform parity, 21-locale i18n + CJK allowlist, alembic,
engine back-compat, local-first, loopback security posture), and a
contribution-licensing grant that keeps the AGPL + commercial
dual-license viable
- SUPPORT.md: new — channels, before-you-file checklist, expectations
- docs/install/macos.md: Intel caveat aligned with reality
* docs: codify the docs-sync hard rule — behavior changes update their docs in the same PR
* chore(agents): rtk rules for Antigravity — token-compressed tool output
---------
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
ci(release): reinstate macOS Intel (x86_64) build target on macos-15-intel (#342)
Intel MacBook users had no installable artifact: the release matrix only
built aarch64-apple-darwin, and Rosetta 2 cannot run arm64 apps on Intel
(it only translates the other direction) — the rationale in the old
"Intel dropped" comment was backwards. Refs #279.
- Add a native `macos-15-intel` matrix leg (GitHub's designated x86_64
migration target after macos-13 retired Dec 2025; standard image,
supported through Aug 2027) building --target x86_64-apple-darwin
with app,dmg,updater bundles.
- Existing per-TRIPLE steps already carry x86_64-apple-darwin cases
(uv sidecar tar.gz, evermeet.cx ffmpeg/ffprobe — x86_64 Mach-O,
natively correct on Intel), so the leg flows through the same
Bundle/Build/Smoke/Verify steps untouched.
- The PR #290 signing path applies automatically: ad-hoc seal from
tauri.conf.json signingIdentity "-", opt-in APPLE_* stable signing,
and scripts/verify-macos-signing.sh both gated on runner.os == macOS.
- tauri-action includeUpdaterJson merges the new darwin-x86_64 platform
key into latest.json alongside darwin-aarch64, so Intel installs
auto-update on both Stable and Preview channels.
- docs/install/macos.md: table telling users which DMG (aarch64 vs x64)
matches their Mac, and the from-source fallback for old releases.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
fix(tts): pin cudagraph-compiled model inference to one dedicated thread (#315) (#343)
torch.compile(mode="reduce-overhead") captures CUDA graphs whose state is
thread-local (torch/_inductor/cudagraph_trees keys its tree manager off the
capturing thread). The _gpu_pool ThreadPoolExecutor runs up to 4 workers, so
the first render captured the graph on worker A and a later render dispatched
to worker B replayed mismatched cudagraph state — silently corrupting the
audio (static noise + slowed playback from the second render onward, no
exception, so the #327 eager fallback never fired).
Fix: when the model is compiled with a cudagraph mode, wrap model.generate
(the same single choke point #327 uses) so every call hops to a dedicated
1-thread "compiled-infer" executor — capture and replay always happen on the
same thread, deterministically. A thread-ident re-entrancy guard runs inline
when already on that thread (a 1-worker executor submitting to itself would
deadlock). Installed after the #327 fallback wrapper, so the eager retry path
also runs on the dedicated thread.
No behavior change for CPU / MPS / Windows-no-Triton / compile-disabled
paths: should_torch_compile() gates exactly as before and uncompiled models
keep the full pool.
Closes#315
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
macOS: ad-hoc sign so users open without Terminal + signing/notarization verification (#290)
* chore(release): add macOS signing/Gatekeeper/notarization verification
Codify and enforce the macOS build-signing requirements. The release
pipeline built bundles and had opt-in Apple signing, but never verified
codesign/spctl/notarization — unsigned or broken bundles could ship silently.
- scripts/verify-macos-signing.sh: runs codesign --verify --deep --strict,
spctl Gatekeeper assessment, per-nested-Mach-O signature check, stapler
validate, and (opt-in) notarytool history. Report-only by default (unsigned
dev/preview is expected); --require-signed fails on any unsigned/un-notarized
component so a broken release stops instead of publishing an unsigned artifact.
- scripts/macos-dev-unquarantine.sh: local-dev-only quarantine stripper, with a
loud "never a substitute for notarization" warning.
- release.yml: new "Verify macOS signing" step on the macOS leg — report-only on
unsigned paths, STRICT on the opt-in signed stable path (same condition as
"Configure Apple signing"), so signing/notarization failures fail the job.
- docs/macos-signing-verification.md: the canonical 10-point requirements +
how-to-verify checklist, cross-linked to docs/install/macos.md and DESKTOP_RELEASE.md.
Verified locally: report-only PASS (exit 0) and --require-signed FAIL (exit 1)
against the real unsigned debug .app; release.yml parses as valid YAML.
* feat(macos): ad-hoc sign bundle so users open it without Terminal (no Apple ID)
The "app is damaged and can't be opened" error is caused by a broken/incomplete
code-signature seal (codesign --verify failed: "code has no resources but
signature indicates they must be present") on the quarantined download — there
is no GUI bypass for that variant on modern macOS, forcing users to run `xattr`.
Give the bundle a VALID ad-hoc signature at build time (free, no Apple Developer
account) via tauri.conf.json bundle.macOS.signingIdentity = "-". Verified through
a real `tauri build`: the produced .app is now flags=adhoc,runtime and passes
codesign --verify --deep --strict. A valid seal flips the Gatekeeper prompt from
the un-bypassable "damaged" to the GUI-bypassable "unidentified developer", which
users clear with right-click → Open / Settings → "Open Anyway" — no Terminal.
Still not notarized (that needs the paid Apple ID), so there's a one-time
confirmation rather than a clean double-click. The opt-in Developer-ID path is
unchanged: APPLE_SIGNING_IDENTITY (env) overrides the "-" default on the signed
stable release.
- tauri.conf.json: signingIdentity "-" (ad-hoc default).
- verify-macos-signing.sh: detect ad-hoc tier; report the no-Terminal GUI path
in report-only, still FAIL it under --require-signed (production must notarize).
- docs/install/macos.md: lead the Gatekeeper section with right-click → Open;
keep xattr as fallback for the harsher "damaged"/corrupted-download case.
- docs/macos-signing-verification.md: signing-tiers table + ad-hoc default note.
- release.yml: comment the ad-hoc default + env override on the signed path.
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat(dub): speech-onset alignment + regional dialect targeting (#280) (#330)
Items 1 and 2 from the improvement list:
1. Synchronization — Whisper-family ASR stretches segment starts back
over leading non-speech (intro music, silence), so the dub starts at
0:00 while the speaker starts at 0:02-0:03. New onset_align service
snaps each segment start forward to the first audible vocal onset
(adaptive RMS threshold over the Demucs-isolated vocals when
available). Forward-only and conservative: never moves a start
earlier, ignores sub-100ms shifts, preserves minimum duration,
leaves silent-window segments untouched. Pure NumPy — identical
across platforms.
2. Accent/vocabulary by country — a Dialect picker in the Dub panel
(BCP-47 codes per target language) injects a regional instruction
into LLM translation prompts (OpenAI/Ollama engines and the
Cinematic refine pass): Argentina yields 'Vos sos muy listo', not
'Tú eres muy listo'. Non-LLM engines show a clear hint that the
dialect needs an LLM. New i18n keys translated in all 21 locales.
Item 3 (segment rectangles: move/crop/stretch on the timeline) is a
larger editor feature and stays open on #280.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: mergetest <test@local>
fix(tts): torch.compile failures fall back to eager — generation never fails on unsupported GPUs (#278) (#327)
* fix(tts): torch.compile failures fall back to eager — generation never fails on unsupported GPUs (#278)
On GPU architectures the bundled Triton doesn't support (e.g. Blackwell
sm_120 / RTX 5060), the compiled model dies mid-generation inside the
Dynamo/Inductor/Triton/cudagraph stack — previously surfaced as a fake
'ran out of memory' error and a dead Archetype preview. Now:
- up-front arch gate: skip compile when the GPU's compute capability is
not in this torch build's arch list (OMNIVOICE_FORCE_TORCH_COMPILE=1
overrides for PTX forward-compat setups)
- runtime fallback: model.generate is wrapped once; a compile-stack
failure (classified by exception chain: module, message, traceback
paths — the cudagraph case is a bare AssertionError) logs a warning,
restores the eager module, disables compile for the session, resets
dynamo state, and retries eagerly. Non-compile errors propagate
unchanged.
- the /generate OOM handler no longer mislabels compile crashes as OOM
and points users at the actual remedy.
Fixes#278
* Potential fix for pull request finding 'CodeQL / Empty except'
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
* Potential fix for pull request finding 'CodeQL / Empty except'
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
* Update backend/api/routers/generation.py
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
Co-authored-by: mergetest <test@local>
ci(security): never cancel main scans — merge trains left red ✗ on every intermediate commit (#340)
PR branches keep cancel-in-progress (superseded scans are wasted work).
On main each commit gets its own concurrency group, so a burst of merges
runs every scan to completion instead of cancelling all but the last —
'cancelled' renders as a permanent red ✗ in the commit history even
though nothing failed.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
chore(version): main is always latest release + 1 — rule, bump to 0.3.6, Docker retag, auto-bump job (#338)
Versioning hard rule (owner-set 2026-06-11), codified in CLAUDE.md:
- main's three version sources (tauri.conf.json, Cargo.toml,
pyproject.toml) always carry last release + 1 patch; bumped 0.3.5 ->
0.3.6 now.
- Preview builds stamp BASE-N which now sorts ABOVE the last stable
(0.3.6-N > 0.3.5) — the updater ordering becomes natural and the
Windows MSI ProductVersion wrinkle disappears.
- Docker: :latest = rolling main preview; :stable + :X.Y.Z + :X.Y =
tagged releases. workflow_dispatch still only emits throwaway :sha-.
- release.yml gains a version-bump job: on every stable v* tag it
bumps main to the next patch automatically.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
fix(dub): burn translated subtitles, fix subtitle save JSON error (#309) (#328)
* fix(dub): burn translated subtitles, fix subtitle save JSON error (#309)
Two symptoms, one root: the job kept the original-language ASR transcript
while the editor only sent translated/edited text in the generate request.
- dub_generate now persists the segments the dub was actually generated
from back onto the job (metadata carried over by stable id, fallback
index; text_original retained for dual-subtitle layouts) — SRT/VTT
export and ffmpeg burn-in now render the dub language, not the source.
- The SRT/VTT export endpoints honor the save_path query param the Tauri
save dialog appends (like every other export) and return the standard
JSON envelope — previously they ignored it and returned the raw body,
so the frontend's JSON.parse choked on the SRT cue index ('Unexpected
non-whitespace character after JSON').
- Frontend guards the save response content-type so any future raw-body
response surfaces as a clear error.
Fixes#309
* Potential fix for pull request finding 'CodeQL / Uncontrolled data used in path expression'
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
* fix(dub): use the file's established realpath+startswith containment idiom (CodeQL)
* fix(dub): write subtitle saves from the Tauri process, not the backend (#309)
The backend save_path variant on /dub/srt and /dub/vtt routed a
user-controlled destination through the loopback HTTP surface — six new
CodeQL path-injection flows plus two log-injection flows. Subtitles are
small text bodies, so the frontend now fetches them raw and writes the
file via a new save_text_file Tauri command: the OS save dialog in the
trusted process is the write authorization, and the backend never sees
a destination path. Binary exports keep the established save_path flow.
Also strips newlines from user-derived values in the two flagged log
lines.
* fix(dub): leave _native_save byte-identical to main
The newline-strip on the log line moved a path sink onto a changed line,
which made CodeQL re-attribute the long-standing binary-export flow to
this PR as a new alert. The subtitle endpoints no longer feed this
function at all, so restore the exact original line — the baseline alert
stays baseline, and hardening pre-existing flows belongs in its own PR.
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
docs: competitive analysis — voicebox, pyvideotrans, Patter (feature matrix + ranked adoption plan) (#339)
* docs: competitive analysis — voicebox, pyvideotrans, Patter
Feature matrix vs our self-inventoried maturity grades, license-aware
reuse verdicts (MIT = port with attribution, GPL-3.0 = reimplement only
— copied GPL files would break the AGPL + commercial dual-license), and
an 11-item ranked action plan with effort estimates.
* docs: append Chatterbox engine evaluation to the competitive analysis
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: mergetest <test@local>
chore(review-bots): diagrams + ASCII UI sketches in every PR walkthrough (#337)
* chore(review-bots): visual walkthroughs — diagrams for mechanics, ASCII sketches for UI
CodeRabbit: enable sequence_diagrams explicitly and instruct the
high-level summary to sketch UI changes as compact ASCII before/after
and behavior changes as a small mermaid flow. Greptile: new repo-level
greptile.json turning on the sequence-diagram and summary sections with
matching instructions, plus the project's local-first and cross-platform
hard rules so both bots review against them.
* chore(review-bots): expert-panel review rubrics, pre-merge rule audits, knowledge base
Encode one senior-domain-expert lens per subsystem (ML inference for
backend/services, product frontend for src, desktop systems for
src-tauri, test infra for tests) as path instructions; add non-gating
pre-merge checks for the project's four hard rules (cross-platform
default parity, 21-locale i18n completeness, local-first guarantee,
backward compatibility); feed CLAUDE.md and docs into CodeRabbit's
knowledge base; mirror it all in greptile.json with customContext rules
and strictness tuning.
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
feat(setup): unified first-run journey — install gate, studio-console wizard, platform awareness (#295)
* feat(setup): first-run install gate — nothing installs until the user confirms a plan
New `setup` module parks first runs in BootstrapStage::AwaitingSetup instead
of auto-installing. complete_setup validates the user's InstallPlan and only
then starts the existing bootstrap:
- install modes: installed (platform dirs) / portable (one folder next to
the exe / AppImage, config.json travels with it)
- user-chosen storage: env dir, data dir (OMNIVOICE_DATA_DIR), model cache
(OMNIVOICE_CACHE_DIR) — None = legacy default, byte-identical behavior
- minimum-space gate: per-volume free-space check (fs4 statvfs), grouped by
filesystem so dirs sharing a disk sum their requirements; install refused
when short (9 GiB env + 7 GiB models + 1 GiB data, measured + headroom)
- custom mirrors (PyPI index, HF endpoint, python-build-standalone) take
precedence over region presets in the venv/sync/backend env wiring
- ROCm torch variant selectable via config (env var still wins)
- existing installs migrate silently: venv present → setup_complete=true,
no questions re-asked; dev trees skip the gate entirely
19 unit tests (disk probing, space grouping, mirror validation, legacy
config compat).
* feat(setup): first-run setup screen — mode, storage with space gate, mirrors, compute
FirstRunSetup renders when the Rust side reports awaiting_setup (lazy-loaded;
regular launches pay nothing). One screen, defaults all work:
- language picker first (rest re-renders translated), 21 locales shipped
- Installed / Portable mode cards (portable disabled with reason when the
exe-adjacent folder isn't writable)
- storage rows with live per-path free-space probes (debounced
check_install_target), 'needs ~X / Y free' readouts, folder pickers
- client mirrors the Rust per-volume space gate: Start installation is
disabled with an explicit reason until every volume fits
- compute (CUDA-auto / ROCm), update channel, region + custom mirror URLs
- complete_setup errors surface inline; on success the normal bootstrap
progress UI takes over on the next status poll
Verified on a wiped machine: gate parks (no spawn, no downloads), screen
renders, 450 GB ≥ 17 GB requirement → Start enabled.
* feat(setup): studio-console redesign of the first-run screen
The setup screen now reads as powering on studio hardware rather than a web
form — true to a voice studio, and self-sufficient offline (every font and
asset is bundled; a first run may be on a restricted network):
- breathing waveform masthead (CSS-only, deterministic speech-cadence
silhouette, staggered per-bar delays)
- Source Serif 4 display headline + engraved IBM Plex Mono panel labels +
Inter body — the three faces the app already ships
- rack-unit panels with corner screws, engraved title rules, serial plate
(OVS · vX.Y.Z)
- disk space as segmented LED capacity meters: lit = what the install
consumes, alarm-blink red on insufficient volumes
- mode cards with indicator LEDs; 'armed' Start button — LED lights and a
halo pulses only once every volume passes the space gate
- atmosphere: corner accent glows + SVG film grain; staggered rise-in
choreography on load
- all motion transform/opacity only; prefers-reduced-motion holds every
frame still; theme-token derived colors; focus-visible rings throughout
No logic changes: same IPC calls, same i18n keys, same space-gate math.
* feat(setup): wide desktop deck, hardware-aware Compute + Update channel cards
Three pieces of feedback addressed:
- width: the console is now a 1240px two-column deck (storage rail left,
decision rail right) that uses desktop real estate; collapses to one
column under 980px and stacks fully under 620px
- no outer chassis box: panels float directly on the atmospheric backdrop,
each carrying its own rack-unit treatment
- Compute and Update channel split into separate cards with real
information: get_setup_state now detects hardware (nvidia-smi → CUDA
name, /sys/class/drm vendor 0x1002 → AMD/ROCm, Apple Silicon → MPS,
CPU cores + RAM via sysinfo; best-effort, never blocks) — the Compute
card shows a live 'Detected: …' readout, badges the option that matches
the machine, and pre-selects ROCm on AMD boxes; both cards use LED
radio options with full descriptions (6 new i18n keys × 21 locales)
Also pins playwright-core as an explicit devDep — bun did not materialize
it through @playwright/test, breaking programmatic browser use.
20/20 Rust tests · vite build · CJK guard green. Verified live (gate
engaged, responsive single-column) and at 1600×1000 via mocked-IPC
browser shot (two-column deck).
* feat(setup): move network (region + mirrors) into the masthead with language
Language and download region are the two 'where am I' choices — they now
sit together top-right of the masthead, with the custom-mirrors disclosure
tucked beneath the subtitle. The Network panel is gone, leaving a balanced
deck: Install mode + Storage left, Compute + Update channel right.
* feat(setup): strip the boxes — fills and rules carry the structure
One design rule now: borders only where state demands them. Panels lose
their boxes entirely (engraved mono title + rule separates sections);
option cards, storage rows, selects/inputs, the hw readout, the version
plate and the ghost buttons are all flat fills; active options glow with
an accent tint + LED; blocked rows and errors use a red tint + 2px inset
edge bar instead of a border. The badge chip is fill-only too.
* feat(setup): quiet pass — every element earns its visual weight
- waveform becomes a whisper: 22px trace, 2px bars, ~half opacity — an
ambient signature instead of a billboard
- storage readouts collapse to one mono line ('needs ~9 GB · 449 GB free');
the LED meter now appears only when it carries information (install
would consume >35% of free space, or the volume is blocked) — at 449 GB
free a bar was a meaningless sliver
- Change… buttons go text-quiet (transparent until hover)
- custom-mirrors disclosure right-aligns under the region select it
extends, instead of floating under the subtitle
- version plate moves to the footer next to the disk total — the masthead
keeps only title, subtitle, and the two locale/region selects
* feat(setup): platform-matrix awareness — distro+arch detection, ROCm gated to Linux, no Windows console flash
The install matrix is OS family × distro × arch × GPU vendor, and the
setup screen now both shows it and only offers choices valid for it:
- HardwareInfo gains os_name (distro PRETTY_NAME from /etc/os-release on
Linux, macOS/Windows elsewhere) and arch (x86_64/aarch64) — the detected
line reads 'CachyOS x86_64 · NVIDIA RTX 4070 · 32×CPU · 31 GB RAM',
exactly what bug reports cite
- SetupState gains os; the ROCm option renders on Linux only (wheels
don't exist elsewhere) and complete_setup clamps rocm→auto on
non-Linux as the server-side backstop
- nvidia-smi probe gets CREATE_NO_WINDOW on Windows — no cmd flash on
the first screen a user ever sees
- Apple Silicon → MPS, Intel mac → CPU, ARM Linux → CPU: all matrix
cells resolve through the same base constructor
* feat(setup): unify the whole first-run journey under the studio-console system
Setup → Installing → Model wizard now read as one continuous experience:
the same atmosphere, whisper waveform masthead, serif/mono type, LED
language and quiet fills across all three acts.
- Installing (BootstrapSplash): rebuilt in frs-* — segmented LED journey
meter (completed steps + live byte progress), LED step rail (done=green,
active=pulsing accent, pending=dim), engraved ACTIVITY panel with the
quiet mono log (collapse/copy as text-quiet actions), failure act with
red-tint error + hints + armed Retry. All logic untouched: stage poll,
event subscription + backfill, dedupe, hints, region/language selects.
- Model wizard (SetupWizard): same masthead with the step rail as engraved
mono LED steps top-right, welcome cards as option-card surfaces,
preflight as LED check rows (pass/warn/fail), frs nav buttons with armed
primaries, embedded Model Store / Engines / Dictation panels scroll
inside the act. Old 556-line stylesheet replaced by ~60 lines of glue;
BootstrapSplash.css reduced to a resolving stub.
- FirstRunSetup.css is now the journey's shared design system (step rails,
log panel, banners, hints, wizard chrome, check rows appended).
- 2 new strings (Installing / Activity) translated across all 21 locales.
Validated end-to-end on this machine: setup screen → Start installation →
real venv bootstrap (~10 min) → backend healthy on 3900 → model wizard.
20/20 Rust tests · vite build · CJK guard green · installing act verified
via mocked-IPC screenshot at stage=installing_deps.
* feat(setup): --setup re-entry flag + make the install-plan screen un-stealable
The setup stage is first-run-only by design (completed installs skip it),
but it must be reachable on demand and must actually win the mount when
engaged. Three fixes:
- 'omnivoice-studio --setup' parks the bootstrap in AwaitingSetup on any
launch — checked before the attach-to-healthy-backend shortcut, so a
running backend can't skip past it
- App routing: awaiting_setup now outranks everything (a live backend
answering /setup/status used to route straight to the model wizard);
the wizard additionally requires stage === 'ready' so it can't mount
during the initial stage race
- useBootstrapStage: a transient IPC miss no longer permanently declares
'ready' (which killed the poll loop and silently skipped the setup /
progress screens) — it retries up to 5 ticks before conceding
Plus journey-wide titlebar clearance (content never sits under the GTK
headerbar / macOS traffic lights / Windows controls) and drag-region
mastheads on all three acts.
Verified: mocked-IPC harness with stage=awaiting_setup + a LIVE backend
answering /setup/status renders the setup screen, not the wizard.
* style(setup): remove backdrop decoration — flat surface, state-only emphasis
The corner accent glows and SVG film grain rendered as visible banding /
noise artifacts on many panels — both gone; the journey now sits on a
clean flat chrome background. Also swept the remaining decorative bloom:
the active option card drops its glow shadow (flat accent tint + LED carry
the state), and the armed Start button loses its pulsing halo (the lit LED
already signals actionable). Remaining shadows are functional micro-detail
only: 6px LED glows, meter track inset, red edge bars.
* feat(setup): journey rail + verbosity diet — clean, smooth, elegant
The setup page is now visibly stage 1 of the install flow: a quiet
breadcrumb rail (SETUP → INSTALLING → MODELS & ENGINES) sits between the
waveform and the headline on both the setup and installing acts, LEDs
marking done/active/pending — one continuous story across the journey.
Verbosity halved without hiding information:
- option descriptions unfold (260ms ease) only on the selected card; the
page shows exactly one explanation per group, collapsed cards keep the
text as a tooltip
- storage rows drop their always-on caption (label + path + readout +
Change… on one line; caption lives in the row tooltip)
The whole page now fits a laptop window without scrolling.
* feat(setup): merge Models + Engines into one wizard act
Two tabs weren't necessary: models are the required gate, engines the
optional extras — now two stacked panels in a single 'Models & engines'
step (label reuses the journey-rail key, translated in 21 locales).
Wizard shrinks to 4 steps: Welcome → System check → Models & engines →
Dictation. Continue still gates on models_ready only; engines stay
optional. Welcome cards updated to the 3 remaining acts; static cards
keep their descriptions visible (the active-only fold is for radios).
* fix(setup): wizard was skipped after first-run install — probe /setup/status on bootstrap ready
The models-needed probe started at mount with a ~30s retry ceiling. On a
first run, mount happens at the setup page — by the time the user reads
it and the multi-minute install finishes, the attempts were long burned,
so setupChecked landed as 'no wizard needed' and the studio rendered with
zero models on disk. The probe is now keyed on bootstrapStage and runs
when it hits 'ready' — the first moment a backend exists to answer.
Normal launches (backend up quickly) behave exactly as before.
Caught by running the full journey three times end-to-end: rounds 2–3
skipped Models & engines after install; with the fix the wizard mounts
with models_ready=false (Whisper large-v3 listed missing).
* feat(setup): drop the Welcome step — wizard opens on System check
The welcome act had nothing left to say: the journey rail names the
stages, the setup page already oriented the user, and the cards repeated
both. The wizard is now three steps — System check (auto-runs on mount) →
Models & engines → Try dictation — landing the user directly on live
preflight results instead of a page about the pages to come.
* feat(setup): true unified library — models + engines as ONE list
'Merge them' meant one list, not two panels stacked — fair criticism.
The wizard's Models & engines act is now a purpose-built WizardLibrary:
every installable is a row of the same grammar (LED · name · chip ·
size · action):
- required models lead (REQUIRED chip, Download action, live SSE
progress bar + percent, green LED when installed) — they gate continue
- TTS engines follow (ENGINE chip): active engine glows accent,
available ones offer one-click Use (selectEngine), heavy installs
defer honestly to Settings ('install later in Settings' + reason
tooltip)
- the optional-model tail folds behind 'Show N optional models'
The full management surface (search, HF token, deletes, sorting) stays
in Settings — a first run needs a checklist, not a store. 9 new strings
× 21 locales. Verified against the live backend via the browser harness:
required/installed/engine/active/Use/defer states all render in one list.
* feat(diagnostics): local-first self-check, error journal, and bug-report pipeline (#296)
* feat(diagnostics): local self-check + scrubbed bug-report pipeline
Closes the gap between 'something broke' and 'a useful GitHub issue
exists' — entirely within the local-first constraint: the only outbound
path remains the user's own browser opening a prefilled issues/new URL.
Backend:
- core/scrub.py: privacy scrubber for anything leaving the machine —
env-var secret values (*TOKEN*|*KEY*|*SECRET*|*PASSWORD*), credential
shapes (hf_/ghp_/github_pat_/sk-), home dirs on all three OSes
- core/diagnose.py: 9-check self-check (device+GPU, ffmpeg, HF token,
disk, data-dir writability, RAM, engine registry, hub reachability),
pre-scrubbed, ASCII-safe output
- GET /system/diagnose + 'python main.py --diagnose' (exit 0/1)
- /system/info: hardware inventory (os_version, cpu_model, cpu_count,
ram_total_gb, gpu_name, vram_total_gb, disk_free_gb), cached statics
Frontend:
- utils/bugReport.js: single source for the prefilled-URL builder —
scrubText twin, hardware context capture, scrubbed error+stack embed,
URL-length cap; ReportBugButton refactored onto it
- ErrorBoundary 'Report this bug' action with the error attached
- utils/errorToast.jsx toastErrorWithReport(); wired into export toasts
- Settings > About 'Run self-check' with per-check status badges
Tests: 27 pytest (scrub, diagnose) + 15 vitest (bugReport); existing
suites green; verified live (--diagnose, TestClient, vite build).
* feat(diagnostics): error journal, diagnostic bundle, crash notice, global handlers
Second slice of the bug-tracking work — still zero outbound paths beyond
the user's own browser/file manager.
- core/error_journal.py: deduped ring of recent unhandled backend errors
(fingerprint counts, error_class triage: GPU_OOM, HF_AUTH_FAILED,
PYANNOTE_LICENSE_REQUIRED, DISK_FULL, FFMPEG_MISSING, NETWORK_ERROR),
scrubbed, JSONL-persisted so the error that killed the last run survives
restart. Wired into the global exception handler; 500 bodies now carry
error_class; GET /system/errors/recent.
- core/diagnostic_bundle.py + POST /system/diagnostic-bundle + Settings >
About 'Save diagnostic bundle': zip of self-check report, error journal,
scrubbed log tails — drag onto a GitHub issue; bypasses the ~8k
prefill-URL ceiling.
- crash-on-next-launch: /system/notifications flags a crash logged before
this session started (size vs acked-size in prefs, mtime vs process
start); POST /system/crash/ack; LogsFooter acks on action click.
- utils/globalErrorHandlers.js: uncaught errors + unhandled rejections get
a throttled, noise-filtered 'Report this bug' toast.
- sidecar log parity fix: _tauri_log_candidates() now lists the Rust
sidecar's backend.log/backend_err.log on Linux (XDG state dir) and
Windows (LOCALAPPDATA) — sidecar crashes were only visible on macOS.
Tests: +19 pytest (journal, bundle); suite at 102 passed. Vitest 124
passed; vite build green. Live-verified: journal recorded and classified
a real HF 401 from the test run (HF_AUTH_FAILED, paths scrubbed).
* feat(diagnostics): breadcrumbs, deep self-check, report sweep, issue search
Final slice of the bug-tracking work.
- toastErrorWithReport adopted at the high-traffic failure sites: TTS
generation, dub upload/ingest/transcribe, engine install, engines-matrix
load, voice profile save/delete/test, batch enqueue/cancel/delete.
Validation toasts and cancellations stay plain on purpose.
- utils/breadcrumbs.js: local-only ring of the last 20 action names
(closed-set names only — never content or paths), embedded as a
'Recent actions' section in the prefilled report. Instrumented: view
changes, generate, dub pipeline, export, engine switch.
- deep self-check: /system/diagnose?deep=true and --diagnose --deep load
the active engine and synthesize a short utterance (num_step=4) —
catches 'installed but broken'. 180s time-box, skips during model load,
scrubbed failure detail. Verified live: cold-loaded omnivoice and
produced 2.2s of audio in 43.9s on CUDA.
- 'Search similar issues' action on the ErrorBoundary: scrubbed,
noise-stripped GitHub issue search URL — dedupe before filing.
- bug_report.md template now points at the diagnostic bundle and the
--diagnose CLI so manual reports arrive with the same evidence.
Tests: pytest 107 passed (4 new deep-check tests, CJK gate green);
vitest 218 passed (breadcrumbs + issue-search suites); vite build green.
* docs(diagnostics): self-diagnosis section in troubleshooting + README pointer
Settings > About self-check / --diagnose / --deep / diagnostic bundle are
now the documented first step before the per-error entries — and the
support team's first ask on every issue.
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(setup): flush sticky action bar, global dbl-click maximize, open maximized
First-run polish on the studio-console journey:
- FirstRunSetup: fixed-footer / scrollable-middle layout — mast + decision grid
live in a dedicated .frs__scroll region; the install action bar is the last
flex item, so it sits flush at the window's bottom edge and nothing (e.g. an
expanded compute-option description) can render beneath it on small windows.
- Double-click-to-maximize on the custom borderless titlebar now works on EVERY
drag region (splash, first-run, wizard, main header) via one delegated
listener in main.jsx, on all platforms; removed App.jsx's redundant inline
handler so it doesn't double-toggle. Skips interactive controls in the bar.
- Window opens maximized to the available desktop size (tauri.conf.json).
* fix(diagnostics): quiet Bandit on the journal hash and hub probe
The journal fingerprint is a dedup key, not a security boundary —
usedforsecurity=False. The hub reachability probe gets an explicit
https scheme guard on its constant URL so the urlopen sink is audited.
* fix(setup): address PR #295 review findings — security, lifecycle, privacy, i18n
Security:
- setup.rs valid_mirror: reject plaintext http:// mirror URLs (MITM
supply-chain path into UV_PYTHON_INSTALL_MIRROR / UV_INDEX_URL /
HF_ENDPOINT); explicit http://localhost / 127.0.0.1 / [::1] exceptions
only. Tests extended incl. loopback-lookalike hosts.
- setup.rs detect_hardware: AMD vendor ID alone no longer maps to
kind="rocm" — a cheap ROCm userspace probe (/opt/rocm or rocminfo on
PATH) gates it; bare AMD GPUs report kind="amd" so the UI offers ROCm
without pre-selecting it ("matches this machine" only when verified).
Functional:
- lib.rs/setup.rs --setup re-entry: complete_setup now kills any backend
still serving on the port before retry_bootstrap, so changed
env/mirror/layout settings actually apply instead of re-attaching.
- setup.rs: nvidia-smi probe runs behind a 3 s timeout thread — a wedged
driver degrades to CPU instead of hanging the first-run IPC.
- setup.rs: is_first_run is now a pure read; the existing-install
migration write moved to migrate_existing_install_if_needed, invoked
only from the bootstrap thread (get_setup_state no longer writes).
- setup.rs complete_setup: config save errors now abort setup and surface
in the UI instead of bootstrapping into a stale on-disk layout.
- setup.rs complete_setup: logs default-vs-custom flags instead of the
user's absolute env/data/models paths (privacy rule).
- scrub.py + bugReport.js: also redact forward-slash Windows homes
(C:/Users/<name>, file:///C:/Users/...), ordered before the macOS
pattern so "C:~" residue can't form. Tests added on both sides.
- bugReport.js: context fetches bounded by a 2.5 s AbortController
timeout so report assembly degrades to partial context instead of
hanging on a stalled backend.
- system.py: crash ack is now {size, mtime} (legacy size-only ack still
honored) and /system/logs/clear drops the ack — truncation can no
longer permanently suppress 'crash-last-session'.
- system.py: Linux Tauri-log probe honors XDG_DATA_HOME.
- setup.ts/WizardLibrary.jsx: SetupProgressEvent type now documents the
full phase taxonomy actually emitted (per-file start/progress/done +
install_*/delete_* lifecycle); reducer verified correct against the
backend stream and annotated — a file-level 'done' must not clear the
repo row.
- SetupWizard.jsx: step rail clamps to the highest unlocked step
(preflight/models gates) — no more jumping straight to "Enter studio".
Polish:
- BootstrapSplash.jsx: Waveform heights wrapped in useMemo([bars]) like
its siblings.
- BootstrapSplash.jsx: detectHints returns i18n keys (bootstrap.hint_*)
rendered through t(); translated in all 21 locales.
- SetupWizard.jsx: step rail aria-label localized (setup.step_aria /
setup.step_completed) in all 21 locales.
- FirstRunSetup.css: deprecated word-break: break-word → overflow-wrap:
anywhere; reduced-motion override also stops the frs-hw-pulse LEDs
(.frs-step.is-active LED + .swiz-lib__led--busy).
Deferred (design-level, follow-up PR): --setup re-entry round-tripping of
custom dirs/mirrors into the form (setup.rs), and worker-thread leak on
timed-out deep checks (diagnose.py).
* fix(i18n): translate back-filled keys in all 20 locales, drop inline fallbacks
The reconciliation merge back-filled 16 new keys (about.self_check*,
about.*bundle*, dub.num_speakers_*, errors.*) with English text in
every non-English locale — CodeRabbit flagged 9 locales; fixed all 20.
Interpolation tokens preserved and asserted during the rewrite. Also
removed the two inline English fallback strings in App.jsx
(firstrun.first_sound_*) so copy lives only in locales/*.json.
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: mergetest <test@local>
fix(dictation): microphone permission — OS usage descriptions, WebView grant handler, actionable denied-state UI (#323) (#336)
On Windows 11 the dictation pill (Ctrl+Shift+Space) always reported
"Microphone access denied" even though OS-level mic permission was
granted (Voice Clone worked, backend transcribed fine). Root cause:
no WebView2 PermissionRequested handler was registered, so WebView2
fell back to its own permission UI — which the 300x64 transparent,
undecorated, deliberately-unfocused pill window can never host — and
getUserMedia() rejected with NotAllowedError.
Per-platform fixes:
- Windows (WebView2): register a PermissionRequested handler on both
the main and widget webviews that allows microphone/camera requests
in code, for the app's own origin only (tauri.localhost + dev
loopback). The Windows privacy toggle still applies on top.
- Linux (WebKitGTK): the media-stream enable + permission auto-grant
previously covered only the "main" window — the dictation widget is
a separate WebView and was silently denied. Now applied to both.
- macOS: already correct — NSMicrophoneUsageDescription ships in
src-tauri/Info.plist and wry grants media capture to the app origin;
documented in the shared helper.
Frontend: getUserMedia failures are now mapped by error name
(utils/micError.js) instead of one blanket "access denied" toast —
permission denials get a per-OS "where to re-enable it" hint
(Windows hint now mentions the desktop-apps mic toggle), missing
devices and busy devices get their own messages, and the previously
hardcoded English toast in useRecording goes through i18n. New keys
added to all 21 locales.
Tests: vitest unit tests for the error mapping (19 cases) and a Rust
unit test for the WebView2 origin allow-list; Windows handler code
cross-checked against webview2-com 0.38.2 / windows-core 0.61.2.
Fixes#323
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
fix(scripts): desktop-prod works from cmd/PowerShell via cross-platform launcher (#282) (#333)
`bun run desktop-prod` (and its :run/:upgrade/:pill/:run:pill variants)
invoked `bash scripts/desktop-prod.sh` directly. On Windows, cmd and
PowerShell have no `bash` on PATH unless Git Bash happens to be there,
so the documented from-source install path died with a cryptic spawn
failure before printing anything — the exact first step in issue #282's
repro.
Add scripts/desktop-prod.mjs, a tiny launcher (runs under bun or node):
- macOS/Linux: execs the bash script unchanged — zero behavior change.
- Windows: locates Git Bash via `where.exe bash`, well-known Git for
Windows install paths, or derived from git.exe's location; explicitly
skips C:\Windows\System32\bash.exe (the WSL launcher, which would run
the script inside Linux and wipe/launch the wrong paths).
- No usable bash: prints an actionable error (install Git for Windows,
use `bun run desktop`, or use the installer) instead of a spawn error.
All flags are forwarded untouched and the child's exit code is
propagated. scripts/desktop-prod.sh itself is unchanged, and
docs/install/windows.md now lists Git for Windows as a prerequisite
for from-source installs.
Refs #282
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
chore(probe): standardized PR-report publisher with redaction + review gate (#334)
Turns the ad-hoc 'attach a probe trace to the PR' habit into one script:
redacts credentials/home-dirs/emails/IPs from the HTML report, prints a
markdown digest, prunes old local reports, and only uploads (secret gist +
PR comment) behind an explicit --post --yes after human browser review.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
fix(updater): preview channel offers the newest build across channels (#326) (#335)
Root cause, two layers:
1. tauri-plugin-updater's default comparator is plain semver
(remote > current). Preview builds are published as X.Y.Z-N
(e.g. 0.3.5-41 = main, 41 builds after the 0.3.5 tag), which semver
treats as a *pre-release* of X.Y.Z — so it sorts BELOW stable X.Y.Z.
Once stable 0.3.5 shipped, preview users were told "you already have
the latest version" forever.
2. The endpoint list [preview, stable] is not a "best of both" — the
plugin stops at the first manifest that parses and uses later
endpoints only as network fallbacks, so a reachable preview manifest
hid a newer stable release entirely.
Fix: for the preview channel, check BOTH manifests with a custom
version_comparator implementing cross-channel ordering (higher base
version wins; on equal base a suffixed preview build outranks the bare
stable it was built on; preview-vs-preview uses numeric-aware semver
pre-release comparison), then offer the newest candidate. A manifest
error is non-fatal while the other manifest answers. The stable channel
keeps the single endpoint and the plugin's default comparison —
default behavior unchanged on all platforms.
Adds 7 unit tests covering preview ahead of stable (the bug case),
stable passing preview, equal-base both directions, equal versions
(no ping-pong), numeric build-counter ordering, and base dominance.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
feat(design): free-text 'describe your voice' field maps to design parameters (#317) (#331)
Parity with the hosted omnivoice.app describe field, implemented fully
locally: a deterministic, ordered synonym-table mapper (no model, no
network, stdlib only) projects a natural-language description onto the
existing six-category design space (Gender/Age/Pitch/Style/EnglishAccent/
ChineseDialect). Every emitted token is validated at import time against
the engine taxonomy, so the mapper can never produce an instruct item the
engine validator would reject; Chinese token forms are derived from the
taxonomy, never hardcoded (the one functional pinyin->dialect mapping is
allowlisted in test_no_hardcoded_cjk.py with justification).
UI: a describe textarea in the Design tab fills the attribute picker live
(hand-tuning still possible afterwards); parts of the description the
taxonomy can't express are listed back to the user as 'ignored' instead
of failing silently. New i18n keys in all 21 locales.
Fixes#317
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
fix(dub): re-dub honors transcript edits — fingerprints canonicalised, preview cache-busted, atomic mux (#281) (#329)
* fix(dub): re-dub honors transcript edits — fingerprints canonicalised, preview cache-busted, mux made atomic (#281)
Three symptoms, three causes:
1. Edited line, unchanged result: the dubbed preview-video URL was
identical across re-dubs, so the WebView kept serving the previous
dub. A generation nonce now cache-busts the preview after every
completed generation.
2. Preview stuck loading forever: overlapping preview requests ran
ffmpeg against the same output path and the mtime cache check saw
the half-written file as valid. The mux now runs under a per-path
lock, writes to a temp file, and os.replace()s into place.
3. One edit re-dubs all lines: server-side fingerprints were computed
from pydantic-parsed segments (defaults filled in) but recomputed
client-side from raw dicts (keys omitted), so every segment always
looked stale and incremental degraded to a full re-dub. Values are
now canonicalised on the backend and the frontend builds generation
inputs through one shared helper (utils/segments.js) for both the
generate request and the incremental plan.
Fixes#281
* Potential fix for pull request finding 'CodeQL / Uncontrolled data used in path expression'
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
* fix(dub): realpath containment for job-derived preview paths (CodeQL)
Request-supplied job_id/lang flowed into the preview mux output path.
Both now pass a realpath containment guard against DUB_DIR (the file's
existing per-segment pattern) and lang is allowlist-validated before it
lands in a filename.
* fix(dub): inline the containment guard — CodeQL can't track it through a helper
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
fix(tts): /generate honors the selected TTS engine (#312) (#324)
* fix(tts): /generate honors the selected TTS engine (#312)
The /generate route always ran the OmniVoice model directly, ignoring both
the Settings engine selection and any per-request override. It now resolves
the active backend (env var > Settings selection > default), supports an
explicit `engine` form field (same pattern as /ws/tts and /v1/audio/speech),
reuses the per-process engine instance cache, keeps inline [pause Nms]
markers working on every engine, and honors applies_own_mastering so studio
engines skip the broadcast mastering chain. The OmniVoice default path is
byte-identical to the old behavior — existing API consumers see no change.
* test(312): resolve modules at run time, drop lifespan client — fixes full-suite isolation
tests/backend/** runs before tests/test_*.py and pollutes sys.modules
(re-imports the services tree), so module-level imports bound at pytest
collection pointed at a stale services.tts_backend — registry patches
landed on a dict the routes no longer read ('Unknown TTS engine' in CI).
Modules are now resolved through sys.modules inside each test. The client
fixture also drops the module-scoped lifespan context manager that bound
event_bus queues to this module's loop (teardown 'Queue bound to a
different event loop') — plain function-scoped TestClient, the
test_api.py pattern.
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
fix(bootstrap): self-heal structurally broken venv instead of exiting 106 (#314) (#325)
A venv with no pyvenv.cfg (interrupted creation, half-deleted dir, or a
managed Python that was removed) made the backend exit 106 forever; the
only fix was manually deleting .venv. Bootstrap now (1) validates venv
structure before declaring it ready and (2) recognizes the broken-venv
death signature (exit 106 / 'No pyvenv.cfg file') after spawn — in both
cases it quarantines only the .venv itself (rename-aside if deletion
fails, never user data) and rebuilds through the normal setup path with
existing progress stages. Healing is attempted once per launch; a healthy
venv is never touched. The spawn+health-poll loop is extracted from
lib.rs and shared with the retry path.
Fixes#314
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
fix(design): stop button + single-playback manager for voice previews (#316) (#322)
Voice previews and synthesized outputs could overlap with no way to stop
them: playBlobAudio() fire-and-forgot a fresh Audio()/AudioContext per
call, and each component (Design demo grid, gallery, demo player) kept
its own uncoordinated audio handle.
- Add utils/playback.js: a global single-playback manager. claimPlayback()
stops whatever was playing before registering the new playback, returns
a release() for natural end, and exposes stopActivePlayback() plus a
usePlaybackSource() hook for UI affordances.
- Register every preview/output path with the manager: playBlobAudio
(Synthesize output, profile previews, dub segment previews),
DemoPresetGrid cards, VoiceGallery previews (archetypes / community /
imports), and the CloneDesignTab "Hear demo" player.
- Visible stop affordance: while a synthesized output is playing, the
Design/Clone footer CTA becomes a "Stop playback" button (new i18n key
clone.stop_playback in all 21 locales). Preview cards keep their
existing play/pause toggle, now wired through the manager.
- Tests: unit suite for the playback manager (claim/stop/release/
subscribe semantics) and two DemoPresetGrid regression tests for the
single-playback invariant and the stop toggle.
Fixes#316
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
style(icons): thinner HD icon strokes app-wide + themed native file inputs (#300)
Lucide ships stroke-width 2 on a 24px grid; at the app's 11-16px render
sizes that weight reads heavy. One global rule (svg.lucide) re-weights
every icon to 1.5 with geometricPrecision shape-rendering — crisper,
lighter, no call-site churn. Hand-rolled SVGs (logo mark, batch spinner)
don't carry the .lucide class and keep their bespoke weights; the one
explicit per-icon strokeWidth (archetype icons) is dropped so the global
weight governs everywhere.
Native <input type="file"> chips are now themed via
::file-selector-button mirroring .ui-btn--subtle (chrome tokens, pill
radius, hover states). All current file inputs hide behind themed labels,
but any visible one — future panels, the LAN/share web view — no longer
renders the OS-default grey button.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat(setup): flush action bar, global dbl-click maximize, open maximized (#318)
- First-run action bar is now a pinned flex sibling below a dedicated
scroll region (.frs__scroll) — flush to the window's bottom edge, with
nothing rendering beneath it; only the content above scrolls.
- Double-click-to-maximize is wired once in main.jsx, delegated across
every data-tauri-drag-region (splash, first-run, wizard, main header)
on all platforms, skipping interactive controls. Replaces the
wizard-only handler in App.jsx.
- Main window opens maximized (tauri.conf.json).
- Setup wizard preflight checks flow into responsive columns on wide
windows instead of one tall single column.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
fix(bootstrap): sync venv deps on app upgrade — stale venv crashed on new imports (#307) (#319)
Upgraded installs replaced backend/ + omnivoice/ sources from the bundle
but never refreshed pyproject.toml/uv.lock or re-ran uv sync, so any
dependency added after the user's venv was created was missing at import
time — e.g. a venv predating scalar-fastapi (added May 4) died on
startup with ModuleNotFoundError once v0.3.5 code landed on it.
- bootstrap.rs: refresh pyproject.toml + uv.lock from the bundle whenever
a healthy venv is reused; when the lockfile content changed, run
`uv sync --frozen --no-dev` so newly added deps land. On sync failure
(e.g. offline upgrade) keep the existing venv instead of bricking a
previously-working install.
- bootstrap.rs: the repair path now refreshes manifests first (it used to
sync against the stale lock from when the venv was created) and applies
the restricted-network HTTP env tuning it was missing.
- backend/main.py: scalar_fastapi import is now guarded — it only powers
/docs, so a venv without it must still boot; /docs returns 503 with an
actionable message instead.
Closes#307
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
fix(asr): clone references transcribe via the ASR registry, not the broken transformers pipeline (#308) (#321)
Voice cloning without a transcript fell through to OmniVoice's built-in
load_asr_model() — a transformers pipeline() load of
whisper-large-v3-turbo that fails outright on transformers 5.3 — even
when whisperx / faster-whisper / mlx-whisper were installed and working.
The dub pipeline already used the registry; the /generate clone path
never did.
- services/asr_backend.py: new transcribe_reference() resolves the
active registry backend (honoring auto-detect order and the
OMNIVOICE_ASR_BACKEND override), extracts text from either result
shape (top-level "text" or whisperx-style segments), and degrades to
None on any failure so the model fallback behaves exactly as before.
When the registry itself resolves to pytorch-whisper it defers to the
model's lazy load instead of building a second pipeline.
- api/routers/generation.py: transcript-less references get transcribed
in the GPU pool before inference.
- tests/test_transcribe_reference.py: covers both result shapes,
failure degradation, and the pytorch-whisper deferral.
The remaining half of #308 — pytorch-whisper itself being incompatible
with transformers 5.3 when it truly is the last resort — is tracked in
the issue.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
fix(dub): Timing strategy options never rendered — wrong prop name on Segmented (#313) (#320)
The Timing control passed `options=` to <Segmented>, whose prop is
`items=` (defaulting to []), so the toggle group rendered as a single
empty pill with nothing to click — users had no way to pick
Concise / Stretch Video / Strict slot. Broken since the control was
introduced; every other Segmented call site already uses `items=`.
Closes#313
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
fix(tts): let studio engines skip the broadcast mastering chain (#311)
* fix(tts): let studio engines skip the broadcast mastering chain
apply_mastering() (HighpassFilter + Compressor + 8% Reverb) is tuned for
OmniVoice's 24 kHz clone output. The OpenAI-compatible /v1/audio/speech
route (_run_tts) runs it on every engine, including VoxCPM2 — whose native
48 kHz output is already studio-grade. There the compressor pump and the
reverb tail are audible degradation rather than polish.
Add an opt-out class flag TTSBackend.applies_own_mastering (default False,
so all existing engines are unchanged) and set it True on VoxCPM2Backend.
_run_tts() skips apply_mastering() when the active backend declares it.
Loudness normalisation still runs for every engine (benign peak scale).
* fix(tts): also skip mastering on the streaming route for studio engines
tts_stream.py is the other route that runs the *active* TTS backend
(get_active_tts_backend), so it needs the same applies_own_mastering guard
as openai_compat._run_tts — otherwise VoxCPM2 output is still pumped/reverbed
when streamed. The remaining apply_mastering() call sites (generation.py,
batch.py, batched_tts.py, dub_generate.py) run the OmniVoice model directly
via get_model(), never the active backend, so VoxCPM2 cannot reach them.
* docs(tts): mark OmniVoice-only mastering sites with TODO(#312)
Per review: instead of always-False guards on routes that never run the
active backend, leave a pointer so the applies_own_mastering guard is added
exactly when those routes become engine-aware (issue #312).
fix(gguf): forward speech generation controls (#306)
Co-authored-by: openclawer <bdfzer8@gmail.com>
fix: disable tqdm on non-TTY to prevent OSError on Windows (#305)
* fix: disable tqdm on non-TTY to prevent OSError on Windows (#283)
When running as a Tauri backend (non-TTY stdout), tqdm tries to write
terminal control characters which fails with Errno 22 on Windows.
Set TQDM_DISABLE=1 when stdout is not a TTY during model loading.
* fix: guard sys.stdout against None and fix import ordering (#283)
- Add None check before calling isatty() to prevent AttributeError
- Fix import ordering (sys after re alphabetically)
fix(dictation): macOS auto-paste — don't steal focus, write clipboard natively (#287) (#299)
Dictation via the global shortcut transcribed fine but the text never reached
the target app on macOS, due to two stacked bugs (diagnosed, patched, and
verified by @geektf in #287):
1. The ShortcutState::Pressed handler called win.set_focus(), making the
widget frontmost — the simulated ⌘V from simulate_paste() landed in the
widget instead of the app being dictated into. Skip set_focus() on macOS
(same #[cfg(not(target_os = "macos"))] guard the other widget call sites
already use).
2. With the widget unfocused, the WebView clipboard APIs
(navigator.clipboard.writeText / execCommand('copy')) fail silently in
WKWebView, so ⌘V pasted whatever was previously on the clipboard.
simulate_paste now takes Option<String> and writes the transcript to the
clipboard natively (arboard) before sending the keystroke — no window
focus required. CaptureWidget passes the transcript; copyText() stays as
best-effort for browser (non-Tauri) mode, and the optional param keeps
any text-less call sites working.
cargo check clean (the unreachable_code warning in setup.rs is pre-existing
from #286); frontend node:test suite passes. End-to-end behavior verified by
the reporter on macOS 26 / M4 Pro with both patches applied.
Fixes#287
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(dub): video retry after URL ingest, responsive layout, icon-only toolbar (#304)
Three reported issues in the dubbing editor:
1. Dark video after YouTube ingest: the preview mounted while yt-dlp was
still finalizing the media file — the first load failed (MediaError 2
network / 4 non-media body) and the once-only error handler declared
the source dead, leaving a black box until the project was reloaded.
The error handler now retries with backoff (up to 6× over ~21s) before
giving up; decode errors (3) stay terminal.
2. Responsive/resizable layout: min-width:0 on the split-grid columns
(the classic shrink trap), settings-bar fields get real shrink room
instead of locked min-widths, bulk selects flex, prep-bar overlays are
viewport-bounded, and the segment table's fixed rails narrow at
1100px and collapse speaker/gain entirely below 760px so the text
column keeps usable width at any size.
3. Toolbar: Save / Reset / Export are icon-only with hover tooltips
(+ aria-labels); Generate Dub keeps its label as the primary verb.
Skeleton header matches.
Vitest 196/196 green.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat(setup): optional Hugging Face token in the library act (#303)
The unified library dropped the inline HF-token field the old
ModelStoreTab embed used to provide — so onboarding produced installs
with no token, and users hit the 'speaker diarization disabled' wall on
their first multi-speaker dub. Restored as a quiet disclosure at the
bottom of the Models & engines act: password input → POST
/system/set-env HF_TOKEN (same durable persistence Settings uses),
saved/error states, Enter-to-save. Copy names the concrete benefit
(pyannote diarization) and the local-first promise (token stays on this
machine). 6 strings × 21 locales.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
test(setup): update DictationDemo asset-missing contract to #294 (#302)
The test asserted the component renders nothing when demo clips 404 —
the exact behavior #294 deliberately removed (it blanked the wizard's
Try-dictation act on every real install). New contract under test: the
script cards are asset-gated and disappear; the hotkey card (shortcut +
press-to-verify, zero assets needed) stays.
This was the single failure breaking CI on main since #294 merged
(34 files / 196 tests green with the fix).
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
style(setup): stable scaffold — no layout shift anywhere in the journey (#301)
Fair criticism taken: vertically centering variable-height content meant
every act and step reflowed the page around its own center, and selecting
an option pushed everything below it. The journey now has one stable
scaffold — only the content region changes:
- deck is top-anchored (waveform opens the page right under the titlebar;
the centering dead-zone is gone) and fills the viewport
- footer (serial plate, totals, armed action) is sticky at the bottom
with a soft fade — never scrolls out of view, hugs the bottom when
content is short
- variable text gets reserved space: masthead subtitles hold two lines;
option descriptions move out of the cards into a fixed two-line caption
slot per radio group (aria-live), so switching options swaps text in
place with zero shift — cards themselves are title-only
- description tooltips retained on every card
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat(setup): first-sound ending, a11y pass, orphan-backend EPIPE fix (#298)
* feat(setup): first-sound ending + accessibility pass
First sound — onboarding ends with the product doing the thing: the
moment the studio mounts after the wizard, one short line is generated
locally and played ('Welcome to your studio. Every word you hear was
generated on this machine, just now.' — localized, 21 locales), with a
toast naming what just happened. sessionStorage handoff so it fires only
on the run that completed the wizard; every failure path is silent — a
first impression must never surface an error.
Accessibility:
- WAI-ARIA radio pattern on all option groups: roving tabindex (selected
option owns the tab stop) + Arrow-key navigation, selection follows
focus; groups get aria-labels
- aria-live='polite' on the installing act's stage label so screen
readers hear stage transitions
- contrast: quiet text raised from 0.45–0.55 to 0.6–0.68 opacity — small
visual change, real WCAG gain
* fix(backend): orphaned backend couldn't load models — EPIPE-safe stdio
Caught in the wild by the in-app diagnostic report: when the desktop
shell that spawned the backend dies but the backend survives, its
stdout/stderr pipes close — and transformers' tqdm weight-loading bar
crashes the entire model load with BrokenPipeError on the next write.
Fix: wrap sys.stdout/stderr in utils.hf_progress.SafeFileWrapper (the
same EPIPE-swallowing wrapper the patched hub tqdm already uses) at
startup. Logs are best-effort for a server process; model loading is
not. Progress bars stay alive — they feed the loading-progress UI via
hf_progress listeners, so disabling them was not an option.
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat(setup): onboarding quick wins — trust line, resume reassurance, download ETAs (#297)
Three small high-leverage additions from the onboarding audit:
- trust line on the setup page footer — 'Everything runs and stays on
this machine — no account, no cloud, no telemetry.' The product's
thesis, stated at the moment the user decides.
- resume reassurance on the installing act and (while downloading) in the
model library — 'Interrupted downloads resume automatically — closing
the app is safe.' Kills unnecessary Clean&Retry panic; uv and the HF
hub both genuinely resume.
- ETAs on the long waits: the installing act derives an EMA byte-rate
from successive bootstrap-progress events; library rows aggregate the
per-file rates already on the SSE stream. Shown as '~3m left', only
while a total is known and progress is mid-flight.
3 new strings × 21 locales.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(setup): Try-dictation act was blank — keep the hotkey card when demo clips aren't bundled (#294)
The wizard's final act rendered nothing on installs without the
build_demos.sh sample WAVs (they aren't committed or shipped — every
real install hits this). DictationDemo returned null whenever the asset
probe 404'd, hiding the hotkey card too, even though that card teaches
real things with zero assets: the registered shortcut and live
press-to-verify via the tray-dictate events.
Now only the replayable script cards gate on the bundled WAVs; the
hotkey card always renders, with a hotkey-only lede ('hold, speak,
release — press it now to verify') translated across 21 locales.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
i18n(license): translate AGPL commercial-license strings across 20 locales (#292)
Follow-up to the AGPL relicense (abae6e2): the in-app Commercial License page
strings were updated in English only, leaving 20 locales describing the old FSL
model ("free for internal use, license required for competing products",
"converts to Apache-2.0 in two years" — now false).
- Translate the 5 reworded strings (enterprise.hero_desc/hero_note,
enterprise_faq.a_internal_tools/a_try_before/a_watermark) into all 20
non-English locales: ar de es fr hi id it ja ko nl pl pt ru sv th tr uk vi
zh-CN zh-TW, reusing each locale's existing terminology (Settings → Privacy
path names, formality register).
- Remove the now-orphaned q_apache/a_apache keys everywhere (the renderer block
was already removed app-wide in 07479be's follow-up), restoring
enterprise_faq key parity with en.json across all locales.
- README: one-line macOS first-launch note under the download badges
(right-click → Open / Settings → "Open Anyway", no Terminal) linking to
docs/install/macos.md#gatekeeper-quarantine.
Translations are AI-generated and tone-matched to each locale's existing
strings — native-speaker review welcome.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(release): MSI-legal preview version stamp — numeric pre-release identifier (#293)
The Windows preview build dies in WiX with 'optional pre-release
identifier in app version must be numeric-only and cannot be greater
than 65535 for msi target' because the stamp was BASE-preview.N. Drop
the word: BASE-N is still a valid semver prerelease (sorts below the
stable BASE for the updater channel), unique per run, and MSI-legal.
Failed run: 27096586578 (Windows x64; macOS + Linux built fine but the
publish job was skipped).
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat: first-run setup screen — install mode, storage choice + minimum-space gate, mirrors, compute (#286)
* feat(setup): first-run install gate — nothing installs until the user confirms a plan
New `setup` module parks first runs in BootstrapStage::AwaitingSetup instead
of auto-installing. complete_setup validates the user's InstallPlan and only
then starts the existing bootstrap:
- install modes: installed (platform dirs) / portable (one folder next to
the exe / AppImage, config.json travels with it)
- user-chosen storage: env dir, data dir (OMNIVOICE_DATA_DIR), model cache
(OMNIVOICE_CACHE_DIR) — None = legacy default, byte-identical behavior
- minimum-space gate: per-volume free-space check (fs4 statvfs), grouped by
filesystem so dirs sharing a disk sum their requirements; install refused
when short (9 GiB env + 7 GiB models + 1 GiB data, measured + headroom)
- custom mirrors (PyPI index, HF endpoint, python-build-standalone) take
precedence over region presets in the venv/sync/backend env wiring
- ROCm torch variant selectable via config (env var still wins)
- existing installs migrate silently: venv present → setup_complete=true,
no questions re-asked; dev trees skip the gate entirely
19 unit tests (disk probing, space grouping, mirror validation, legacy
config compat).
* feat(setup): first-run setup screen — mode, storage with space gate, mirrors, compute
FirstRunSetup renders when the Rust side reports awaiting_setup (lazy-loaded;
regular launches pay nothing). One screen, defaults all work:
- language picker first (rest re-renders translated), 21 locales shipped
- Installed / Portable mode cards (portable disabled with reason when the
exe-adjacent folder isn't writable)
- storage rows with live per-path free-space probes (debounced
check_install_target), 'needs ~X / Y free' readouts, folder pickers
- client mirrors the Rust per-volume space gate: Start installation is
disabled with an explicit reason until every volume fits
- compute (CUDA-auto / ROCm), update channel, region + custom mirror URLs
- complete_setup errors surface inline; on success the normal bootstrap
progress UI takes over on the next status poll
Verified on a wiped machine: gate parks (no spawn, no downloads), screen
renders, 450 GB ≥ 17 GB requirement → Start enabled.
* feat(setup): studio-console redesign of the first-run screen
The setup screen now reads as powering on studio hardware rather than a web
form — true to a voice studio, and self-sufficient offline (every font and
asset is bundled; a first run may be on a restricted network):
- breathing waveform masthead (CSS-only, deterministic speech-cadence
silhouette, staggered per-bar delays)
- Source Serif 4 display headline + engraved IBM Plex Mono panel labels +
Inter body — the three faces the app already ships
- rack-unit panels with corner screws, engraved title rules, serial plate
(OVS · vX.Y.Z)
- disk space as segmented LED capacity meters: lit = what the install
consumes, alarm-blink red on insufficient volumes
- mode cards with indicator LEDs; 'armed' Start button — LED lights and a
halo pulses only once every volume passes the space gate
- atmosphere: corner accent glows + SVG film grain; staggered rise-in
choreography on load
- all motion transform/opacity only; prefers-reduced-motion holds every
frame still; theme-token derived colors; focus-visible rings throughout
No logic changes: same IPC calls, same i18n keys, same space-gate math.
* feat(setup): wide desktop deck, hardware-aware Compute + Update channel cards
Three pieces of feedback addressed:
- width: the console is now a 1240px two-column deck (storage rail left,
decision rail right) that uses desktop real estate; collapses to one
column under 980px and stacks fully under 620px
- no outer chassis box: panels float directly on the atmospheric backdrop,
each carrying its own rack-unit treatment
- Compute and Update channel split into separate cards with real
information: get_setup_state now detects hardware (nvidia-smi → CUDA
name, /sys/class/drm vendor 0x1002 → AMD/ROCm, Apple Silicon → MPS,
CPU cores + RAM via sysinfo; best-effort, never blocks) — the Compute
card shows a live 'Detected: …' readout, badges the option that matches
the machine, and pre-selects ROCm on AMD boxes; both cards use LED
radio options with full descriptions (6 new i18n keys × 21 locales)
Also pins playwright-core as an explicit devDep — bun did not materialize
it through @playwright/test, breaking programmatic browser use.
20/20 Rust tests · vite build · CJK guard green. Verified live (gate
engaged, responsive single-column) and at 1600×1000 via mocked-IPC
browser shot (two-column deck).
* feat(setup): move network (region + mirrors) into the masthead with language
Language and download region are the two 'where am I' choices — they now
sit together top-right of the masthead, with the custom-mirrors disclosure
tucked beneath the subtitle. The Network panel is gone, leaving a balanced
deck: Install mode + Storage left, Compute + Update channel right.
* feat(setup): strip the boxes — fills and rules carry the structure
One design rule now: borders only where state demands them. Panels lose
their boxes entirely (engraved mono title + rule separates sections);
option cards, storage rows, selects/inputs, the hw readout, the version
plate and the ghost buttons are all flat fills; active options glow with
an accent tint + LED; blocked rows and errors use a red tint + 2px inset
edge bar instead of a border. The badge chip is fill-only too.
* feat(setup): quiet pass — every element earns its visual weight
- waveform becomes a whisper: 22px trace, 2px bars, ~half opacity — an
ambient signature instead of a billboard
- storage readouts collapse to one mono line ('needs ~9 GB · 449 GB free');
the LED meter now appears only when it carries information (install
would consume >35% of free space, or the volume is blocked) — at 449 GB
free a bar was a meaningless sliver
- Change… buttons go text-quiet (transparent until hover)
- custom-mirrors disclosure right-aligns under the region select it
extends, instead of floating under the subtitle
- version plate moves to the footer next to the disk total — the masthead
keeps only title, subtitle, and the two locale/region selects
* feat(setup): platform-matrix awareness — distro+arch detection, ROCm gated to Linux, no Windows console flash
The install matrix is OS family × distro × arch × GPU vendor, and the
setup screen now both shows it and only offers choices valid for it:
- HardwareInfo gains os_name (distro PRETTY_NAME from /etc/os-release on
Linux, macOS/Windows elsewhere) and arch (x86_64/aarch64) — the detected
line reads 'CachyOS x86_64 · NVIDIA RTX 4070 · 32×CPU · 31 GB RAM',
exactly what bug reports cite
- SetupState gains os; the ROCm option renders on Linux only (wheels
don't exist elsewhere) and complete_setup clamps rocm→auto on
non-Linux as the server-side backstop
- nvidia-smi probe gets CREATE_NO_WINDOW on Windows — no cmd flash on
the first screen a user ever sees
- Apple Silicon → MPS, Intel mac → CPU, ARM Linux → CPU: all matrix
cells resolve through the same base constructor
* feat(setup): unify the whole first-run journey under the studio-console system
Setup → Installing → Model wizard now read as one continuous experience:
the same atmosphere, whisper waveform masthead, serif/mono type, LED
language and quiet fills across all three acts.
- Installing (BootstrapSplash): rebuilt in frs-* — segmented LED journey
meter (completed steps + live byte progress), LED step rail (done=green,
active=pulsing accent, pending=dim), engraved ACTIVITY panel with the
quiet mono log (collapse/copy as text-quiet actions), failure act with
red-tint error + hints + armed Retry. All logic untouched: stage poll,
event subscription + backfill, dedupe, hints, region/language selects.
- Model wizard (SetupWizard): same masthead with the step rail as engraved
mono LED steps top-right, welcome cards as option-card surfaces,
preflight as LED check rows (pass/warn/fail), frs nav buttons with armed
primaries, embedded Model Store / Engines / Dictation panels scroll
inside the act. Old 556-line stylesheet replaced by ~60 lines of glue;
BootstrapSplash.css reduced to a resolving stub.
- FirstRunSetup.css is now the journey's shared design system (step rails,
log panel, banners, hints, wizard chrome, check rows appended).
- 2 new strings (Installing / Activity) translated across all 21 locales.
Validated end-to-end on this machine: setup screen → Start installation →
real venv bootstrap (~10 min) → backend healthy on 3900 → model wizard.
20/20 Rust tests · vite build · CJK guard green · installing act verified
via mocked-IPC screenshot at stage=installing_deps.
* feat(setup): --setup re-entry flag + make the install-plan screen un-stealable
The setup stage is first-run-only by design (completed installs skip it),
but it must be reachable on demand and must actually win the mount when
engaged. Three fixes:
- 'omnivoice-studio --setup' parks the bootstrap in AwaitingSetup on any
launch — checked before the attach-to-healthy-backend shortcut, so a
running backend can't skip past it
- App routing: awaiting_setup now outranks everything (a live backend
answering /setup/status used to route straight to the model wizard);
the wizard additionally requires stage === 'ready' so it can't mount
during the initial stage race
- useBootstrapStage: a transient IPC miss no longer permanently declares
'ready' (which killed the poll loop and silently skipped the setup /
progress screens) — it retries up to 5 ticks before conceding
Plus journey-wide titlebar clearance (content never sits under the GTK
headerbar / macOS traffic lights / Windows controls) and drag-region
mastheads on all three acts.
Verified: mocked-IPC harness with stage=awaiting_setup + a LIVE backend
answering /setup/status renders the setup screen, not the wizard.
* style(setup): remove backdrop decoration — flat surface, state-only emphasis
The corner accent glows and SVG film grain rendered as visible banding /
noise artifacts on many panels — both gone; the journey now sits on a
clean flat chrome background. Also swept the remaining decorative bloom:
the active option card drops its glow shadow (flat accent tint + LED carry
the state), and the armed Start button loses its pulsing halo (the lit LED
already signals actionable). Remaining shadows are functional micro-detail
only: 6px LED glows, meter track inset, red edge bars.
* feat(setup): journey rail + verbosity diet — clean, smooth, elegant
The setup page is now visibly stage 1 of the install flow: a quiet
breadcrumb rail (SETUP → INSTALLING → MODELS & ENGINES) sits between the
waveform and the headline on both the setup and installing acts, LEDs
marking done/active/pending — one continuous story across the journey.
Verbosity halved without hiding information:
- option descriptions unfold (260ms ease) only on the selected card; the
page shows exactly one explanation per group, collapsed cards keep the
text as a tooltip
- storage rows drop their always-on caption (label + path + readout +
Change… on one line; caption lives in the row tooltip)
The whole page now fits a laptop window without scrolling.
* feat(setup): merge Models + Engines into one wizard act
Two tabs weren't necessary: models are the required gate, engines the
optional extras — now two stacked panels in a single 'Models & engines'
step (label reuses the journey-rail key, translated in 21 locales).
Wizard shrinks to 4 steps: Welcome → System check → Models & engines →
Dictation. Continue still gates on models_ready only; engines stay
optional. Welcome cards updated to the 3 remaining acts; static cards
keep their descriptions visible (the active-only fold is for radios).
* fix(setup): wizard was skipped after first-run install — probe /setup/status on bootstrap ready
The models-needed probe started at mount with a ~30s retry ceiling. On a
first run, mount happens at the setup page — by the time the user reads
it and the multi-minute install finishes, the attempts were long burned,
so setupChecked landed as 'no wizard needed' and the studio rendered with
zero models on disk. The probe is now keyed on bootstrapStage and runs
when it hits 'ready' — the first moment a backend exists to answer.
Normal launches (backend up quickly) behave exactly as before.
Caught by running the full journey three times end-to-end: rounds 2–3
skipped Models & engines after install; with the fix the wizard mounts
with models_ready=false (Whisper large-v3 listed missing).
* feat(setup): drop the Welcome step — wizard opens on System check
The welcome act had nothing left to say: the journey rail names the
stages, the setup page already oriented the user, and the cards repeated
both. The wizard is now three steps — System check (auto-runs on mount) →
Models & engines → Try dictation — landing the user directly on live
preflight results instead of a page about the pages to come.
* feat(setup): true unified library — models + engines as ONE list
'Merge them' meant one list, not two panels stacked — fair criticism.
The wizard's Models & engines act is now a purpose-built WizardLibrary:
every installable is a row of the same grammar (LED · name · chip ·
size · action):
- required models lead (REQUIRED chip, Download action, live SSE
progress bar + percent, green LED when installed) — they gate continue
- TTS engines follow (ENGINE chip): active engine glows accent,
available ones offer one-click Use (selectEngine), heavy installs
defer honestly to Settings ('install later in Settings' + reason
tooltip)
- the optional-model tail folds behind 'Show N optional models'
The full management surface (search, HF token, deletes, sorting) stays
in Settings — a first run needs a checklist, not a store. 9 new strings
× 21 locales. Verified against the live backend via the browser harness:
required/installed/engine/active/Use/defer states all render in one list.
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
chore(license): relicense from FSL-1.1-ALv2 to AGPL-3.0 (open-core)
Replace the Functional Source License with the GNU Affero General Public
License v3 across the project, with a paid commercial license retained for
proprietary/closed-source use without AGPL obligations (open-core, like
Firecrawl).
- LICENSE: verbatim AGPL-3.0 text under an AGPL Notice + Scope header;
drops the FSL "Competing Use" framing and the 2-year Apache-2.0 conversion.
The bundled omnivoice/ TTS model stays Apache-2.0 upstream (AGPL-compatible).
- Manifests now declare SPDX AGPL-3.0-only: pyproject.toml, Cargo.toml
(normalized from bare AGPL-3.0), and both package.json (added license field).
- README.md / README_CN.md: badge, pricing, commercial-use FAQ, License section.
- en.json: in-app Commercial License copy reworded to AGPL; the false
"converts to Apache 2.0" FAQ removed (+ its renderer block in SupportPage.jsx).
Non-English locale strings still describe the old FSL model and are left for a
follow-up translation pass.
feat(tts): inline [pause Nms] marker for silence in generated speech (#276) (#277)
Lets users insert pauses in the transcript: `[pause]` (350ms default),
`[pause 500ms]`, `[pause 1s]`, `[pause 1.5s]`. Requester confirmed the
`[pause Nms]` syntax (fits the existing marker style).
Implementation is fully opt-in and model-free:
- `omnivoice/utils/text.parse_pause_markers()` splits the text into
`(span, pause_ms_after)` tuples (case-insensitive; bare number = ms; `s`
suffix = seconds; adjacent markers sum; clamped to 10s). Text with no marker
returns unchanged, so existing behavior is untouched.
- `_run_inference` synthesizes each span as today and stitches a `torch.zeros`
silence buffer between them at the `[pause]` points (matching channel
dims/dtype/device); DSP/mastering then runs once over the combined audio.
An explicit overall `duration` isn't split across spans (left to the model
per span).
Tests (no TTS model loaded): tests/test_pause_markers.py covers the parser
(ms/s/default/clamp/leading/trailing/adjacent/round-trip) and the silence
stitching with a fake gen fn (lengths + zeroed regions). Full pause + CJK guard
+ router smoke suites pass (39).
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat(dub): optional speaker-count hint for diarization (#274) (#275)
When a clip has multiple speakers, pyannote's auto-detect sometimes collapses
them into a single "Speaker 1" — so the transcript merges turns and the dub
mixes voices. The diarization-consumption side is correct (overlap-weighted,
distinct Speaker N ids — pinned by a new test), so the collapse comes from
auto-detect itself.
Add an optional speaker-count hint (the reporter's own suggestion):
- backend: `/dub/transcribe-stream/{job_id}?num_speakers=N` (clamped 1–20;
None → auto-detect) threaded to `diar_pipe(audio, num_speakers=N)`. Omitted
entirely when unset so we don't depend on the kwarg in every pyannote build.
- frontend: `dubNumSpeakers` store field + a compact "Speakers" number input
in the dub panel (placeholder "Auto") + i18n; `transcribeStreamUrl` appends
the param; the SSE hook reads the hint at stream-open time.
Tests: tests/test_assign_speakers_from_diarization.py (multi-speaker split,
overlap weighting, label robustness, empty-result safety) +
dub.transcribeUrl.test.ts (param appended only for a positive int). Full
backend diarization + frontend suites pass; CJK i18n guard passes.
Does NOT close#274 — pending the reporter confirming that setting the count
resolves the collapse on their video (can't verify pyannote behaviour without
a CUDA box + the clip).
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
chore(release): v0.3.5 (#272)
Patch release. Version bumped across all sources + lock files; [0.3.5] CHANGELOG.
Ships:
- #270 — speaker diarization fixed on PyTorch >=2.6 (weights_only=True rejected
the pyannote checkpoint's TorchVersion global); the loader now registers the
shared safe-globals allowlist before loading.
Tagging v0.3.5 triggers release.yml (desktop) + docker.yml.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(diarization): register torch safe-globals before pyannote load (#270) (#271)
On torch>=2.6, `Pipeline.from_pretrained("pyannote/speaker-diarization-3.1")`
fails with "Weights only load failed ... Unsupported global: GLOBAL
torch.torch_version.TorchVersion" — PyTorch 2.6 flipped torch.load's default to
weights_only=True and its secure unpickler rejects the checkpoint's metadata
globals. This broke diarization on torch>=2.6 even when the license IS accepted
(reported on v0.3.4, RTX 4070 Ti, license accepted).
The WhisperX VAD load already solved this via
`WhisperXBackend._allow_vad_pickle_globals()` (allowlists TorchVersion,
omegaconf nodes, pyannote metadata, builtins, numpy, …). `get_diarization_pipeline`
just never called it. Reuse it before the diarization load — idempotent,
per-process, verified to register TorchVersion on torch 2.8.
Graceful fallback (silence-gap heuristic) is preserved if anything still fails.
Tests: tests/test_diarization_weights_only.py (allowlist runs before load;
no-token short-circuit). Existing diarization classification tests still pass.
Cross-platform (the torch 2.6 weights_only change affects all platforms).
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
chore(release): v0.3.4 (#269)
Patch release. Version bumped across all sources + lock files; [0.3.4] CHANGELOG.
Ships:
- #255 — PyTorch-Whisper backend works as a standalone fallback (no cuDNN 8,
no OMNIVOICE_PRELOAD_TTS_ASR=1), unblocking Windows+NVIDIA users hitting the
cudnn_ops_infer64_8.dll error.
Tagging v0.3.4 triggers release.yml (desktop) + docker.yml.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(asr): PyTorch-Whisper fallback works without cuDNN 8 or preload (#255) (#268)
Windows + NVIDIA users hit `Could not locate cudnn_ops_infer64_8.dll`:
WhisperX/faster-whisper run on CTranslate2, which needs cuDNN 8, but PyTorch
2.8 ships cuDNN 9 and the side-loaded `cudnn8_compat` libs were missing from
the venv. The PyTorch-Whisper backend should have been the fallback, but it
errored "set OMNIVOICE_PRELOAD_TTS_ASR=1" because it only worked when the TTS
model preloaded an ASR head.
- `PyTorchWhisperBackend._ensure_pipe()` now builds its OWN transformers ASR
pipeline on demand (PyTorch stack → cuDNN 9, no CTranslate2/cuDNN-8), without
loading the full TTS model and without the preload env var. A constructor-
passed pipe (when the TTS model already has one) is still reused. Model is
overridable via OMNIVOICE_PYTORCH_ASR_MODEL.
- dub_core transcribe preflight no longer hard-rejects pytorch-whisper when no
pipe is preloaded — it lazy-loads; any failure surfaces per-chunk with the
real cause.
So a Windows box without cuDNN 8 can switch ASR backend to "PyTorch Whisper"
in Settings → Models and transcription works. Docs: troubleshooting entry.
Tests: tests/test_pytorch_whisper_fallback.py (lazy standalone build, reuse of
a passed pipe, no get_model() call, env override). Full tests/ suite: 700 pass.
Does NOT close#255 — pending the reporter confirming the fallback works on
their machine; the cuDNN-8 install gap (faster-whisper path) is a follow-up.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
chore(release): v0.3.3 (#267)
Patch release. Bumps version across all sources + lock files; adds [0.3.3]
CHANGELOG.
Ships:
- #262 — Settings → About now shows the server's CPU architecture (was the
client browser's platform, e.g. "Win32", in Docker).
- Validates the bash-3.2 checksum CI fix on a real release (the macOS
SHA256SUMS should now upload automatically).
Tagging v0.3.3 triggers release.yml (desktop) + docker.yml (GHCR
:0.3.3/:0.3/:latest).
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(about): show server CPU arch, not the client browser's platform (#262) (#266)
Settings → About → Architecture rendered `navigator.platform` — the *client
browser's* OS. In the Docker/web build that's the remote machine (e.g. "Win32"
when browsing from Windows), not the container, which is misleading.
Expose the server's `platform.machine()` as `arch` on /system/info and render
that instead, so the row reflects the machine OmniVoice actually runs on — for
both the desktop app (local backend) and Docker.
Note: the *blank* version/GPU/RAM/VRAM in the same report were the loopback-gate
403s fixed in v0.3.2 (#261); this PR fixes the remaining architecture row.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(ci): make SHA-256 checksum step bash-3.2 safe (macOS runner) (#265)
The "Compute SHA-256 checksums" step used `mapfile -t` (a bash 4+ builtin) but
macOS GitHub runners execute `shell: bash` as /bin/bash 3.2, which has no
`mapfile`. The step exited 127 ("mapfile: command not found") on the macOS leg,
so `SHA256SUMS-macOS Apple Silicon.txt` was never produced/uploaded for v0.3.1
and v0.3.2 (the binaries themselves shipped fine; only the macOS checksum file
was missing and had to be regenerated by hand each time).
Replace `mapfile` with a portable `while IFS= read -r … done < <(find … | sort)`
loop (works on bash 3.2). Verified on bash 3.2.57: builds the array correctly,
handles spaces in bundle filenames. Linux/Windows legs are unaffected.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
chore(release): v0.3.2 (#264)
Patch release. Bumps the version across all sources + lock files and adds the
[0.3.2] CHANGELOG section.
Ships:
- #261 — "Loopback origin required" 403s in the Docker admin UI (and blank
version): the image now runs in OMNIVOICE_SERVER_MODE so the loopback gate
is relaxed for the headless deployment; desktop loopback boundary unchanged.
Tagging v0.3.2 triggers release.yml (desktop) and docker.yml (GHCR
:0.3.2/:0.3/:latest).
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(server): relax loopback gate in headless server mode so Docker admin UI works (#261) (#263)
In Docker the loopback origin gate (`require_loopback`) is unenforceable:
Docker's NAT rewrites `request.client.host` to the bridge gateway (e.g.
172.17.0.1) even for a localhost-only `-p 127.0.0.1:3900:3900` mapping, so every
request looks non-loopback. The gate then 403s the operator out of the routes
the web UI needs — `/system/*` (incl. `/system/info`, which left the version
blank, re-breaking #249 in Docker) and `/api/settings/*` (HF-token entry) —
surfacing as "Loopback origin required" all over the UI.
Fix: add an explicit, opt-in `OMNIVOICE_SERVER_MODE` flag. When set,
`require_loopback` becomes a no-op; exposure is then governed by the operator's
port mapping plus the optional share PIN (NetworkAccessMiddleware still 401s
unauthenticated non-loopback clients whenever a PIN is set). The Docker image
sets `OMNIVOICE_SERVER_MODE=1` (Dockerfile + documented in compose).
Security: the desktop build NEVER sets this, so its loopback boundary is
unchanged — LAN share guests are still denied the admin/system routes. New
unit tests lock the contract (strict 403 by default incl. the PR #81 vectors;
relaxed only under the flag). Existing non-loopback 403 tests still pass.
Docs: docker.md troubleshooting entry for "Loopback origin required".
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
chore(release): v0.3.1 (#260)
First tagged build of the 0.3 line off main. Bumps the version across all
sources (pyproject + frontend package.json + Tauri conf + Cargo + both lock
files + the source-checkout fallback in core/version.py) and adds the [0.3.1]
CHANGELOG section.
Ships:
- #256 — browser/Docker file-export crash (invoke undefined)
- #249 — version surfaced in web/Docker UI + desktop-only updater hidden
- #255 — transcribe stream now surfaces the real ASR/model-load failure
Tagging v0.3.1 triggers release.yml (desktop binaries) and docker.yml
(GHCR :0.3.1 / :0.3 / :latest).
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(dub): surface real ASR/model-load failures instead of dropping the stream (#255) (#259)
* fix(dub): surface real ASR/model-load failures instead of dropping the stream (#255)
When the transcribe SSE stream died before emitting any event, the UI showed a
misleading generic "Transcribe stream dropped before emitting any segments.
Likely ASR backend failed to load" — hiding the real cause (e.g. a faster-
whisper/CTranslate2 cuDNN load failure, or a missing pkg_resources).
The per-chunk transcribe was already wrapped, but two preflight/setup calls in
the stream generator were not — if either raised, the connection dropped with
no structured error event:
- `get_model()` (preflight) — now wrapped; failures emit a structured `error`
event built via `core.failure.build_failure` (sanitized reason + actionable
hint, e.g. the pkg_resources→setuptools hint).
- `offload_tts_for_asr()` — now non-fatal; an offload hiccup logs and continues
rather than killing the stream.
- The empty-segments guard now sanitizes each chunk error (no home-path/token
leakage) and appends the recognized-failure-class hint.
Adds a regression test: a raising `get_model()` must yield a structured `error`
SSE event carrying the real message, not a dropped connection.
Does NOT close#255 — this makes the underlying cause visible (pending the
reporter's backend log) rather than asserting a specific Windows-CUDA fix.
* test(dub): drive transcribe-stream gen directly (avoid cross-loop teardown)
The regression test for #255 hit the SSE streaming endpoint through TestClient,
whose lifespan created an asyncio Queue bound to a different event loop than the
streaming request — erroring at teardown in the full-suite run. Drive the
route's async generator directly instead: the preflight-error path yields a
single event with no executor/Queue, so it stays isolated from any app loop.
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(version): surface running version in web/Docker UI + hide desktop-only updater (#249) (#258)
The Docker web build has no Tauri runtime, so Settings → About → Version read
`getVersion()` (Tauri-only) and rendered a dash — leaving Docker users unable
to tell which version they were running (issue #249). The update-channel toggle
was also shown there even though the auto-updater is desktop-only.
- Backend: expose the single-source `APP_VERSION` over HTTP — add it to
`/system/info` (`app_version`) and `/health` (`version`). Both are model-free
and the latter is zero-auth.
- Frontend: Settings → About → Version falls back to `info.app_version` when
no Tauri `getVersion()` is available, so Docker shows the real 0.3.x version.
- Frontend: hide the update-channel toggle, update-endpoint row, and the
"Check for updates" button outside Tauri — the Docker image updates by
pulling a new tag, not via the in-app updater.
- Docs: fix the wrong package name in the version-check command
(`omnivoice-studio` → `omnivoice`) and document the new `/health` version
field + the in-UI version row.
Tests: assert `/system/info.app_version` and `/health.version` equal
APP_VERSION (test_router_smoke.py). The stale `:latest` tag itself was already
fixed in #252; cutting a v0.3.x release repopulates it.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(frontend): browser/Docker fallback for file export (closes#256) (#257)
The history-item export button (and the dub/audio export path) called the
Tauri `save` dialog unconditionally. In the Docker web-server build there is
no Tauri shell, so the plugin's internal invoke() dereferences an undefined
__TAURI_INTERNALS__ and crashes with:
TypeError: Cannot read properties of undefined (reading 'invoke')
…which is exactly what users hit when downloading a freshly cloned voice from
the browser/Docker UI.
Fix: extract a shared `browserDownload` helper (utils/download.js) that does a
plain HTTP-blob download via a temporary <a download>, and guard
`handleNativeExport` on `isTauri` — falling back to that helper (streaming the
file already served at /audio/<path>) when no Tauri runtime is present.
`triggerDownload`'s browser branch now reuses the same helper instead of
duplicating the blob-download logic.
Adds utils/download.test.js covering the Content-Disposition parser and the
no-Tauri download path (regression guard for #256).
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(bootstrap): surface setuptools-repair failures + verify pkg_resources (follow-up to #253) (#254)
* fix(bootstrap): surface setuptools-repair failures + verify pkg_resources (follow-up to #253)
Three gaps flagged by review bots on PR #253 are addressed:
1. **Layer-2 repair result captured** (`bootstrap.rs` ~line 481): the
`let _ = run_streaming(...)` that silently discarded network/permission
failures from the targeted `uv pip install setuptools>=75,<80` is replaced
with a `match` block that logs `log::info!` on success and `log::error!`
on failure (consistent with the Layer-3 path).
2. **Post-repair re-verification added** (`bootstrap.rs`): after the targeted
install in Layer 2, a second `import pkg_resources` check is run. If
pkg_resources is *still* absent, a `log::error!` with an actionable
remediation message is emitted before returning. This closes the gap where
bootstrap handed back a known-bad venv that caused the dubbing crash (#248)
with no clear signal in the log.
3. **Test strengthened** (`bootstrap.rs` `setuptools_repair_uses_correct_specifier`):
the test now mirrors the exact `&[&str]` slice used in both repair branches
and asserts `repair_args[2] == "setuptools>=75,<80"` as a single positional
argument. This catches the split-arg regression the review bot identified
(e.g. `["setuptools>=75", ",<80"]`) which would silently install the latest
setuptools and leave pkg_resources absent.
4. **Smoke-test INST-01/02 hardened** (`scripts/smoke-test.sh`): exports
`UV_PYTHON_PREFERENCE=only-system`, `UV_HTTP_TIMEOUT=120`, and
`UV_HTTP_RETRIES=5` before the `uv run` import checks so that failures
reflect real bootstrap regressions, not harness-network timeouts.
`cargo test bootstrap` → 5 passed, 0 failed.
Closes review findings on #253. Related: #248.
* fix(bootstrap): fail clearly when pkg_resources repair fails (PR #254 review)
- ensure_venv_ready now returns None (via fail()) when pkg_resources is still
missing after the targeted setuptools repair, instead of returning a venv that
crashes on the first ASR/dub call. The 'pkg_resources' message routes to the
PKG_RESOURCES_MISSING failure mapping for a clear, doc-linked remediation.
- smoke-test.sh: correct the comment (timeout+retry vars, not a non-existent index var).
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
test(probe): whole-app coverage — dubbing, i18n, engines, security, migration, dictation, design (#247)
* test(probe): expand coverage — dubbing, i18n, engines, security, migration, dictation, design, coverage-critic
Broadens the probe harness from one happy-path spec per layer to whole-app
feature coverage (web, backend, dictation, clone, design), keeping the
Actor/Judge split and offline-by-default + enable-on-demand for heavy paths.
New specs + judges (one subprocess boot shared across backend-touching specs):
- dubbing (L4): segment duration-ratio, SRT/VTT well-formed, export-archive
contents, output language-ID (advisory)
- i18n: locale files valid JSON (gate); orphan-keys + coverage (advisory).
NOTE: surfaced a real bug — all 20 non-en locales carry gallery.cat_*/
bootstrap.lines keys absent from the en reference (reported, not gated).
- engine matrix: active engine available + every unavailable engine explains
why (11 TTS / 7 ASR backends via /engines/*)
- loopback security: system routes reject non-loopback origins (403)
- DB migration: alembic UPGRADE on the seeded omnivoice_data fixture
- Coverage Critic: every declared layer still has a spec (gate) + API inventory
- dictation: streaming-ASR WebSocket /ws/transcribe registered + handshake
- voice design: reuses the audio-correctness ladder
- real ASR round-trip: enable-on-demand (PROBE_E2E=1)
Enriched _boot_runner.py to capture engines/asr/loopback/openapi/ws in ONE
isolated boot (conftest boot_capture session fixture); added env.seeded_data_dir.
13 specs total. probe suite 74 passed / 5 skipped; full repo 687 passed, 0 failures.
* fix(probe): address all 15 unresolved review findings on #247
- coverage.py:22 — use `with open(...)` context to close spec files after
yaml.safe_load (file handle leak)
- _boot_runner.py:80 — store only `type(exc).__name__` for WS errors; drop
raw str(exc) that could leak home paths / secrets into capture JSON
- _boot_runner.py:99 — snapshot DB files before boot; set db_created=True
only when boot creates NEW files (not when fixture already had one)
- dubbing.py:46 — FAIL segments_duration_ratio when validated==0 (guards
against empty/corrupt segment list passing vacuously)
- i18n.py:49 — FAIL locale_valid_json when locales_dir is empty/missing
- i18n.py:7 — fix docstring: locale_no_orphan_keys is advisory, not blocking
- test_probe_i18n.py:59 — assert r.passed is False, not just r.advisory
- coverage_critic.probe.yaml:15 — add "meta" to required layers list
- dub_export.probe.yaml:17 — capture dub_audio in steps before advisory reads it
- migration.probe.yaml:13 — add path_exists(db_path) data-integrity check
- test_probe_asr_e2e.py:33 — os.path.exists → os.path.isfile for PROBE_ASR_SAMPLE
- test_probe_migration.py:24 — assert context["db_path"] (presence) not
db_created (new creation), aligning with the boot_runner fix
Two findings intentionally skipped with reasons (see review thread replies):
test_probe_design.py:36 — offline pattern is intentional; actor step is
bypassed by design throughout the probe suite for CI compatibility
test_probe_engines.py:22 — whisperx pin is intentional; it verifies the
shipped default ASR engine is available out-of-the-box
* fix(probe): ASCII x in dubbing detail (ruff) + run migration judges inside seeded dir
Two regressions from the hardening pass:
- dubbing.py: replace non-ASCII '×' with 'x' (Ruff ambiguous-unicode → Tests lint fail)
- test_probe_migration: move run_judges inside the seeded_data_dir with-block so the
new path_exists check sees the DB before the temp dir is torn down (was always failing)
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(docker): fix stale :latest tag + add push-to-main trigger (#252)
* fix(docker): fix stale :latest tag and add push-to-main trigger (closes#251, addresses #249)
Two bugs caused the Docker image to be stale (showing v0.2.7 inside a
:latest/:0.3.x-tagged image):
1. **`:latest` was never set on tag pushes.** The metadata-action rule
`type=raw,value=latest,enable={{is_default_branch}}` evaluates
`is_default_branch` as false on tag-triggered runs (which run in a
detached-HEAD context, not on the default branch). The tag rule was
replaced with `enable=${{ github.ref_type == 'tag' }}` so `:latest`
is updated on every `v*` tag push.
2. **No trigger for main-branch pushes.** There was no way to keep an
up-to-date `:main` edge image between releases. Added
`push: branches: [main]` which produces a `:main` rolling tag.
Also added a note in the workflow and docs clarifying that the
update-channel toggle (Settings → About) is a Tauri desktop feature and
does not apply to the Docker image (headless web-server build).
* fix(docker): gate mutable tags to push events + guard :latest against prereleases (PR #252 review)
- semver / :latest / :main now require github.event_name == 'push' so a manual
workflow_dispatch only ever emits a throwaway :sha- tag (no mutable-tag rollback)
- :latest excludes prerelease tags (ref contains '-') so an rc/beta can't clobber it
- header + tag-strategy comments corrected (:sha- emits on every trigger)
Addresses greptile + coderabbit review on #252.
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(bootstrap): guarantee pkg_resources in backend venv (closes#248) (#253)
Root cause: the existing-venv fast-path in ensure_venv_ready() only
checks `import uvicorn` before returning — it never verified that
`pkg_resources` (dropped by setuptools≥80, issue #224) was present.
Users who installed before commit 675cc20 (Jun 1) had setuptools 82.x
in their venv; the app launched fine but any dubbing/transcription run
immediately crashed with `ModuleNotFoundError: No module named
'pkg_resources'` via the whisperx → ctranslate2 → import chain.
Reinstalling didn't help because the app re-detected uvicorn and skipped
the repair sync entirely.
Fix (three-layer defence):
1. **Existing-venv health check**: if uvicorn imports but
`import pkg_resources` fails, fall through to the repair-sync path
instead of returning the broken venv. Logs a clear message
distinguishing "uvicorn missing" from "pkg_resources missing (#248)".
2. **Post-repair verification**: after the repair sync succeeds, run
`import pkg_resources` again; if still absent, run a targeted
`uv pip install "setuptools>=75,<80"` to install it directly without
re-resolving the full dependency tree.
3. **Post-fresh-sync verification** (belt-and-suspenders): same
pkg_resources check + targeted pip-install added after every
fresh-install `uv sync`, catching the edge case where the bundled
uv.lock is absent and uv resolves setuptools≥80 from scratch.
All three paths use the same scrub_python_env + apply_uv_http_env
guards already applied elsewhere; safe on macOS/Linux/Windows.
Also adds `setuptools_repair_uses_correct_specifier` unit test and
updates the INST-01 smoke-test comment to reference #248.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
test(probe): spec-driven AI-agent test harness (L1–L5 + HTML report + triage) (#245)
* test(probe): add spec-driven AI-agent test harness (L1/L2/L4/L5 + report + triage)
Introduces `tests/probe/`, a portable, mostly-deterministic test harness built
on the Actor/Judge split: AI agents may drive and self-heal, but verdicts are
always deterministic code + metrics — no LLM on the verdict path.
Layers:
- L1 API: Schemathesis property-fuzz over in-process ASGI (enable-on-demand).
- L2 web: Playwright Driver + deterministic self-heal (id→test-id→text, loosened
CSS) → pluggable Healer; LLMHealer/anthropic_healer for genuine agentic heal.
Judges + self-heal logic unit-tested offline via FakePage; live browser skips.
- L4 media: audio correctness — exists/decode/duration/not-silent/clipping/NaN,
round-trip ASR WER (pure-python, faster-whisper backend), speaker similarity.
No golden-WAV (device-stable metrics only); naturalness is advisory-only.
- L5 env/first-run: fresh-data-dir backend boot in a SUBPROCESS (no session
contamination), asserts health + DB init + endpoint reachability. Docker gated.
Plus: hybrid YAML spec engine + JudgeResult/registry; self-contained HTML report
that auto-opens (suppressed in CI/headless/PROBE_NO_OPEN); Triager that clusters
failures and drafts a prefilled GitHub issue URL (sanitized, no auto-submit) with
a one-click button in the report.
Dependency-light: runs in the base venv; schemathesis/resemblyzer/playwright/
anthropic are enable-on-demand and skip cleanly. Generated reports gitignored.
Full suite green (657 passed); no contamination of existing tests.
* test(probe): add L3 desktop layer (Tauri config-integrity + guarded launch)
Per the architecture decision, desktop E2E is substituted by backend-over-HTTP
(L5) + browser (L2) since Tauri has no official macOS WebDriver. L3 guards the
packaging/shell contract a browser test can't see, against the real
tauri.conf.json (with platform-override merge), running on any platform with no
Tauri toolchain:
- version parity between tauri.conf.json and pyproject (release integrity)
- dev/build wiring (devUrl matches the Vite frontend, frontendDist, before* cmds)
- bundled binaries first-run depends on (uv / ffmpeg / ffprobe in externalBin)
- CSP actually permits the local backend origins (desktop-only failure mode:
packaged app can't reach :3900 while the browser build works)
Adds desktop.py (config load + platform deep-merge + bundle discovery + launch
guard), judges/desktop.py (config_present/config_eq/config_contains/csp_allows),
desktop_smoke.probe.yaml, and tests covering integrity, platform-merge replace
semantics, and a live bundle launch that skips without a built bundle/display.
Full suite green (662 passed).
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix: dub OOM fallback, watermark on /generate, Settings responsiveness (#241 follow-up) (#244)
* fix(dub): whisperx CUDA OOM → CPU fallback instead of a bare 500
Found while exercising the dub pipeline on an 8 GB RTX 4070 Laptop GPU: with
the TTS model + GPU worker pool resident, whisperx's CTranslate2 load of
large-v3 dies with "CUDA failed with error out of memory", and
POST /dub/transcribe surfaced it as an unhandled 500 with no guidance.
WhisperXBackend now catches a CUDA OOM at load and retries on CPU (int8, same
model + accuracy, just slower) after clearing the CUDA cache. Dubbing keeps
working on small/laptop GPUs instead of dead-ending. Only triggers on a CUDA
OOM, so the MPS/CPU paths are untouched (cross-platform parity).
Verified: /dub/transcribe on the prepped job went 500 → 200 with correct
segments. Added a deterministic unit test (forces the OOM, asserts the device
switches cuda→cpu; a non-OOM RuntimeError still propagates).
* fix(watermark): embed invisible watermark on /generate output, not just dubs
embed_watermark was wired only into the dub pipeline (dub_generate.py), so
plain TTS from /generate came out unmarked even with invisible watermarking
enabled — i.e. the setting silently did nothing for the main generate path.
Embed it on the final audio in the generate handler too. embed_watermark
self-gates on the setting + AudioSeal availability and passes audio through
unchanged on failure, so it's a no-op when off and never breaks generation.
Verified: detector on a fresh /generate clip went is_watermarked:false →
true, confidence 1.0, message OMNI ("OM"), is_omnivoice:true.
* fix(settings): wrap the settings sub-nav so tabs don't clip out of view
The settings sub-nav has 10 tabs (General…Privacy) but the shared .ui-tabs
primitive is a non-wrapping inline-flex row, so on a narrow Settings pane the
later tabs (Credentials/Logs/About/Privacy) overflowed the right edge and were
unreachable. Scope flex-wrap to `.ui-tabs.settings-tabs-ui` only — the bar now
grows to 2–3 rows instead of running off-screen. The shared primitive (used by
the models role tabs, log-source tabs, etc.) is unchanged.
Verified at 900px (2 rows) and 700px (3 rows): all 10 tabs visible.
* feat(settings): connect active tab to content via accent + tighten spacing
Make the active settings tab read as connected to the panel below: each tab
carries its own semantic accent (already in TAB_DEFS — Models pink, Engines
purple, …) instead of a uniform pink, and that accent is threaded down as
--settings-accent to paint a matching hairline along the top of the content
panel. The shared colour ties tab→content subtly and wrap-proof (no fragile
positional connector). Content wrapped in .settings-content with deliberate
margin/padding so it breathes under the bar; the bar's own bottom margin is
dropped so the bridge owns that gap.
* fix(engines): make the compatibility matrix responsive (scroll, don't overlap)
On a narrow Settings pane the matrix's fixed-width columns (status/gpu/
isolation/actions ≈ 630px) plus the flexible name column couldn't fit, so the
cells collapsed and OVERLAPPED — name text rendered under the AVAILABLE/ACTIVE
badges and GPU chips. Give the table a horizontal-scroll container with a
shared header/body min-width (840px) and stop the fixed cells from shrinking,
so columns keep their shape and stay legible at any width (scroll for the
overflow) — the same data-table treatment used elsewhere. Fills normally on
wide panes.
* feat(settings): border-connect the active tab to its content panel
Refine the tab→content connection from a single accent hairline to a
"border-connect": the pill bar opens at its bottom (flat corners, no bottom
border) into a 3-sided panel (.settings-content) framed in the active tab's
accent, with a 2px full-accent top edge at the seam. The bar + panel read as
one outlined container, and the active tab's colour visibly feeds into the
panel it opens. Accent is threaded per-tab via --settings-accent.
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
fix(asr): repoint two 404 ASR model repo IDs in catalog (closes#239) (#242)
* fix(asr): repoint two 404 ASR model repo IDs in catalog (closes#239)
Model install failed with HTTP 404 for two ASR entries whose Hugging Face
repos don't exist:
- UsefulSensors/moonshine-small -> UsefulSensors/moonshine-tiny (Moonshine
ships tiny/base; there is no 300M 'small')
- Systran/faster-whisper-large-v3-turbo -> deepdml/faster-whisper-large-v3-turbo-ct2
(Systran publishes no turbo repo; deepdml is a valid CTranslate2 build)
Audited all 25 catalog repo_ids — every one resolves 200 on HF after the swap.
Adds a static (no-network, CI-safe) regression test asserting repo_ids are
well-formed and the known-404 IDs can't reappear.
* test/docs(asr): safer repo_id access + flag turbo repo as community build (PR #242 review)
- test_known_404_repo_ids_absent: m.get('repo_id','') so a missing field gives a
clean assertion instead of KeyError regardless of test order.
- models.yaml: note the turbo entry is a community CTranslate2 conversion to
re-verify on future audits (greptile).
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(license): declare actual FSL-1.1-ALv2 in pyproject (was Apache-2.0) (#236)
pyproject declared license = "Apache-2.0", but the repo's LICENSE is
FSL-1.1-ALv2 (Functional Source License; each release converts to Apache-2.0
two years after publication). The Apache-2.0 declaration was inaccurate for
the current grant. Declared as a PEP 639 LicenseRef since FSL isn't an
OSI/SPDX-listed identifier.
Validated: hatchling accepts the expression and builds the project cleanly
(uv build OK), so uv sync / packaging in CI is unaffected.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat(update): move update pill into status bar + Updates panel (changelog/channel/history) (#240)
* docs(spec): updates-in-status-bar design (move pill to LogsFooter + Updates panel)
* docs(plan): updates-in-status-bar implementation plan (11 tasks, TDD)
* docs(plan): pin i18n task to scripts/translate_all.py backfill
* feat(update): pure chip + release presentation helpers
* feat(update): listReleases + fetchAppVersion wrappers
* feat(update): transient releasesSlice composed into store
* feat(update): app version + channel in updaterSlice
* feat(update): list_releases Tauri command (GitHub releases)
* feat(update): UpdateStatusChip bar indicator
* feat(update): UpdatesPanel (live row + channel + releases list)
* feat(update): mount chip+panel in LogsFooter, retire floating UpdateBadge
* feat(update): Settings channel switcher shares store value (auto-sync)
* i18n(update): add updates.* keys across 21 locales
* chore(deps): lock reqwest for list_releases command
* polish(update): a11y radiogroup on channel switch, safer release key, drop dead test seam
Addresses final-review nits (non-blocking): role=radiogroup/radio + aria-checked
on the channel Segmented; key={r.name||r.version} to avoid collisions; remove the
unused vi import + __loader seam in releasesSlice.test.ts.
* fix(update): i18n the channel-set error + correct flagged updates.* translations (PR #240 review)
* fix(update): add 10s timeout to list_releases HTTP client (PR #240 review)
* fix(update): guard chip Restart against in-flight dub job (greptile P1, PR #240)
The always-visible status chip's one-click Restart (ready state) called
installUpdate→relaunch without the dub-busy guard the panel uses, so a user
with a dub/transcription job running could lose in-flight work. Mirror the
panel's gate: toast update.busy and bail when dubStep === 'generating'.
* fix(update): surface channel-switch failures in the Updates panel (greptile, PR #240)
Mirror Settings' error handling: the panel's stable/preview switch now catches a
failed set_update_channel and toasts settings.channel_set_failed instead of an
unhandled rejection.
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(gallery): OmniVoice Gallery rename, dark dropdowns, and noisy/stale archetype previews (#241)
* fix(gallery): rename to "OmniVoice Gallery" + fix dark-theme dropdown colors
The gallery heading now reads "OmniVoice Gallery" (gallery.title, all 21
locales — brand prefix on each localized word).
The facet filter <select>s (Gender/Age/Pitch/Accent/Language) rendered with
the OS-default light control surface on the dark theme: .facet-select set
background/border from --bg-tertiary / --border-color, which are defined
nowhere. An undefined var() reads as transparent on the sibling <div> filters
(fine over the dark page) but falls back to the native light background on a
form control. Switch to the defined dark-chrome tokens and add
color-scheme: dark + an explicit dark option list so the popup matches across
WebKit / WebView2 / WebKitGTK.
* fix(gallery): archetype previews render a noise buzz instead of voice
The Hype Host, The Podcaster and The Vlogger previews played a loud tonal
buzz, not speech. The preview renderer pinned num_step=16 and seed=42; the
"social" sample script at that exact point lands on a degenerate diffusion
trajectory and collapses to a near-pure tone. The blank-audio guard missed it
because the buzz is loud (peaks near -2 dBFS), not silent — so the garbage was
cached and served. The cache key is (instruct, language) only, so it never
self-corrected.
- Bump preview num_step 16 -> 32: reliably converges to speech across the
gallery's instruct/script space (one-time, cached render cost).
- Add a spectral-flatness floor (_is_unusable_audio) so a degenerate tonal
render is rejected like a blank one, reusing the existing retry-on-new-seed
path. Whisper/breathy voices are broadband (high flatness) so they're safe.
Verified: flatness Hype Host 0.001->0.050, Podcaster 0.0002->0.083,
Vlogger 0.004->0.039; whisper control (Calm Guide) 0.239, not flagged.
* fix(gallery): stop preview playback replaying stale cached audio
Preview audio is re-rendered server-side when an archetype is fixed, but the
URL is stable and the response carried no Cache-Control — so the WebView's
HTTP cache replayed the first clip it ever fetched (e.g. the old buzz)
indefinitely, even after the server file was corrected.
- Frontend: fetch previews with { cache: 'no-store' } so playback always
pulls current bytes.
- Backend: send Cache-Control: no-cache on the preview response so any client
revalidates against the ETag instead of serving a stale clip.
* test(e2e): add Playwright UI smoke + gallery specs and preview-quality unit test
UI testing system to catch regressions like "Use design → Importing a module
script failed" (a dead Vite/module server) and the noisy-preview bug.
- Playwright (frontend/e2e): drives the system chromium (no browser download)
against the Vite dev server. ui-smoke mounts all 12 routable views and fails
on any code-split/import failure, uncaught exception, or ErrorBoundary
fallback. gallery.spec asserts the "OmniVoice Gallery" heading, the dark
facet dropdowns (computed bg = rgba(255,255,255,0.04), not the OS-default
light surface), and that opening an archetype in the Designer mounts the
lazy CloneDesignTab. `bun run e2e`.
- backend/tests/test_archetype_preview_quality.py: unit-tests the
_spectral_flatness / _is_unusable_audio guard with synthetic signals
(tone < threshold < speech < noise; loud tone + silence are unusable) and
pins the render constants. CI-safe — no model/GPU.
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
feat(launchpad): Transcripts card + recent OmniDrive files strip (#235)
- New Transcripts action card (lime accent, FileText) → opens the
Transcriptions view, alongside Clone/Design/Dub/Stories/Gallery.
- Below the cards, a "Recent files" strip shows the last few exports from
OmniDrive (GET /export/history, already loaded on mount) with a "View all
files →" link that jumps to the full OmniDrive browser (Projects page).
Hidden when there are no exports yet.
App.jsx passes exportHistory to Launchpad; reuses existing lp-project-card
chrome, adds a small files-head + view-all + grid in index.css. 5 new
launchpad.* keys, backfilled across 21 locales.
Verified: tsc clean, build OK, vitest 167/167, CJK guard passes.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat(support): unify Donate + Commercial License behind one toggle (#234)
* feat(support): unify Donate + Commercial License behind one toggle
The donate page and the commercial-license (enterprise) page were two separate
full-screen modes reached from different places. Merge them into a single
SupportPage with a charming segmented toggle:
- Segmented "💛 Support ⇄ 🏢 Commercial License" control with a sliding active
pill that carries each panel's accent hue (pink for Support, teal for
License) and an icon that pops on selection.
- Switching cross-fades the panel (key remount replays the hero/card entry
animations) over the shared Launchpad aurora + a single Back button.
- Both legacy modes still work: 'donate' opens the Support tab, 'enterprise'
opens the Commercial License tab — so the footer heart and the dub/export
"commercial license" links land on the right tab unchanged.
Reuses the existing donate/enterprise chrome (DonatePage.css + EnterprisePage.css
kept and imported); SupportPage.css only owns the toggle + transitions. Both
views share one 640px container width so the frame doesn't jump on toggle.
DonatePage.jsx + EnterprisePage.jsx removed (content folded in).
3 new support.* i18n keys, backfilled across 21 locales.
Verified: tsc clean, build OK (SupportPage chunk replaces the two old ones),
vitest 167/167, CJK guard passes.
* feat(support): add 'Other ways to help' chips + FSL hero_note fix + i18n backfill
- SupportPage: add Star GitHub / Join Discord chips below donation methods
- SupportPage CSS: vertically center short Support panel, single-column
donation grid, ghost-pill chip styles
- Fix FSL hero_note wording across all 21 locales to accurately reflect
the license (internal use at any scale is free; only competing
product/service triggers commercial license)
- New i18n keys: support.other_ways, support.star_github, support.join_discord
* feat(support): polish Support panel + correct Commercial License wording
Support panel (from screenshot feedback):
- Donation methods now stack in a single clean column — no orphaned PayPal
card floating in a half-empty second row.
- Short Support panel is vertically centered so it no longer clings to the top
of an empty page (License stays top-aligned; it's tall enough to fill).
- New "Other ways to help" row: Star on GitHub + Join Discord ghost chips, so
people who can't donate still have a real way to support — and it balances
the layout.
Commercial License wording:
- Fixed enterprise.hero_note: it implied "deploying at scale (pay-per-use API)"
triggers a commercial license. Per the actual FSL-1.1-ALv2, scale does NOT
trigger licensing — internal use is free at any scale; the trigger is
offering OmniVoice to others as a competing product/service. Reworded to say
exactly that, and re-translated across all 21 locales.
3 new support.* keys. Verified: tsc clean, build OK, vitest 167/167, CJK guard
passes, 21 locales at parity.
* chore: drop stray stories-editor plan doc that slipped into the branch
This planning artifact (with CJK i18n examples) was accidentally swept into
an earlier commit on this branch; it isn't part of the Support-page feature
and isn't on main. Removing it so the CJK guard passes — the committed tree
no longer carries hardcoded CJK outside the translation layer.
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(i18n): close coverage gap the translation PR missed (#230 follow-up) (#232)
The "full i18n coverage" PR (#230) was based on a stale snapshot, so strings
added to main after that point — the updater/channel dialog, dictation
shortcut, and a batch of export/save/project toasts — were still hardcoded
English. This extracts the remaining user-facing imperative strings (toasts +
the update ask() dialog) plus the adjacent JSX labels in DubFailureNotice:
- App.jsx (17): export/save/download/project/flush toasts → i18n.t('app.toast_*')
(App already imports the configured i18n instance; reused it rather than
plumbing a hook through 17 handlers).
- Settings.jsx (14): save/clear failures, engine-switch, channel, updater
download/install + the "Update available" ask() dialog, dictation-shortcut
set/register/reset → t('settings.*').
- DubTab.jsx (DubFailureNotice): added the useTranslation hook; "Diagnostic
copied"/"Copy failed" toasts + "Open docs"/"Copy diagnostic" labels.
38 new keys added to en.json, backfilled across all 21 locales. No regressions:
the only shared-key value change from the #230 merge was the intentional
engines.unavailable casing fix.
Verified: 21 locales at parity, tsc clean, build OK, vitest 167/167, CJK guard.
NOTE: this covers imperative strings (toast/ask) in these 3 files. A full
codebase audit of all JSX text/placeholders is a larger separate sweep.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat(gallery): calmer, more elegant archetype cards (#231)
The "Use voice" buttons were solid, fully-saturated per-category color fills —
16 loud, differently-hued blocks on screen drew the eye to the buttons instead
of the voice names. Polish pass:
- Use-voice button: tonal by default (13% accent wash + accent-colored text +
hairline accent border), going solid only on hover/focus. Keeps the
per-category hue as identity but lets the resting grid stay calm; the CTA
lights up on the card you're pointing at.
- Chip row: always rendered with a reserved min-height so cards without an
accent/whisper chip (e.g. Captain Crusty) no longer leave a ragged void —
action rows now line up across the grid.
- Designer (wand) button: quiet at 0.5 opacity at rest, full on card
hover/focus — it's tertiary, so it no longer competes on every tile.
- Card hover border softened a touch.
color-mix() is already used in 17 frontend files (proven on all WebView
targets). Verified: tsc clean, build OK, vitest 167/167.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat(l10n): extract remaining 531 hardcoded strings — full i18n coverage (#230)
* feat(l10n): complete translations for all 21 languages (837 keys each)
Translate all UI keys across every component for 20 non-English locales:
ar, de, es, fr, hi, id, it, ja, ko, nl, pl, pt, ru, sv, th, tr, uk, vi, zh-CN, zh-TW
- 837 flattened keys per language (100% coverage)
- Covers settings, splash, main UI, dialogs, tooltips, errors
- Placeholders ({{var}}) and HTML tags (<1>) preserved
- Add translate_all.py batch script for future re-translations
* feat(l10n): extract remaining 531 hardcoded strings and translate all UI
Scan found ~250 hardcoded user-facing strings across ~30 component files.
Extracted all into en.json (837 → 1368 keys, 49 namespaces) and updated
every component to use t() / i18next.t().
Components updated (35 files):
- Zero-i18n: AudioTrimmer, CaptureWidget, CastingView, CheckpointBanner,
CompareModal, DirectionDialog, EngineCompatibilityMatrix, ErrorBoundary,
FloatingPill, KeyboardCheatsheet, NetworkToggle, ReadinessChecklist,
SupertonicLicenseDialog, VoicePreview, BatchAddDialog
- Partial-i18n: BootstrapSplash, DubSegmentRow, Header, LogsFooter,
DictationDemo, DubbingDemo, NavRail, MultiLangPicker, SearchableSelect,
Sidebar, Settings, SetupWizard, EnterprisePage, VoiceGallery,
SharingPanel, ReportBugButton, StoriesEditor, App.jsx
- Hooks: useDubWorkflow.js, useTTS.js (using i18next.t directly)
New namespaces: trimmer, casting, checkpoint, compare, direction, errors,
keyboard, header, sidebar, network, readiness, license, voicePreview,
models, enterprise_faq, gallery_extra, dub_workflow, tts_errors, sharing,
reportBug, app
All 20 non-English locales translated to 100% (1368 keys each).
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(logs): import useAppStore in LogsFooter (donate + notification nav crashed) (#229)
LogsFooter.jsx called useAppStore.getState().setMode(...) in four handlers
(donate button + notification action targets, lines ~405/447/880/922) but
never imported useAppStore — clicking any of them threw
'ReferenceError: Can't find variable: useAppStore' and the handler died.
Add the canonical 'import { useAppStore } from "../store"'. tsc + build clean.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat(launchpad): surface Stories + Voice Gallery; fix gallery key-spread warning (#228)
- Launchpad showed only Clone/Design/Dub. Add ActionCards for the two newer
modes: Stories (multi-voice audiobooks → setMode('stories')) and Voice Gallery
(browse designed-voice archetypes → setMode('gallery')). i18n in en + zh-CN,
backfilled across all 21 locales.
- VoiceGallery: stop spreading a 'key' prop into <ArchetypeCard {...cardProps}>
(React dev warning + ignored). cardProps no longer carries key; pass key={a.id}
directly at the two render sites.
Verified: tsc clean, build OK, vitest green, CJK guard passes.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat(stories): redesign — chapter section bars, grouped toolbar, readable column (#227)
Full design polish pass (behavior unchanged; JSX structure + CSS only):
- Chapters render as distinct section bars (heading title + grip + delete) with
an accent left-border — no speaker/voice/tune/preview controls. Detection
(isChapterText) is lenient so clearing the title doesn't flip the bar back to
a voiced line mid-edit; unified with the chapter auto-numberer.
- Toolbar split into three labelled clusters with thin dividers — Project
(Projects · Cast) · Content (Import · Paste&Split · +Line · +Chapter) ·
Output (Stems · format · Generate) — and wraps instead of cramming one row.
- Editor centered at a 1040px reading column so lines no longer stretch
edge-to-edge on wide windows.
- A line's secondary actions (inline-voice / tune / pause / preview / delete)
are quieted to 0.5 opacity and revealed on row hover/active, cutting visual
noise. Drag handlers factored into a shared dragProps (reused by both bar
and line) so reordering still works across chapters + lines.
Verified: tsc clean, build OK, vitest 167/167.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat(release): version preview builds (0.3.0-preview.N) + rollback spec (#226)
Phase A: stamp each preview build with a unique monotonic semver prerelease
(<base>-preview.<run_number>) via an ephemeral tauri.conf.json rewrite on the
preview path. Today every preview reported the static 0.3.0, so the updater
never saw a newer version and never delivered preview updates. The prerelease
ordering makes each new preview offer-able and converges to stable when <base>
ships. (Windows MSI ProductVersion strips the prerelease — caveat noted to
verify; mac/linux unaffected.)
Phase B (rollback) is captured as a design spec for review, not implemented:
per-version preview releases + retention, an in-app Preview-builds picker, an
allow_downgrades install path, and the alembic-head data-safety boundary.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(asr): cap setuptools <80 so pkg_resources stays present (#224) (#225)
Windows v0.3 users hit two transcription failures with models installed:
- WhisperX: "Transcription produced no segments. No module named 'pkg_resources'"
- Whisper PyTorch fallback: "No ASR backend is ready. … set OMNIVOICE_PRELOAD_TTS_ASR=1"
Shared root cause: whisperx / faster-whisper import `pkg_resources` at runtime,
and setuptools 80+ DROPPED the bundled pkg_resources. The existing pin
`setuptools>=75` therefore resolved to 82.0.1 — which has no pkg_resources — so
`import whisperx` fails. That both breaks WhisperX transcription and makes its
is_available() return false, which is why every backend reports "not ready" and
the engine asks for the PyTorch fallback (the user's PowerShell env var never
reached the GUI-launched app, a separate red herring).
Fix: pin `setuptools>=75,<80`. Verified: <80 resolves to 79.0.1 which ships
pkg_resources; 82 does not. `uv lock` changed only setuptools (82.0.1→79.0.1).
This fixes BOTH errors — WhisperX imports again, so it's available and the
fallback is no longer needed.
Adds tests/test_pkg_resources_available.py to guard the pin from regressing.
Full suite 602 passed (incl. the new test), 0 failures.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
refactor(settings): move Performance toggles to the General tab (#223)
The Performance panel (Disable torch.compile / Show live system metrics in
header) was nested under the Credentials tab — an odd home. Render it in the
General tab instead, where users look for app-level toggles. Pure relocation:
PerformancePanel is unchanged; removed its render from CredentialsTab and added
it after GeneralTab in the general view.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(release): macOS smoke mount path preserves space in volume name (#221)
fix(release): macOS signing env must be ABSENT not empty (unblocks mac build) (#220)
feat(update): dismiss button for the failed-update pill (#219)
#216 stopped the 6h periodic re-check from clobbering a failed-install error
badge — correct, but it left no way to clear that badge except retrying, so a
transient install failure pins a red "Update failed · Retry" pill until the
user retries or restarts the app. Add a × to dismiss it (mirrors FloatingPill),
returning the updater surface to idle.
- updaterSlice: dismissUpdate() → idle + clears error/progress
- UpdateBadge: × dismiss button on the error state (i18n: update.dismiss)
- updaterSlice.test: dismiss returns to idle and clears the error
- en.json: update.dismiss ("Dismiss"); other locales fall back to en
Verified: vitest 10/10 (updaterSlice + updater guard), typecheck:ci clean, build OK.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(release): resolve AppImage path before cd in Linux smoke (exit 127) (#218)
fix(release): make macOS signing opt-in so a bad cert can't break builds (#217)
The APPLE_CERTIFICATE secret is currently set-but-invalid, so tauri-action's
'security import' fails and kills the whole macOS build — on stable v* releases
too, not just preview. Make Developer-ID signing OPT-IN: pass the Apple creds
only on a v* tag push AND when the repo variable MACOS_SIGNING_ENABLED == 'true'.
Otherwise pass empty -> the build stays unsigned and succeeds (users clear
quarantine via xattr -cr, as documented). Preview is always unsigned.
To re-enable signed stable releases: fix the signing secrets, then set
MACOS_SIGNING_ENABLED=true (Settings -> Secrets and variables -> Actions ->
Variables). No code change needed to flip it.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(update): keep error/retry pill across periodic re-check (#214 follow-up) (#216)
* fix(update): keep error/retry pill across periodic re-check (#214 follow-up)
PR #214's 6h periodic re-check guard skipped only downloading/ready, not
error. setUpdateChecking() clears updateError and the badge renders null
for 'checking', so a tick while 'Update failed · Retry' was showing
silently erased the prompt the user still needed to act on — defeating
the PR's own error-surfacing goal (greptile P1, unresolved).
- updater.js: also short-circuit the re-check on 'error'. Retry is
user-initiated (installUpdate → downloading), so auto re-check is
unnecessary in that state.
- updater.test.js: new regression test — guard no-ops on
error/downloading/ready, proceeds from idle.
- UpdateBadge.jsx: add aria-controls + panel id to the 'What's new'
disclosure (greptile P2 a11y).
- UpdateBadge.css: word-break:break-word → overflow-wrap:break-word
(CodeRabbit; the deprecated value).
Verified: vitest 166/166, typecheck:ci clean, bun run build OK, CJK guard pass.
* fix(update): keep notes panel mounted so aria-controls always resolves
greptile P2: aria-controls pointed at a conditionally-rendered panel, so
the IDREF dangled while collapsed. Render the panel whenever notes exist
and toggle with the hidden attribute (canonical disclosure pattern) — the
reference now always resolves.
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(release): unblock all-platform preview/release builds + auto-generated notes (#215)
The first preview build surfaced four real release-pipeline issues (all of which
also affect a stable v* release):
- macOS: build died at codesign — `security import: failed to import keychain
certificate` (the APPLE_CERTIFICATE secret is set but invalid). Preview now
force-skips Apple signing (passes empty creds) so it can't fail on a bad/absent
cert; stable v* tags still receive the secrets, so signing engages once the
cert is fixed.
- Linux: .deb bundling fails with "Failed to create control scripts: No such
file or directory" (no custom deb config of ours). Drop .deb, ship AppImage
only — the universal Linux format and the Linux auto-update target.
- Installer smoke (all 3 OSes): the steps hunted for a frozen backend binary to
boot with --health-check, but the thin uv-venv installer ships no such binary
(the venv builds on first launch). Rewrite to structural verification —
assert the bundle carries the shell binary + bundled uv sidecar + backend
source resources (pyproject.toml + backend/main.py).
Also: a new preview-notes job regenerates the rolling preview release body with
GitHub's auto-generated notes (What's Changed by PR + New Contributors + Full
Changelog) plus a Contributors avatar strip built from the PR authors — instead
of the bare "Auto-generated release for main…" fallback. Runs once after the
matrix, preview-only; stable keeps its CHANGELOG section + appended checksums.
Stable v* tag-push behavior is otherwise unchanged. YAML validated.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat(gallery): multilingual designed-voice archetypes (#213)
* feat(gallery): multilingual designed-voice archetypes
Ship curated designed voices in 9 more languages (Spanish, French, German,
Italian, Portuguese, Russian, Hindi, Japanese, Korean) so the gallery offers
more than English + Chinese out of the box -- 27 new featured archetypes
across three reusable roles (Narrator / Explainer / Companion).
Voice-design timbre (gender/age/pitch) is language-independent, and a designed
voice's spoken language is driven by the preview text, not the instruct. So
these reuse a neutral instruct + a localized sample script + a `language` value
matching frontend/src/languages.json -- byte-for-byte the same
model.generate(text, language, instruct) call the Generate tab already makes.
They carry no accent/dialect token (accents are English-only, dialects
Chinese-only; an invented "spanish accent" would crash synthesis, the issue-#89
mode), so every instruct stays inside the validator vocabulary.
- backend/core/archetypes.py: _ML_SAMPLES + _ML_ROLES + _make_multilingual()
- frontend VoiceGallery: extend the language facet filter
- tests: assert the 9 languages are present, neutral-timbre, valid-token
- test_no_hardcoded_cjk: note JA/KO sample text in the existing allowlist entry
* fix(gallery): surface featured-only languages through the filter
Address review feedback (greptile/coderabbit) on #213:
- VoiceGallery: the Browse query hard-coded `featured: false` while the
Featured strip is hidden whenever a filter is active. The 9 new languages
have *only* featured archetypes, so selecting Spanish/French/etc. produced
an empty Browse AND a hidden Featured strip -> "No voices match these
filters" despite 3 archetypes existing per language. Now Browse includes
featured exactly when the Featured strip is hidden (i.e. when filtering),
with no duplication when nothing is filtered.
- archetypes.py: module docstring said the Featured tier was "~24"; it is now
~51 (24 English + 27 multilingual). Added a docstring to _make_multilingual().
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat(update): release notes in badge + periodic re-check + error surfacing (#214)
Closes the three highest-value gaps from the auto-update audit vs best-in-class:
1. Release notes — the available pill gains a 'What's new' expander showing the
release body (already captured as updateNotes) so users see what changed
before installing.
2. Periodic re-check — App.jsx re-checks every 6h, not only on boot, so
long-running sessions get notified. checkForUpdate now no-ops while a
download/restart is in flight, so the interval can't interrupt an install.
3. Error surfacing — the badge no longer returns null on 'error'; it shows a
'Update failed - Retry' pill (with the error as tooltip) that re-attempts
the install, instead of silently vanishing.
i18n: update.whats_new/failed/retry added to en + zh-CN and backfilled across
all 21 locales (placeholders intact).
Verified: tsc clean, vitest 162/162, build OK, CJK guard passes, 21 locales
valid + key-complete.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
docs(readme): add ASR Engines table (surfaces SenseVoice/FunASR) (#212)
* docs(readme): add ASR Engines table (surfaces SenseVoice/FunASR)
The README documents the multi-engine TTS backend but never listed the
ASR backends, so users filed requests (#206, #208) for engines that
already ship. Add an ASR Engines section mirroring the TTS table,
grounded in backend/services/asr_backend.py engine ids + display names,
plus a nav anchor.
* docs(readme): correct Parakeet language scope + MLX framework name
Address review feedback on PR #212:
- Parakeet TDT: NeMoASRBackend docstring documents 25+ European
languages w/ auto language detection (not English-only); note GPU req.
- MLX Whisper: the engine uses Apple's MLX (Metal-backed) framework,
not the CoreML inference stack.
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(ci): skip supertonic license/cpu tests when the optional dep is absent (#211)
release.yml installs deps with plain 'uv sync' (no optional engines), so
test_cpu_only_honest and test_license_gate failed there — is_available()
short-circuits with 'supertonic package not installed' before reaching the
license check those tests assert on. This blocked EVERY release (preview and
stable) at the test gate, not just the preview build that surfaced it.
Skip the two when 'supertonic' isn't importable (optional opt-in engine). They
still run fully under ci.yml's 'uv sync --all-extras'; the absent-package path
is covered independently by test_optional_dep_missing. Also fixes the same two
failing on a local '.venv' without the extra.
Verified: tests/test_supertonic3.py now 8 passed, 5 skipped, 0 failed without
supertonic installed.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add Arch Linux installation instructions (#209)
* Add Arch Linux installation instructions
Added installation instructions for Arch Linux.
* Update docs/install/linux.md
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
---------
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
chore(lint): remove unused imports + variables (ruff F401/F841) (#210)
Autofixes the genuine lint behind the CodeQL py/unused-import and
py/unused-local-variable note-level alerts — actually removing the dead
code rather than dismissing it. 68 safe fixes via 'ruff check --select
F401,F841 --fix' across 29 backend files (dead stdlib/symbol imports like
io/sys/json/torch/typing.Optional and unused locals). Only ruff's safe
fixes applied — the 9 'unsafe' fixes and the audio_dsp numpy availability
import were left untouched.
Not touched: empty-except (needs per-site judgement, not autofixable);
frontend js/unused-local-variable (eslint no-unused-vars has no autofix);
the loopback-low-risk path/log/stack-trace alerts (real, left visible).
Verified: full tests/ suite unchanged at 601 passed (the 2 test_supertonic3
failures are pre-existing on main, local .venv state, green in CI).
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat(gallery): lucide/flag icon redesign + community marketplace (omnivoice-gallery) (#207)
* feat(gallery): lucide icons + country flags + card redesign (replace emoji)
- backend archetypes emit lucide-react icon *names* (cross-platform; emoji
render inconsistently across OSes) for use-cases and the 24 featured voices.
- new frontend/src/utils/archetypeIcons.jsx: name→lucide map, accent→country
flag (country-flag-icons, tree-shaken to ~11), per-category color scale,
color-coded avatar tile, and a CSS-animated now-playing equalizer
(prefers-reduced-motion aware).
- card redesign: real elevated surfaces (cards were invisible on the dark bg),
avatar + name + facet sub-line, accent/flag chips, and a footer with
Preview / category-colored "Use voice" / Open-in-Designer.
* feat(marketplace): community voice gallery via omnivoice-gallery submodule
Offloads curated + community gallery content to the standalone
debpalash/omnivoice-gallery repo — added here as a submodule for authoring,
loaded at runtime via the jsDelivr CDN so the binary stays small.
Content repo (seeded + pushed separately): manifest.json (24-voice starter
pack generated from the featured archetypes), a JSON schema, CONTRIBUTING,
and GitHub submission templates carrying consent / no-impersonation guardrails.
Backend (api/routers/community.py): configurable sources (env var > file >
default), CDN fetch with offline disk cache, strict validation (invalid
presets and non-allow-listed audio URLs are dropped, so a bad community entry
can neither crash synthesis nor fetch from an arbitrary host), filtering, the
prefilled submit URL, and "use" (preset → archetype render path; voice →
sha256-verified download). 11 tests.
Frontend: a third gallery zone, "Community", reusing the redesigned card, plus
"Submit a preset / voice" buttons opening the prefilled GitHub forms.
Local-first preserved: network only on open/refresh; everything cached; the
built-in generated archetypes need no network, so the gallery is never empty.
* fix(marketplace): address review feedback on the community gallery
- community_use: run the blocking manifest read + voice download in a thread
(asyncio.to_thread) so they don't stall the event loop (greptile P2).
- community_submit_url: validate the `source` override against an owner/repo
pattern, falling back to the configured default (greptile P1 hardening).
- rename the `type` query param to `item_type` (alias="type") so it no longer
shadows the Python builtin (coderabbit).
- frontend submit buttons use the canonical openExternal() (Tauri-aware) instead
of window.open, which doesn't open the system browser in the desktop app.
- lowercase the `currentcolor` CSS keyword (stylelint).
* refactor(marketplace): rename useCommunityItem -> addCommunityItem (not a hook)
Avoids the use-prefix on a plain API function (rules-of-hooks smell).
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
i18n: backfill gallery + archetypes keys across 20 locales (#205)
The de-celebrified Voice Gallery (#203) added gallery.* (45) + archetypes.*
(15) keys to en.json only, so all 20 other locales fell back to English for the
new gallery UI. Backfill all 60 keys into every locale via the project's own
scripts/translate_all.py (Google Translate, placeholder-masked, incremental) —
the same tool/path fixed in the earlier update-channel backfill.
Also removes 8 stale gallery.cat_* keys per locale (cat_celebs, cat_marvel,
cat_disney, cat_politicians, cat_anime, cat_books, cat_gaming, cat_news) — the
dead celebrity categories #203 removed from en.json but left behind in the
other locales. Finishes the de-celebrification across the whole i18n layer.
en.json / source untouched. Verified: all 21 locales valid JSON and key-aligned
to en for gallery + archetypes (0 missing); {{version}}/{{pct}}/{{channel}}-style
placeholders intact (0 losses); tsc clean; build OK; CJK guard passes (locales
are the allowlisted translation layer).
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(audio): stop near-silent renders becoming blank noise + guard archetype renders (#204)
* fix(audio): stop near-silent renders becoming "blank noise" + guard archetypes
Root cause of the blank/hiss voices: normalize_audio peak-normalized to -2 dBFS
whenever max(|audio|) > 0. When the model emits a near-silent clip (peak at the
noise floor, e.g. 1e-4), that applies thousands of × of gain and lifts the
noise floor to full scale — silence turned into loud hiss. This affected every
generation path (clone/dub/design/archetypes), which is why "some voices" came
out as blank noise.
- services/audio_dsp.py: normalize_audio gains a -50 dBFS silence floor. At or
below it the audio is left untouched (stays inaudible) instead of being
amplified. Real speech — even a whisper — peaks well above the floor, so
normal output is unchanged.
- api/routers/archetypes.py: after rendering, _is_blank_audio() detects a dead
clip (empty / non-finite / peak < 0.02 — a real normalized clip peaks ~0.79).
The render retries once with a different seed, then fails loudly (503 via the
existing handlers) so a blank preview or voice profile is never cached/saved.
Also extracts the script with a non-empty fallback.
- core/archetypes.py: _build never falls back to an empty script (empty text
synthesizes to silence).
Tests (tests/, runs in CI): normalize_audio doesn't amplify silence but still
normalizes real audio to target; _is_blank_audio flags dead renders and passes
real audio; every archetype carries a non-empty sample script.
Verified: full tests/ suite 601 passed incl. 8 new (the 2 test_supertonic3
failures are pre-existing on main — local .venv engine/license state, green in
CI — and unrelated to this diff).
* fix(gallery): static log message in blank-render retry (clears py/clear-text-logging)
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat(gallery): designed-voice archetype gallery + neutral importer (#203)
* feat(gallery): designed-voice archetype gallery + neutral importer
Adds a browsable library of ~1,100 designed voice archetypes (no real
people), generated from OmniVoice's own voice-design taxonomy and
organized ElevenLabs-style: 24 curated Featured voices plus a
facet-filtered "Browse all" explorer (595 English + 504 Chinese-dialect).
Every generated instruct is built from the validator's own vocabulary, so
none can trigger the issue-#89 synthesis crash.
Backend:
- core/archetypes.py: catalog engine (featured + generated, implausible
combos pruned, stable hashed ids); loads the taxonomy by file path to
stay torch-free in tests.
- api/routers/archetypes.py: categories / list+filter+paginate / get /
preview (render-on-demand + disk cache) / use (materialize a voice
profile). Preview/use reuse generation.py's proven inference path.
- gallery.py: drop the celebrity/character catalog; the importer is now a
neutral, user-driven "My Imports" (paste a URL you have the rights to).
No project-shipped directory of named real people.
Frontend:
- Gallery UI rewrite: Archetypes zone (featured grid + facet filters +
favorites) and My Imports zone; per-card Use voice / Open in Designer.
- api/archetypes.ts, useArchetypes/useArchetypeCategories hooks (v5
placeholderData:keepPreviousData), gallerySlice, en.json keys.
Tests: 27 new (engine contract + API), full backend suite green (72);
CJK guard allowlists the one functional Chinese preview sample.
* fix(gallery): clear new bandit alerts (sha1 + SQL false-positive)
The PR's code-scanning "Bandit" check fires on NEW alerts vs main's baseline.
The archetype work introduced three:
- archetypes.py / core/archetypes.py: hashlib.sha1 used to derive a
deterministic preview-cache key and archetype id (not a security digest) —
flagged B324 (HIGH). Add usedforsecurity=False; the digest is unchanged.
- gallery.py: the UPDATE query interpolates only static, code-controlled column
fragments ("is_favorite = ?", "description = ?"); every user value is bound
via a ? placeholder — flagged B608 (false positive). Annotate `# nosec B608`
with the justification.
Behavior-preserving. Net new bandit alerts after this: zero (verified with
bandit -ll -ii; only main's pre-existing baseline remains).
* fix(gallery): resolve PR #203 CI (SHA-256 ids, log sanitization, CJK allowlist)
All failures stemmed from the initial commit:
- Bandit + CodeQL (2 high): SHA-1 weak-hash on the archetype id and the
preview cache key. These are deterministic identifiers, never security
digests — switched to SHA-256, which the SAST scanners accept.
- CodeQL (log injection): the render-failure logs echoed the raw
user-supplied archetype_id; log the catalog's canonical a["id"] instead
(untainted — it comes from the trusted in-memory catalog, not the request).
- CodeQL (superfluous argument): declare createGallerySlice's StateCreator
store param so its arity matches the 3-arg call site.
- Tests (test_no_hardcoded_cjk): the committed design spec's Chinese-dialect
reference table tripped the guard; allowlist it under documentation.
* fix(gallery): clear CodeQL clear-text-logging on archetype render errors
CodeQL's sensitive-data heuristic flags any request-derived value
interpolated into a log call (it persisted even after switching the raw
id to the catalog's canonical a["id"]). Log a static message with
exc_info=True instead: the full traceback still reaches the backend log
for debugging, but no data expression remains for the query to flag.
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
i18n: backfill update-channel + auto-update keys across 18 languages (#202)
PR #200 added 18 new locale files, but they predated #199 (auto-update badge +
Stable/Preview channel toggle), so they were missing the `update.*` namespace
(6 keys) and `about.channel_*` (5 keys) — those strings fell back to English in
ar/de/es/fr/hi/id/it/ja/ko/nl/pl/pt/ru/sv/th/tr/uk/vi/zh-TW.
Backfill all 11 keys in every one of those languages so the updater UI is fully
localized. en.json / zh-CN.json already had them and are untouched. Placeholders
({{version}}, {{pct}}, {{channel}}) preserved verbatim; files re-emitted in the
exact format scripts/translate_all.py writes (ensure_ascii=False, indent=2) so
the diff is additions only (+13 lines/file, 0 deletions).
Also fix scripts/translate_all.py: LOCALES_DIR was hardcoded to a contributor's
absolute path (/Users/.../orca/...) — make it repo-relative so the generator
actually runs for anyone.
Verified: all 21 locales valid JSON + key-complete, placeholders intact;
tsc clean; vitest 162/162; build OK; CJK guard passes (locales are the
allowlisted translation layer).
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat(l10n): complete translations for all 21 languages (837 keys each) (#200)
Translate all UI keys across every component for 20 non-English locales:
ar, de, es, fr, hi, id, it, ja, ko, nl, pl, pt, ru, sv, th, tr, uk, vi, zh-CN, zh-TW
- 837 flattened keys per language (100% coverage)
- Covers settings, splash, main UI, dialogs, tooltips, errors
- Placeholders ({{var}}) and HTML tags (<1>) preserved
- Add translate_all.py batch script for future re-translations
chore(security): scope CodeQL to shipped product code (#201)
CodeQL flagged 459 alerts on main, but triage showed the bulk are in code
that never ships in the installer's runtime path: file-not-closed in the
omnivoice/eval harnesses, unused-global "FPs" in alembic migration boilerplate
(revision/down_revision), bind-all in tests, and path sinks in the legacy
Gradio research UI. They drowned out the handful of real findings.
Add a CodeQL config (inline, supported because build-mode is `none`/interpreted)
that scopes analysis to product code via paths-ignore: omnivoice/eval, research,
tests, backend/migrations, and *.test.* files. Queries move into the inline
config so security-and-quality stays the single source of truth alongside
paths-ignore.
Net effect on the next scan: the non-shipped-code alerts auto-resolve, leaving
the security tab focused on shipped backend + frontend. No product code changes.
Deliberately NOT touched (assessed, left as-is):
- Stack-trace-exposure (detail=str(e) in routers) — these are intentional,
helpful one-line diagnostics (the error-transparency work in b64f53b).
Genericizing them on a loopback/single-user app would regress a product value
for ~zero real benefit.
- "Critical" command-injection in exports.py and the high path-injections in
settings.py / system.py — design-correct false positives: list-form argv (no
shell), and the destination/model-dir/ffmpeg paths are arbitrary user-chosen
paths by design (containment guards would break the features). Best handled by
dismiss-with-justification, not code surgery.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat(update): Stable/Preview update channels with opt-in toggle (#199)
Adds a user-selectable updater release channel (Settings -> About -> Update
channel). Stable (default, every install + launch) tracks tagged vX.Y.Z
releases; Preview tracks the latest main build via a rolling "preview"
prerelease, falling back to stable if a stable release is ahead.
Why Rust: tauri-plugin-updater reads its endpoints from tauri.conf.json and
neither the JS check() nor the plugin's registration Builder can change them at
runtime (verified against the 2.10.1 source). The only runtime-endpoint API is
UpdaterExt::endpoints, so check+install move into two Rust commands that mirror
the plugin's own check/download_and_install -- the Stable path behaves
identically to the JS flow it replaces; only which manifest is consulted
changes. Switching is instant (channel is read per check), no restart.
backend (Rust):
- config.rs: update_channel field (default "stable", VALID_CHANNELS) +
get/set_update_channel commands.
- updater_channel.rs: channel_endpoints() (preview -> [preview, stable]) +
check_update / install_update commands; install emits update://progress.
frontend:
- utils/updateChannel.js (+test): single source of truth, normalizeChannel.
- utils/updater.js: routes the badge flow (#198) through the Rust commands via
the same store contract -- UpdateBadge/App.jsx unchanged.
- Settings About: Stable/Preview segmented toggle, channel-aware endpoint row +
diagnostics; Check-for-updates honors the live channel.
- i18n en + zh-CN.
release.yml: additive, workflow_dispatch-guarded preview publish to a rolling
"preview" prerelease. The v* tag-push stable path evaluates to its exact prior
values (verified) and is never affected. Preview builds are manual -- no
scheduled CI spend, nothing auto-published.
docs/update-channels.md.
Verified: cargo check (compiles clean), tsc, vitest 162/162, build, CJK guard,
release.yml YAML parses.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Merge branch 'debpalash/translation'
feat(l10n): add 15 more major UI languages and update auto-detector
Merge branch 'debpalash/translation'
feat(l10n): add Spanish, French, German, Japanese UI locales, auto-detection, and splash page switching
feat(update): non-blocking auto-update with progress + states + busy-gating (#198)
Replaces the blocking ask() dialog with a state-driven, progress-visible flow
that never interrupts in-flight work — and preserves existing work by design
(user data lives outside the bundle; alembic migrates on next backend start).
- updaterSlice: idle→checking→available→downloading(pct)→ready/error state
machine (transient, not persisted). 5 reducer tests.
- utils/updater: checkForUpdate() (launch, non-blocking → store) +
installUpdate() (downloadAndInstall with a Started/Progress/Finished →
progress callback, then relaunch). No-ops outside packaged Tauri.
- UpdateBadge: a non-intrusive pill — 'Update vX available · Install & Restart'
→ progress bar → 'Restart to update'. Install is gated while a dub job is
generating (toast 'finish your dub first') so a relaunch can't lose work.
- App.jsx: launch check now just surfaces availability into the store + mounts
the badge (no blocking dialog, no silent auto-install).
- i18n (en + zh-CN).
Builds on the existing tauri-plugin-updater (signed, GH-release latest.json).
Preview/main channel (a release.yml latest-preview.json + channel toggle) is a
follow-up; this is the stable-channel core.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat(dub): FunASR cam++ inline diarization → dub speakers (#182 Phase 2) (#197)
When FunASR is the active ASR backend, use its cam++ per-segment speaker IDs
directly and skip pyannote — the 'all-in-one' diarization the issue asked for.
- asr_backend: FunASRBackend loads spk_model='cam++' (ASR_FUNASR_SPK, set '' to
disable); transcribe()/_normalize_funasr already surface per-segment speakers.
- segmentation.assign_speakers_from_turns(segments, turns): generalised
overlap-weighted speaker assignment from {start,end,speaker} turns (mirrors
assign_speakers_from_diarization without a pyannote object; falls back to the
silence-gap heuristic when no turns). Pure + tested.
- dub_core: _transcribe_chunk collects offset-shifted speaker turns; they
accumulate across chunks; _diarize() uses them and skips pyannote when present.
Default (WhisperX) flow unchanged — no turns → existing pyannote/heuristic path.
4 new tests (overlap winner, containing turn, malformed-turn filtering, empty→
heuristic). Router smoke confirms boot.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat(ui): density follow-up — tighten studio-panel padding (14/16 → 10/12) (#196)
Reduces the panel chrome padding across the studio (left + right columns),
reclaiming vertical + horizontal space uniformly. Safe global compaction; the
remaining bar-merge + borders→tints are a visual-iteration follow-up.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat(dub): density pass — tighten segment-row rhythm (min-height 30→27, padding 4→3) (#194)
Safe slice of the compactness pass: shave per-row vertical space without
clipping the two-line (translated + ORIG) content. The higher-impact wins
(collapsing the stacked TRANSCRIPT/GLOSSARY/translations-ready bars, borders→
tints) are a follow-up best tuned visually.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(ui): compact the top bar height (per request — keep it, don't move) (#193)
Reduce .header-area vertical padding 6px → 3px and tighten the title
(line-height 1.15, 1.05rem) so the top bar is shorter, reclaiming vertical
space for content without relocating the bar.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(ui): larger Projects/History/Exports icons when the sidebar is collapsed (#192)
The collapsed sidebar rail showed the tab icons at size 13 — too small to read
as the only affordance. Bump to 18 when collapsed; the expanded tab bar keeps 13.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat(asr): add FunASR (SenseVoice) as an opt-in alternative backend (#182) (#191)
FunASR is an all-in-one multilingual ASR (50+ languages, punctuation, optional
cam++ speaker diarization). ASR is already pluggable, so this is a new
ASRBackend:
- FunASRBackend (id 'funasr'): deferred funasr import in is_available() (reports
an install hint when absent — opt-in, NOT a hard dep); _ensure_model loads
AutoModel(SenseVoiceSmall + fsmn-vad); transcribe() normalises output.
- _normalize_funasr(): pure, defensive normaliser → OmniVoice's
{chunks, segments, language} shape (handles VAD sentence_info with ms
timestamps + optional speaker, single-utterance fallback, strips SenseVoice
rich tokens). Unit-tested without funasr installed.
- Registered in _REGISTRY → auto-appears in /system/asr-backends → the Settings
ASR picker, with availability/install hint. WhisperX stays the default.
Phase 2 (future): wire FunASR's cam++ speaker ids into dub diarization.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat(dub): move Output Options + Timing to the top of the right (transcript) section (#190)
Relocates the OUTPUT OPTIONS (Mix BG / Dual subs / Burn subs / Default Track)
and Timing-strategy rows from the full-width footer panel to the top of the
right transcript column, where the export-relevant settings sit next to the
segments they affect. Footer keeps only the done/error banners + export-track
toggles. typecheck/build green.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat(dub): move Generate Dub + Export to the header bar (with Save/Reset) (#189)
Relocate the stateful primary-action cluster (Generate Dub / Stop / Stopping /
Regen-changed + Export) from the bulky footer button bar up to the header bar
next to Save/Reset, rendered as compact sm FooterBtns behind a thin divider.
Frees the footer to hold just output settings — tighter, less scrolling, and the
primary CTA sits where the file/Save/Reset context already is.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(win): SoniTranslate venv paths cross-platform — Scripts/ on Windows (#186) (#188)
sonitranslate.py hardcoded SONI_VENV/'bin'/{pip,python} (POSIX). On Windows,
python -m venv creates Scripts\ with .exe, so is_venv_ready() was always False
(install looped) and start() fell back to the wrong interpreter -> 30s timeout.
- Add _venv_bin(name): Scripts/{name}.exe on win32 else bin/{name} (mirrors
engines/indextts/bootstrap.py). Use it for the is_venv_ready/install/start
pip+python paths.
- stop(): _proc.terminate() instead of send_signal(SIGTERM) (cross-platform);
drop the now-unused signal import.
- test: _venv_bin returns Scripts/pip.exe on win32, bin/python on posix.
Closes#186.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(dub): key per-segment WAVs by stable id, not list index (#185) (#187)
* fix(dub): key per-segment WAVs by stable id, not list index (#185)
Partial regeneration ('regenerate only changed segments') reloaded/wrote
seg_{i}.wav by LIST INDEX while the regen allow-list + fingerprints were keyed
by STABLE id. After a delete/merge/split (ids preserved, positions shifted),
unchanged segments reused a different segment's audio → silently corrupted dub
output on the default in-UI incremental path.
- core.config.dub_seg_path(job_id, seg_id): per-segment path keyed by stable
id, sanitized to a bare filename (defends against path traversal via crafted
ids). A numeric index sanitizes to the legacy seg_{i}.wav, so old jobs resolve
through the same helper.
- dub_generate: write/reload per-segment WAVs by stable seg_id (deferred write,
RVC write, regen reload) with a legacy seg_{i}.wav fallback; persist a
job['seg_order'] manifest (index -> stable id) for index-keyed readers.
- dub_export: preview + stems-zip resolve the file via seg_order (-> stable id)
with legacy fallback, so they keep finding the right audio.
- test: dub_seg_path id-naming, legacy-index equivalence, traversal sanitization.
Back-compatible with in-flight jobs (legacy index files still resolve). Closes#185.
* fix(dub): harden dub_seg_path with realpath containment; route all seg paths through it (CodeQL path-injection)
Both job_id and seg_id are request-derived. Sanitise both and verify the
resolved path stays inside DUB_DIR (realpath + startswith) — raises on escape.
Route the legacy index fallbacks in dub_generate/dub_export through dub_seg_path
so no raw os.path.join(DUB_DIR, job_id, ...) remains and a bare '..' component
can't traverse.
* fix(dub): assert path containment at export sinks (recognized CodeQL barrier)
dub_seg_path already validates realpath containment, but CodeQL doesn't
propagate the barrier across the call. Re-assert at the FileResponse / zf.write
sinks (realpath + startswith on the value used) so the guard is recognized
in-function — clears the py/path-injection false positives.
* fix(dub): realpath+containment guard before any path sink in export/preview
CodeQL flags os.path.exists/FileResponse/zf.write as path sinks and won't
propagate dub_seg_path's internal guard. Resolve each candidate, realpath it,
and containment-check (startswith DUB_DIR) BEFORE any filesystem access — the
guard now dominates every sink in-function, clearing py/path-injection.
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(dub): ExportModal crash — t-shadowing in .map callbacks (#183) (#184)
Opening the Export modal on a dub job threw 'TypeError: e is not a function'
inside an Array.map during render, tripping DubTab's ErrorBoundary. Cause: the
zh-CN i18n sweep (#157) added t('…') translation calls INSIDE .map(t => …) /
.filter(t => …) callbacks where 't' was the loop variable (a track object / a
lang-code string), shadowing the useTranslation 't'. Calling the shadowed 't'
as a function threw (minified to 'e is not a function').
Rename the loop vars (t -> track / code) at the three <option>/<label> render
sites so they no longer shadow the translation fn; strings still go through t().
The harmless non-rendering shadows (useMemo/handlers that never call t()) are
left as-is.
Regression test renders ExportModal with a dub track equal to dubLangCode (the
exact crashing branch) and asserts it doesn't throw. Full suite 154/154,
typecheck/build/legacy ✓.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat(stories): MP3 export — backend ffmpeg encode + format selector (#181)
Completes the pro-output story: an Export-format selector (WAV / MP3) on the
Stories toolbar. WAV stays fully client-side; MP3 routes the client-stitched
WAV through a new backend POST /stories/encode (ffmpeg via the Windows-reload-
safe spawn_subprocess, #175), with a strict format whitelist (mp3/m4b/ogg) and
bitrate validation so the uploaded format can't inject ffmpeg args. Both the
audiobook and per-character stems honor the selected format; MP3 falls back to
WAV with a toast if ffmpeg is unavailable.
- backend/api/routers/stories.py + main.py registration; temp-file cleanup.
- frontend/src/api/stories.ts encodeAudio() (apiFetch: same-origin + PIN).
- tests: format-whitelist 400, ffmpeg-missing 501, real mp3 encode (skips if
ffmpeg absent). Backend 26 incl. router smoke; frontend 152/152, typecheck/
build/CJK ✓.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat(stories): Phase 4 — pro output (stems + chapters) + named projects (#180)
Final phase of the pro-studio Stories Editor.
- Named projects: storiesSlice gains storyProjects[] + currentProjectId with
save/load/new/delete/rename (transient fields stripped on snapshot); a
Tauri-safe Projects panel (name input + Save + New + load/delete list).
Persisted to localStorage.
- Per-character stems: 'Stems' export renders one WAV per cast voice
(exportStems → exportStoryAudio per character group) and downloads each.
- Chapter markers: lines starting with '# ' are chapter headings (not spoken);
Generate emits the audiobook WAV + a story-chapters.txt cue sheet with
HH:MM:SS timecodes. 'Add Chapter' inserts a heading line.
- Pure + tested: isChapterLine/chapterTitle/formatTimecode/tracksByCharacter/
buildCueSheet + project reducers. exportStoryAudio now returns
{blob, chapters, durationSec}.
- i18n (en + zh-CN); 9 new unit tests. Full suite 152/152, typecheck/build/CJK/
legacy ✓.
Client-side, no new deps, no DB. (MP3/M4B encode — a backend ffmpeg step — is
the one remaining optional add; WAV output is universal.)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat(stories): Phase 3 — per-line studio drawer (tone tags + speed) (#179)
Click the tune button on any line to reveal a drawer:
- Tone chips insert OmniVoice's native inline emotion/sound tags ([laughter],
[sigh], [question-en], [surprise-wa], [confirmation-en], [dissatisfaction-hnn])
at the cursor — the model-native way to direct tone (not the instruct param,
which only whitelists gender/age/pitch/style/accent and rejects free emotion).
- Per-line speed slider (0.5–2.0x) → threaded into /generate for both preview
and the audiobook export; reset-to-default.
- insertToken extracted to storyTokens (pure + tested); insertPauseInto + tone
chips share it. exportStoryAudio now resolves per-track {profileId, speed}.
- i18n (en + zh-CN); 4 new unit tests. Full suite 143/143, typecheck/build/CJK ✓.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat(stories): Pro Studio Phase 2 — auto-cast from text + import (.txt/.srt) (#178)
* feat(stories): Phase 2 — auto-cast from text + import (.txt/.srt)
The no-brain ingestion path. Paste or import a story, click Auto-cast ✨, and
the editor detects who's speaking and builds the cast + lines for you.
- parseScript(text): screenplay 'NAME: dialogue' + prose quote attribution
('said the fox' / 'the fox asked', straight + curly quotes); narration →
Narrator. Pure + tested (speaker normalization, URL guard, fallbacks).
- importStory: parseSrt strips indices/timestamps → cue text; importToText
routes .srt vs .txt. File import button (accept .txt/.srt) fills the panel.
- Auto-cast wiring: distinct speakers → cast members (round-robin voice assign
from installed profiles), lines appended; existing cast/work preserved.
- i18n (en + zh-CN); 14 new unit tests. Full suite 139/139, typecheck/build/CJK ✓.
* fix(stories): use substring check for SRT arrow (avoid CodeQL js/bad-tag-filter false positive)
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat(stories): Pro Studio Phase 1 — real audiobook output, cast, persistence, reorder, i18n (#177)
* docs(spec): Stories Editor pro-studio design (line cards, auto-cast, pro output, projects)
* feat(stories): Phase 1 — real audiobook output, cast, persistence, reorder, i18n
First phase of the pro-studio Stories Editor (spec:
docs/superpowers/specs/2026-05-30-stories-editor-studio-design.md). Makes the
editor actually produce audiobooks and remember your work:
- Persistence: storiesSlice (tracks + cast) via zustand persist -> localStorage;
transient fields (generating/audioUrl) stripped on persist; id counter reseeds
from persisted tracks. Dropped the hardcoded sample seed -> clean empty state.
- Cast: editable CastMember[] (name, color, voice) with a Cast panel; each line
picks a character and inherits its voice (per-line override still available).
- Real Generate: exportStoryAudio() stitches every line + [pause] gaps into one
WAV via the Web Audio API (job-less /generate per chunk) with a % progress
indicator and download. Per-line preview already shipped (#176).
- Reorder: native HTML5 drag-and-drop (pure reorder() helper).
- i18n: all Stories strings via t('stories.*') (en + zh-CN).
- Tests: storiesSlice reducers, storyCast resolution, storyExport WAV/concat/
silence, storyReorder. 18 new unit tests.
No DB/alembic; localStorage only. Same-origin + PIN-safe synth (apiFetch). No
new deps. Cross-platform-identical default behavior.
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(stories): preview via job-less /generate (was 404 on /api/dub/preview-segment/__stories__) (#176)
The Stories Editor preview POSTed to a relative /api/dub/preview-segment/__stories__
URL: that dub route requires a real dub job ("__stories__" -> 404) and the bare
relative path skipped the API base + PIN header entirely. Route through the
standalone /generate endpoint via the shared api client (generateSpeech), which
is same-origin and PIN-aware. Per-line and marker-chained preview now work.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(win): subprocess spawns work under `bun run dev` (--reload) on Windows (#122) (#175)
issue #122 'Extract: Unknown Error' on Windows: 'bun run dev' fails, running
backend+frontend separately works. Root cause: dev:api launches uvicorn with
--reload, so use_subprocess=True, and uvicorn 0.42's asyncio_loop_factory
EXPLICITLY forces the SelectorEventLoop on Windows in that case (passed as
loop_factory to asyncio_run, overriding any policy). The SelectorEventLoop has
no subprocess support -> asyncio.create_subprocess_exec raises
NotImplementedError. 'python backend/main.py' (no reload) uses ProactorEventLoop
-> works. So an event-loop-policy fix is futile; the thread fallback is the fix.
The ffmpeg extract path already routed through _spawn_async's thread fallback
(landed in #157), but several other spawn sites used raw create_subprocess_exec
and stayed broken on the dev loop:
- add public spawn_subprocess() (drop-in for create_subprocess_exec) that routes
through _spawn_with_retry -> _spawn_async (NotImplementedError -> thread
fallback + EAGAIN retry); native asyncio path unchanged on supported loops.
- fix _spawn_thread_fallback to forward cwd/env/etc. to subprocess.Popen (was
silently dropping them -- breaks sonitranslate's cwd= pip install).
- convert raw spawns: dub_generate atempo, tools ffprobe, gallery yt-dlp (x2),
sonitranslate install (x4). translation_engines already had its own fallback.
- tests: NotImplementedError -> thread fallback; cwd forwarding; stdin input
(atempo); native path unchanged.
No behavior change off the broken loop (macOS/Linux/Windows-prod): the native
asyncio subprocess is still used; the fallback only triggers on NotImplementedError.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(docker): runtime API-base override so served deployments reach the backend (#174)
Docker users reported Settings -> Engines failing with 'Failed to load engines:
Failed to fetch'. Two problems: (1) the two API-base resolvers diverged
(client.ts honored VITE_API_URL; apiBase.ts honored VITE_OMNIVOICE_API), and
the docs documented VITE_OMNIVOICE_API -- which the Engines request path
ignored; (2) VITE_* is inlined at BUILD time, so a prebuilt ghcr.io image has
no working runtime override at all for reverse-proxy / split-origin deploys.
- backend: when OMNIVOICE_PUBLIC_API_BASE is set, inject it into index.html as
window.__OMNIVOICE_API_BASE__ (core/spa_inject.py; validated to a plain
http(s) URL so it can't break out of the <script>). Unset (default) ->
StaticFiles serves index.html untouched (same-origin, zero overhead).
- frontend: both resolvers (client.ts _resolveApiBase + utils/apiBase.ts) now
read the runtime global FIRST, then VITE_OMNIVOICE_API/VITE_API_URL, then
fall through to same-origin. client.ts also strips trailing slashes and
recognises __TAURI_INTERNALS__ (parity with apiBase.ts/external.ts).
- docs: docker.md + troubleshooting.md document OMNIVOICE_PUBLIC_API_BASE as the
runtime override that works on the prebuilt image (the old VITE_OMNIVOICE_API
docker run -e example never worked -- build-time inlining).
- tests: spa_inject helpers (inject + URL validation/breakout); resolver
precedence for the runtime global + VITE_OMNIVOICE_API in both test files.
Default same-origin behavior is unchanged on every platform; override is opt-in.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(model): bound first-run model load/download so it never hangs forever (#173)
Windows users reported 'create demo voice runs indefinitely, no audio, no
error'. Root cause: the first /generate triggers OmniVoice.from_pretrained()
which downloads multi-GB weights via the legacy LFS path (HF_HUB_DISABLE_XET=1),
with NO timeout anywhere. A stalled socket (proxy/firewall/AV) blocks the GPU-
pool worker forever inside get_model() -- before the try/except that would
surface an error -- and the frontend /generate fetch had no abort, so the
spinner spun forever with no toast.
- backend/main.py: set HF_HUB_ETAG_TIMEOUT=15 + HF_HUB_DOWNLOAD_TIMEOUT=30
(per-read timeout: resets on each chunk, so slow-but-progressing downloads
are never punished; only a dead socket trips it). Set before hf import.
- model_manager: get_model()/preload_model() now load via _load_model_with_timeout(),
an asyncio.wait_for backstop (OMNIVOICE_MODEL_LOAD_TIMEOUT, default 1200s) that
drops the poisoned GPU pool and raises a clear, actionable RuntimeError so a
retry gets a fresh worker instead of queueing behind the wedged one.
- useTTS.js: AbortController backstop on /generate so the UI never spins forever
even if the backend is unreachable; friendly timeout toast.
- tests: watchdog raises + resets pool + releases lock; env/floor parsing.
Cross-platform (no OS-specific behavior); backward-compatible with installed
models; local-first preserved.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
chore: gitignore Spec Kit/GSD local tooling; track network-sharing plan (#172)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(network): remote LAN-share UI actually works (same-origin API + safe clipboard) (#171)
* fix(network): remote share UI must use same-origin API, not hardcoded :3900
When a device opens the LAN-share URL, the SPA is served by the share listener
on :5050 but client.ts hardcoded the API to :3900 — cross-origin (CORS-blocked)
AND loopback-only/unreachable from another machine, so every fetch failed. The
share listener serves the same app+API, so the remote SPA must hit its OWN
origin. _resolveApiBase: Tauri→127.0.0.1; vite-dev→:3900 (CORS-allowed); else
(share listener / docker / prod build)→window.location.origin. +6 unit tests.
* fix(ui): safe clipboard copy over plain HTTP (LAN-share remote devices)
navigator.clipboard is secure-context-only (https/localhost); on a LAN-shared
instance at http://<ip>:<port> it's undefined, so unguarded
navigator.clipboard.writeText(...) threw 'Cannot read properties of undefined
(reading writeText)' — crashing copy buttons on remote devices. Add a copyText
util (clipboard API when available, hidden-textarea + execCommand fallback
otherwise) and route all 9 call sites through it.
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat(settings): dedicated Appearance tab + global font selection (#170)
* feat(settings): global font preference applied app-wide
Add a persisted, system-safe global font selection. A new `font` pref in
the prefs slice overrides the root `--font-sans` CSS variable (the whole
UI uses `font-family: var(--font-sans)`), so the choice applies app-wide;
`default` removes the override and falls back to the :root Inter stack.
- prefsSlice: FontId type, FONT_OPTIONS/FONT_STACKS tables, font + setFont
- store/index: persist `font` via partialize; re-export font tables/type
- App.jsx: re-apply persisted font on launch in the rehydrate effect
- AppearancePanel: Font row (Select) next to UI-scale and color-theme
- AppearancePanel.test: covers render, selection, and default reset
All stacks are system fonts (no web-font downloads) so behavior is
identical offline across macOS/Windows/Linux.
* feat(settings): move Appearance into its own Settings tab
Promote the Appearance panel out of the Credentials tab into a dedicated
top-level tab (with the Palette icon), placed before Credentials. Add the
English "appearance" label so the tab renders via t(`settings.${id}`).
Remove the AppearancePanel render (and its stale comment) from
CredentialsTab.
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(network): footer Local toggle dead in Tauri (window.confirm no-op) (#169)
The footer Local/Network pill called window.confirm() before enabling, but
window.confirm is a no-op in the Tauri webview (returns false), so the enable
action was silently swallowed — the button appeared to do nothing. Replace it
with a reliable in-app confirm popover (Cancel/Enable). The backend endpoint
was always working (verified: enable opens a real listener on the share port).
Adds a regression test for the Local -> confirm -> Enable -> POST flow.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(diarization): use_auth_token->token shim for pyannote on HF Hub 1.x (#167) (#168)
* fix(diarization): shim use_auth_token->token for pyannote on HF Hub 1.x (#167)
pyannote-audio 3.x (pipeline.py:102) calls hf_hub_download(use_auth_token=...),
which huggingface_hub 1.x removed (only 'token' now) -> 'unexpected keyword
argument use_auth_token', breaking speaker diarization. Wrap hf_hub_download/
snapshot_download to translate the dead kwarg, applied before pyannote's
'from huggingface_hub import hf_hub_download' binds it (+ patch already-loaded
pyannote modules). Verified the real pyannote reference binds the shim.
* test(diarization): use pytest.importorskip (fixes CodeQL uninitialized-local)
CodeQL doesn't model pytest.skip() as no-return, so it flagged _pp as a
possibly-uninitialized local (py/uninitialized-local-variable, error). Switch
to pytest.importorskip — cleaner and CodeQL-clean.
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(scripts): Windows (Git Bash/MSYS) support across install/run/smoke-test (#164) (#166)
Follow-up to #165 — the same uname Darwin/Linux-only pattern in the other
dev scripts:
- smoke-test.sh: add MINGW*/MSYS*/CYGWIN* detection + Windows data paths
(%APPDATA%/%LOCALAPPDATA%, per backend/core/config.py) + .exe binary suffix.
- run.sh: Windows detection + %LOCALAPPDATA% log dir; backend launch already
uses 'uv run' which is cross-platform.
- install.sh: detect Windows and print a clear 'use the .msi / WSL' message
(the brew/apt system-dep installer can't run on native Windows) instead of
silently treating it as Linux.
build-omnivoice-tts.sh already handled windows-x86_64; record-reference.sh
already guards macOS-only with a clear message.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(scripts): support Windows (Git Bash/MSYS) in desktop-prod.sh (#164) (#165)
uname -s returns MINGW64_NT/MSYS_NT/CYGWIN_NT under Git Bash, which hit the
catch-all 'Unsupported platform' error. Add a windows case + a Windows data-
path branch (%APPDATA%\OmniVoice backend data + %LOCALAPPDATA% Tauri/HF dirs,
per backend/core/config.py) + launch the debug .exe. The cross-platform
'tauri build' step is unchanged; every rm stays [ -d ]-guarded so an off path
is a no-op, never a wrong delete.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat(network): user-configurable backend / LAN-share / UI ports (#163)
* feat(ports): make backend/share/UI ports configurable via env vars
Single-source the backend port from OMNIVOICE_PORT and derive the LAN-share
base from it (OMNIVOICE_SHARE_PORT override). Previously network_share.py
hardcoded BACKEND_PORT=3900, so a user running the backend on a custom port
got LAN-share/Tailscale pointed at the wrong port.
- network_share: replace BACKEND_PORT constant with backend_port() /
share_port_base() helpers (env-driven, never-throw fallback to defaults);
enable() probes from share_port_base().
- tailscale: serve_enable(port=None) defaults to network_share.backend_port().
- main.py: direct-run + --health-check ports and HEALTH_URL read OMNIVOICE_PORT;
CORS default origins use OMNIVOICE_UI_PORT (default 3901).
- /system/info + SystemInfoResponse: expose backend_port, share_port_base,
ui_port (both success and never-throw except branches).
- set-env: persist OMNIVOICE_PORT/SHARE_PORT/UI_PORT; validate numeric and
1024-65535, reject otherwise with 400.
* feat(ports): pin child OMNIVOICE_PORT in backend spawn
Push OMNIVOICE_PORT=backend_port() onto the spawned Python child's env so
network_share.backend_port() always agrees with the uvicorn --port Rust
passes. Without this, a user-set OMNIVOICE_PORT would move the LAN-share /
Tailscale target while the listener stayed on the Rust-resolved port.
* feat(ports): Ports subsection in Sharing settings + env-driven Vite port
- vite.config.js: dev-server port reads OMNIVOICE_UI_PORT (default 3901).
- SharingPanel: new Ports subsection reads /system/info and displays
backend_port / ui_port (with their env-var names + restart-to-apply note)
and makes the LAN-share port editable — Save POSTs OMNIVOICE_SHARE_PORT to
/system/set-env (persisted), "applies next time you enable sharing".
- Tests: extend SharingPanel.test.jsx for the ports subsection.
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
test: scrub brand name from whisper segmentation fixture (#162)
The whisper_screenshot transcription fixture + its segmentation test
referenced a real product/brand name. Swap it for the neutral placeholder
'Acme' (fixture text + chunks + the expected-segment assertions), keeping
the test's consolidation behavior identical.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(tailscale): HTTP serve fallback when tailnet lacks HTTPS certs + parallel dev launch (#161)
* fix(tailscale): serve over HTTP when tailnet has no HTTPS certs
Real-world failure: 'tailscale serve --https=443' on a tailnet without the
HTTPS Certificates feature (CertDomains: None) fails with 'error enabling
https feature: 404'. Detect cert availability from status --json and use
--https only when certs exist; otherwise serve over --http (the WireGuard
tunnel encrypts transport anyway). Also surface a clear note/error instead
of the raw 404, and a 'run tailscale up' hint when not running. Verified the
--http path live on a real tailnet. SharingPanel now shows the returned note.
* chore(dev): launch app in parallel with backend (drop wait:api gate)
dev/desktop no longer block the Tauri/vite launch on the API being HTTP-ready
— the window appears immediately and the frontend's setup-status check
already retries (30x1s) until the API answers. Matches prod, where the window
shows BootstrapSplash while the sidecar boots. Dev-only; no shipped change.
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
polish(network): outermost PIN gate + non-buffering ASGI middleware + listener test (#160)
* fix(network-share): mount RemoteAuthGate at outermost provider
Move the <RemoteAuthGate> wrap from App.jsx's main-studio return up to
main-app.jsx, inside QueryClientProvider and wrapping the entire app tree
(both the dictation widget and <App />). Previously the gate only wrapped
the studio return, so a remote device opening a bare URL (no ?pin=) during
first-run states — the /setup/status check, SetupWizard, or BootstrapSplash
early returns — would 401 with no gate rendered to collect the PIN. The QR
path was fine (PIN captured pre-fetch in client.ts); only bare-URL was broken.
Remove the App.jsx wrap to avoid double-gating (two PIN dialogs). No behavior
change for loopback or QR users.
* perf(network-share): make NetworkAccessMiddleware non-buffering ASGI
Rewrite NetworkAccessMiddleware from a starlette BaseHTTPMiddleware into a
pure ASGI middleware (class with __init__(app) and __call__(scope, receive,
send)). BaseHTTPMiddleware buffers StreamingResponse/SSE bodies before
forwarding them, so PIN'd LAN clients on streaming endpoints (dictation SSE,
tts streaming, /system/logs/stream) got buffered/laggy responses. Loopback was
unaffected (bypasses early), but remote-share streaming was degraded.
The ASGI form forwards send untouched on every pass-through path, and only
wraps send to inject Set-Cookie on the http.response.start message for the
first valid-PIN request — the body keeps streaming chunk-by-chunk. request.app
resolves in ASGI scope (Starlette sets scope["app"]), so the inert/loopback/
shell/PIN logic is identical to before. Registered after CORS (unchanged) so
CORS stays outermost.
All 5 existing behavior tests pass unchanged. Adds three tests: a guard that
the middleware is not a BaseHTTPMiddleware subclass, a StreamingResponse
pass-through (401 without PIN, full chunked stream with PIN, no buffered
Content-Length), and a Set-Cookie-via-ASGI assertion.
* test(network-share): integration test for real listener lifecycle
Add tests/test_network_share_lifecycle.py exercising the real second uvicorn
listener: await network_share.enable(app) on a minimal FastAPI app, assert
get_state().enabled is True with a share_port set and a live TCP listener on
that port (real socket connect), then await disable(app) and assert the state
resets and the port stops accepting connections.
Uses the returned share_port (never a hardcoded port) and tolerates teardown
timing by polling for socket close. Wrapped in asyncio.run inside a sync test
so it does not depend on a pytest-asyncio event-loop mode; skips gracefully if
binding 0.0.0.0 is not permitted in the sandbox. Defensive cleanup resets the
module-level state on any failure path.
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat: network sharing (PIN-gated LAN + QR) & Tailscale remote access (#125) (#159)
* docs(spec): network sharing + Tailscale remote access design
Same-state LAN sharing via a second in-process uvicorn listener on a
dedicated share port (no restart, model/jobs preserved), PIN-gated for
non-loopback clients, with QR + all-LAN-addresses panel. Tailscale serve
for private remote access. Supersedes the raw 0.0.0.0 default-flip in #125.
* docs(spec): control endpoints reuse existing require_loopback gate
Security review of #157 confirmed the /system router is already loopback-gated
via Depends(require_loopback) (non-spoofable request.client.host). The network
control endpoints inherit it and /system/set-env is auto-protected from the
LAN listener — no new guard needed.
* feat(network): share-listener module — LAN enumeration + PIN + lifecycle
* feat(network): loopback-only control endpoints + /system/info sharing fields
* test(cjk): scan git-tracked files only, not untracked vendored dirs
The no-hardcoded-CJK guard walked the filesystem, so local untracked
vendored experiments (research/voice-pro etc. with JP issue templates)
caused false local failures while CI (committed files) passed. Scan via
git ls-files so local-only and CI behavior match.
* feat(network): PIN middleware — gate non-loopback API access when sharing on
* feat(network): inject X-OmniVoice-Pin globally + capture ?pin= from QR URL
* feat(network): remote PIN gate on 401
* chore(network): add qrcode dep for share QR
* feat(network): footer Local/Network toggle with LAN addresses, QR, copy/open
* feat(tailscale): CLI status + serve enable/disable + endpoints
* feat(network): Settings → Sharing & Remote Access panel (LAN + Tailscale)
* docs(network): sharing & remote access guide (LAN PIN/QR + Tailscale)
* fix(network): enable() tears down and raises if the share listener never binds
Defensive guard (spec §7): if the second uvicorn server doesn't reach
'started' (e.g. the share port was taken in the race after the free-port
probe), cancel the task, reset state, and raise — so the API surfaces the
failure and the UI stays Local rather than reporting a dead 'Network' state.
* test(network): use globalThis (not Node global) in client.test.ts for tsc
CI runs 'tsc --noEmit --checkJs false', which type-checks .ts files; Node's
'global' isn't typed there (TS2304). vitest (esbuild) tolerated it locally.
Use globalThis (standard, typed) + cast the mock.
* fix(network): apiFetch leaves opts untouched when no PIN set
The unconditional headers merge changed the request shape for callers with
no headers (e.g. FormData posts), breaking the legacy 'apiPost passes
FormData without Content-Type override' node test. Only spread opts +
inject X-OmniVoice-Pin when a PIN is actually present; otherwise pass opts
through unchanged.
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(bootstrap): scrub PYTHONHOME/PYTHONPATH before uv so AppImage venv build succeeds (#144, #127) (#158)
On the Linux AppImage, the bundled runtime exports PYTHONHOME / PYTHONPATH
(and sometimes LD_LIBRARY_PATH) pointing at the AppImage's *own* bundled
Python. When first-run bootstrap shells out to `uv` to create/sync the venv,
that build subprocess inherits those vars, so the freshly-built managed
interpreter resolves its stdlib against the wrong (AppImage) Python and dies
with `ModuleNotFoundError: No module named 'encodings'` while compiling a
transitive dep (dora-search/demucs). This surfaces downstream as
"Backend process exited (never started) — no error output captured" (#144).
The backend spawn in backend.rs already scrubs these vars before launching
uvicorn; the uv/venv/pip subprocesses in bootstrap.rs were scrubbing them too
but via five inline copies, which is drift-prone. Factor the scrub into a
single `scrub_python_env(cmd)` helper documenting the #144 root cause, and
apply it at every uv/venv/pip call site (uvicorn import check, repair sync,
venv create, sync, ROCm reinstall). Add a unit test asserting the helper
queues removals for all three vars.
Safe cross-platform: those vars are normally unset on macOS/Windows and
`env_remove` on an unset var is a no-op, so default behavior is unchanged
everywhere. Compile-validated (cargo check + cargo test pass); no AppImage in
CI to reproduce the original failure end-to-end.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
l10n(zh-CN): full Chinese localization + Windows/settings fixes (absorbed from #66) (#157)
* @
fix: skip torch.compile on Windows where Triton is unavailable
torch.compile with mode="reduce-overhead" depends on Triton, which has no
official Windows support. On Windows the compile call succeeds but generates
code paths that crash at inference time with an OOM-like error
("Cannot find a working triton installation").
Check for Triton availability before compiling so TTS gracefully falls back
to eager mode on platforms without Triton.
Closes#65
SummerSec
@
* feat: comprehensive Chinese (zh-CN) localization for Settings and navigation
Add full Chinese (zh-CN) translation support across the frontend:
- NavRail, Launchpad, Clone/Design tabs, Settings (all tabs)
- Sidebar navigation labels, hero text, action cards, section headings
- Fix: Settings missing General tab in TABS array
- Fix: i18n locale not persisted after page reload (useEffect deps)
- Fix: NavRail key prop spreading into JSX elements
Co-authored-by: SummerSec
* fix: translate production override parameter labels (Speed, t_shift, etc.)
* fix: translate voice design category labels (Gender, Age, Pitch, etc.)
* feat: translate Transcriptions and Voice Gallery pages
* fix: translate gallery category names (Disney, Anime, etc.)
* feat: translate DubTab, personality presets, and voice design presets
* fix: remove duplicated emoji in personality name translations
* fix: correct preset translation keys to match actual preset IDs
* fix: filter natural language from personality instruct to prevent validation error
* fix: handle edge case where instruct has no valid tags
* chore: remove debug logging from personality instruct filter
* fix: address CodeRabbit review — importlib.util, English comment, grammar, theme, placeholder
* fix: localize selected category label in VoiceGallery header
* feat: translate remaining DubTab UI text (CAST, Generate Dub, Translate All, etc.)
* fix: improve ffmpeg detection on Windows, error messages, and yt-dlp download timeout
* feat: add proxy setting in Settings → General for downloading via proxy
* fix: improve ffmpeg detection on Windows, error messages, and yt-dlp download timeout
* feat: allow HTTP_PROXY/HTTPS_PROXY env vars via /system/set-env
* fix: support SOCKS5 proxy, also set ALL_PROXY env var
* fix: increase yt-dlp extractor retries for subtitle 429 errors
* feat: translate prep overlay stage labels (download, extract, demucs, scene)
* feat: translate BatchQueue, VoiceProfile, ToolsPage, Projects pages
* feat: translate SetupWizard, DonatePage, EnterprisePage + fix NotImplementedError handling
* feat: add ffmpeg status + manual path setting in Settings → General
* fix: validate ffmpeg path exists when user sets it manually
* fix: fall back to thread-based subprocess when asyncio raises NotImplementedError on Windows
* fix: pin setuptools<70 — ctranslate2 requires pkg_resources removed in 70+
* fix: translate transcribing overlay text
* fix: complete DubTab zh-CN localization + argostranslate preflight check
* fix: add cmn-Hans language code mapping for Google Translate
* fix: fall back to thread-based pip install on Windows when asyncio subprocess raises NotImplementedError
* feat: add DeepL/Microsoft/LLM credential fields to Settings
* feat(i18n): localize GlossaryPanel, DubSegmentTable, DubSegmentRow
* fix: Windows-safe log rotation handler avoids PermissionError on rename
* feat: persist proxy/FFmpeg/LLM/translation credentials, separate DeepL/Microsoft keys, add glossary max-height scroll and collapse
- /system/set-env writes env.* to prefs.json via prefs.set_()/delete()
- Backend startup reads env.* from prefs.json into os.environ (.setdefault)
- PERSISTENT_KEYS covers proxy, FFmpeg, LLM, DeepL/Microsoft keys
- DeepL uses DEEPL_API_KEY, Microsoft uses MICROSOFT_API_KEY (fallback TRANSLATE_API_KEY)
- DeepL/Microsoft _build_translator reads DEEPL_BASE_URL/MICROSOFT_BASE_URL
- Google/MyMemory/Microsoft translators bypass Windows registry proxy
- Frontend CREDENTIAL_GROUPS splits into 4 groups with password/text fields
- Glossary panel body max-height: 35vh + overflow-y: auto
- Glossary panel can be collapsed via ChevronDown button
- queryClient.invalidateQueries after save for immediate refresh
- SystemInfoResponse adds proxy_url, ffmpeg_ok, ffmpeg_path
* feat(i18n): localize ExportModal with zh-CN support
- Add useTranslation + replace ~50 hardcoded strings with t() calls
- Add exportModal namespace to en.json and zh-CN.json
- Cover presets, tracks, tabs, video/audio/subs/package tabs, license notice, and output footer
* fix: address PR #66 security review feedback
- Regenerate uv.lock against pypi.org (remove TUNA mirror URLs)
- Route HF_TOKEN through huggingface_hub.login() instead of prefs.json
- Add os.chmod(prefs_path, 0o600) for restricted file permissions
- Add warning logs to _WindowsSafeRotatingFileHandler bare except blocks
* docs: add Chinese translation README_CN.md
* docs: add link to Simplified Chinese translation in README.md
* docs: add English/Simplified Chinese cross-links between READMEs
* fix(l10n): restore clickable Discord/email footer links in EnterprisePage
The i18n extraction replaced main's clickable <button onClick=openExternal>
footer links with bare {t()} labels, dropping both the clickable behavior
and the literal Discord URL — which broke test_discord_link_updated
(EnterprisePage missing discord.gg/bzQavDfVV9). Restore both as clickable
links wrapping the translated label, with the hardcoded URL/mailto (URLs are
not translated). Keeps i18n, restores functional parity with main.
* chore(l10n): no-hardcoded-CJK rule + enforce; clean dead LLM block; harden set-env
- Add 'Localization (hard rule)' to CLAUDE.md: no hardcoded non-English UI
text outside frontend/src/i18n/; functional CJK allowed via allowlist.
- New tests/test_no_hardcoded_cjk.py enforces it (allowlists text-processing
regexes, model/engine vocab & IDs, error matching, demo/eval data, fixtures).
- Settings.jsx: remove dead saveLlm block (Chinese toasts + unused llm* state,
flagged by CodeQL js/unused-local-variable); render language-picker native
names from new LANGUAGES export in i18n/index.ts instead of hardcoding.
- main.py: drop unused 'import shutil' (CodeQL py/unused-import).
- system.py: harden FFMPEG_PATH/FFPROBE_PATH set-env (reject control chars;
defense-in-depth for the py/path-injection finding). Endpoint stays
loopback-only — network sharing must never expose /system/set-env.
* chore: drop unused ui import (Panel) + fix implicit str-concat in cjk test
Clears the two CodeQL notes introduced/attributed to this PR:
- Settings.jsx: remove unused 'Panel' from the '../ui' import (js/unused-local-variable).
- test_no_hardcoded_cjk.py: collapse multi-line message strings to single lines
(py/implicit-string-concatenation-in-list).
---------
Co-authored-by: SummerSec <summersec@qq.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(windows): port-conflict kill + Triton/torch.compile disable (salvaged from #85) (#156)
* fix(windows): port-conflict kill + Triton/torch.compile disable (salvaged from #85)
Salvages the two safe, valuable Windows fixes from community PR #85
without the changes that would regress all users.
backend.rs — implement the Windows branch of `kill_orphan_on_port`,
which was a no-op (`pub fn kill_orphan_on_port(_port: u16) {}`). It now
parses `netstat -ano -p TCP` for the LISTENING socket on exactly `port`
(suffix-matched on ":PORT" to avoid e.g. :3900 matching 39000) and kills
the owning PID via `taskkill /PID <pid> /F`. Behind `#[cfg(not(unix))]`;
the unix branch is untouched. Signature now matches the unix branch.
main.py — on Windows (sys.platform == "win32"), default
TORCH_COMPILE_DISABLE / TORCHDYNAMO_DISABLE / TORCHINDUCTOR_DISABLE to
"1" before torch is imported. Triton has no Windows wheel, so these
prevent TritonMissing/dynamo errors. Uses os.environ.setdefault (never
overrides an explicit user value) and is win32-guarded, so cross-platform
default behavior is unchanged.
Intentionally NOT salvaged from #85:
- The unconditional `os.environ["HF_HUB_OFFLINE"] = "1"` in main.py and
the `local_files_only=True` / HF_HUB_OFFLINE save-restore in
model_manager.py. This breaks first-run model downloads for every user
(downloading models on first use is the core value prop). Offline mode
must stay opt-in — only when the user sets HF_HUB_OFFLINE themselves.
- The model_manager.py torch.compile/_get_gpu_pool changes: already
present on main in a superior form (`should_torch_compile()` gating
from plan-02/#65 and the existing `_get_gpu_pool()` lazy pool), so
applying #85's cruder `TORCH_COMPILE_DISABLE` env check would regress.
- The 512-line README rewrite, the ~50 frontend .jsx formatting-only
diffs, and the personalities.py attrs additions (out of scope).
Refs #85.
Co-Authored-By: caaaaaleb <caaaaaleb@users.noreply.github.com>
* fix: move win32 torch-disable block below sys.path preamble
test_main_py_bootstrap_adds_backend_dir asserts the first 15 lines of
main.py contain sys.path.insert + _backend_dir. The win32 block was placed
above the preamble, pushing them out of range. The torch env vars only need
to precede torch's (lazy) import, so moving the block below the sys.path
bootstrap keeps behavior identical and restores the test.
---------
Co-authored-by: caaaaaleb <caaaaaleb@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
docs(install): clarify macOS Gatekeeper "damaged" workaround (#134) (#155)
Reword the macOS install doc's Gatekeeper section so it's findable by the
exact symptom ("App is 'damaged' / can't be opened"), and spell out both the
GUI path (right-click -> Open, or System Settings -> Privacy & Security ->
Open Anyway) and the Terminal path (xattr -dr com.apple.quarantine ...).
Explains WHY macOS shows "damaged" (the build isn't notarised yet, so it gets
quarantined) and why the workaround is safe (downloaded from the official
repo/Releases). Proper fix remains Apple Developer signing + notarisation,
already wired in release.yml behind the documented secrets.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat(bootstrap): opt-in AMD ROCm torch install (#124) (#154)
Detection already routes ROCm through torch.cuda (get_best_device +
_configure_rocm_if_needed), but the default install ships the CUDA torch
build, so AMD-only machines fall back to CPU.
Add an opt-in post-sync step: when OMNIVOICE_TORCH_VARIANT=rocm is set, the
bootstrap reinstalls torch/torchaudio from the ROCm wheel index
(default https://download.pytorch.org/whl/rocm6.2, overridable via
OMNIVOICE_TORCH_INDEX). Strictly gated — default (unset) leaves the
CUDA/CPU path untouched — and non-fatal: a failed ROCm reinstall keeps the
working default build and points the user at docs/install/linux.md.
rocm_opt_in() + rocm_torch_reinstall_args() are pure and unit-tested
(gating + index override + arg shape). cargo test green.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(onboarding): hide DictationDemo when sample assets are absent (#119 follow-up) (#153)
DubbingDemo and DemoPresetGrid already degrade gracefully (hide) when their
assets / is_demo profiles are missing, but DictationDemo always rendered its
three hardcoded cards — which fail on click without the bundled sample WAVs
(rendered by scripts/build_demos.sh; absent in a plain source checkout).
Add a mount-time HEAD probe of the first sample; if it's not present, hide
the whole demo (mirrors DubbingDemo's missing-manifest behavior). When assets
are present, behavior is unchanged.
Test: HEAD 404 → demo renders nothing.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(dub): async-ify _pitch_preserving_stretch (#133 Greptile P1) (#152)
_pitch_preserving_stretch ran a blocking subprocess.run() inside the
`_stream` async generator (on the event loop). Each ffmpeg atempo call is
~50-100 ms, so on a multi-segment time_stretch dub job it froze health
checks, status SSE, and every other concurrent request for seconds.
Convert to asyncio.create_subprocess_exec + await communicate() (same
pattern as run_proc_streaming_stderr); await the call site in _stream.
Drop the now-unused `import subprocess`.
Tests: async coroutine + target-length + no-op cases (real ffmpeg).
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
docs(#124): document AMD GPU (ROCm) install path (#151)
Detection already works (get_best_device + HSA_OVERRIDE_GFX_VERSION); the
gap was that the default install ships CUDA torch, so AMD users fell back
to CPU with no guidance. Document the opt-in ROCm wheel swap (rocm6.2),
the device-verify one-liner, and the HSA override for unsupported GFX.
Linux-only, opt-in — default cross-platform behavior unchanged. An
installer-integrated env-var-driven wheel selection is a tracked follow-up.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat(dub): audio-only dubbing mode (#119) (#150)
* feat(dub): audio-only dubbing mode (#119)
Add an audio→audio dubbing path: upload an audio file, get dubbed audio
out, with no video processing. The transcribe → translate → TTS core is
unchanged; only the video-coupled stages are skipped.
Backend:
- dub_core /dub/upload: new `input_type` form field ("video"|"audio").
Audio mode validates the upload is a known audio container (else 400)
and threads input_type into the ingest source dict.
- dub_pipeline ingest: for audio input, skip scene detection + thumbnail
ffmpeg passes (still emits scene_done count=0 so the prep SSE contract
the frontend waits on is unchanged); stores input_type on the job.
- dub_export /dub/download: for audio jobs, branch to an audio-only export
(_build_audio_export_cmd) — no video input/map/codec/subtitle pass.
Outputs dubbed_audio_{lang}_{stamp}.{wav|m4a|mp3|flac} via `out_format`
(default m4a), optionally mixed with the separated background. Unknown
formats fall back to AAC.
Frontend:
- dubSlice: dubInputType state + setter (default 'video').
- DubTab: auto-select audio-only mode when an audio file is dropped/picked.
- dub.ts/useDubWorkflow: pass input_type on upload.
Tests (11): _build_audio_export_cmd format/mix matrix; end-to-end audio-only
export produces an audio file (no video mux); unknown-format fallback;
upload rejects a video extension in audio mode.
Closes#119.
* harden(#119): allowlist-sanitize lang_code in audio export path
The track id is already constrained to an existing track key, but
allowlist-sanitize it before it reaches the output path (same pattern as
the existing safe_name) so a path component can never carry separators —
clears the CodeQL path-injection flag on the new audio-export branch.
* polish(#119): address Greptile P2s on audio-only dubbing
- dub_pipeline: emit scene_start before scene_done(count=0) for audio so
the prep SSE stage sequence is symmetric with the video path.
- useDubWorkflow: 'Preparing audio…' pill for audio jobs (was always
'Preparing video…').
- DubTab: widen the drop-accept regex + file-input accept to the full
supported audio set (aac/opus/wma) so it matches the input-type
detection and the backend allowlist.
* fix(#119): drop unused dubInputType read in DubTab (CodeQL)
Only setDubInputType is used; the value read was dead. Clears the
CodeQL unused-variable alert.
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat: onboarding demos, opt-in bug reporting, error-docs deeplinks + issue triage (#133)
* feat: onboarding demos, opt-in bug reporting, error-docs deeplinks + issue triage
Working-tree snapshot bundling several in-flight workstreams (v0.3.0):
- Onboarding/demo system: DemoPresetGrid, DictationDemo, DubbingDemo components
+ tests, render scripts (render_demos_omnivoice.py, build_demos.sh,
build_dub_demo.sh), personalities preview URLs, alembic 0002 voice-profile
demo fields.
- Opt-in bug reporting: ReportBugButton (prefilled GitHub-issue URL path).
- Error transparency UX: errorDocsMap deeplinks + BootstrapSplash/error wiring.
- Dub workspace: DubSegmentRow/Table, WaveformTimeline, dubSlice tweaks.
- Issue triage: .planning/issue-clusters/ (plan-01..05 root-cause masters,
GH #128-#132).
- CLAUDE.md: hard rule — everything ships on v0.3.0, no version bumps.
KNOWN GAP (why this is a draft): the generated demo audio assets are NOT in
this tree, and backend/assets/samples/demo_voice.wav is deleted. onboarding.py
guards the missing file (skips seeding the demo profile with a warning), so no
crash — but first-run Launchpad will be empty and /demo_audio/ preview URLs
404 until assets are regenerated via scripts/build_demos.sh. Do not merge
before regenerating + committing the demo assets.
* feat(dub): timing strategies — kill audio compression, add Concise + Stretch Video
Replaces the current audio time-compression default (atempo squeeze to fit
slot) that produced chipmunk/alien output on high-density target languages
like Bengali. Two new user-selectable modes; legacy behaviour kept behind
an explicit "Strict slot" choice.
New `DubRequest.timing_strategy` enum (default "concise"):
- "concise" Translator trims text to fit at natural rate; if it
still overflows, hard-trim at slot with a fade so we
never overlap the next speaker. Surface overflow_s
per segment so the user can shorten the text.
- "stretch_video" Audio plays at natural 1.0× rate. Backend computes a
per-segment new timeline; persists a video_stretch_plan
on the job. Mux step (dub_export) builds an ffmpeg
trim+setpts+concat filter graph that stretches each
segment's video portion to match the natural-rate dub
audio. Gaps/pre-roll/tail pass through at 1.0×.
Sub burn under stretch_video is skipped in one pass
(cues would drift).
- "strict_slot" Legacy atempo squeeze. Retained for back-compat.
Director rate-bias side-effect (seg_speed *= bias) now gated on strict_slot
only, so "urgent"/"slow" direction tokens keep their instruct effect in
the new modes without chipmunking.
Per-segment fit_status emitted in the SSE done event:
{status: "fits" | "overflows" | "video_stretched", overflow_s?, stretch_ratio?}
DubSegmentRow's "Sync: 100%" badge (which was lying — sync_ratio was always
~1.0 because the TTS loop pre-trimmed to slot) is replaced with a truthful
"Fits / Overflows +Ns / Video 1.18×" label.
Frontend:
- prefsSlice.timingStrategy (persisted, store v3→v4 with safe migrate).
- DubTab footer Segmented control: "Concise · Stretch Video · Strict slot".
- useDubWorkflow passes timing_strategy on /dub/generate; consumes fit_status.
Tests: tests/test_dub_timing_strategy.py — 13 cases covering schema
defaults/validation, _build_video_stretch_filter_graph (pre-roll, gap,
tail, empty-plan early return, post-subtitle chain-in), and
_video_stretch_plan_for guards. 30/30 existing dub tests still pass.
* fix(waveform): surface missing source as "Source media missing" instead of code-4 black box
When a project's underlying media file is gone (moved or deleted between
save and reload) the <video> element fires MediaError code 4 and the
companion audio fetch returns HTTP 404 — both were silently warned to
the console while the user stared at an unresponsive black panel and an
empty waveform.
- WaveformTimeline now flips loadError when the video element rejects
code 3 (decode) or 4 (src not supported), and tracks `sourceMissing`
separately so the error UI can name the actual problem.
- The audio decode fallback chain catches HTTP 404 specifically and
treats it as source-missing instead of loading silent empty peaks —
an empty waveform on a deleted source is more confusing than a clear
"Re-upload the video to continue" message.
* fix(tray): "Show OmniVoice" reloads when the webview is blank
When the dev Vite server restarts (or the main window is created before
the backend is ready), the webview load fails and the window is left
with `<body></body>` plus a "Could not connect to the server" console
error. Clicking "Show OmniVoice" from the tray menu just re-showed the
broken window — there was no recovery path short of quit+relaunch.
Now the show handler runs a tiny eval after `show()`/`set_focus()` that
calls `location.reload()` only when `document.body.childElementCount === 0`.
A healthy window doesn't blink (body is non-empty); a blank one
self-recovers as soon as the user clicks Show.
* fix(#133): bug-report diagnostics field mapping + drop unused imports
Address PR #133 review:
- ReportBugButton: /system/info exposes `platform` + `device`, not
`os`/`torch_device`/`gpu` — those reads silently dropped OS/GPU from every
bug report. Map to the real fields (CodeRabbit). Also remove the dead
`home` local in stripHome (CodeQL unused-variable).
- DictationDemo: drop unused `Loader` import (CodeQL unused-import).
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
feat(settings): configurable models directory (#64) (#149)
* feat(settings): configurable models directory (#64)
Let users pick where model weights download (the HuggingFace / Torch
cache) instead of being pinned to ~/.cache/huggingface — useful when the
system drive is small or slow.
Backend:
- core/user_env.py: durable per-user env file (~/.config/omnivoice/env)
helper with upsert/unset that preserves other keys and writes 0600.
main.py already loads this at startup before importing torch/HF, so the
value takes effect on the next launch. Path resolves at call time via an
OMNIVOICE_ENV_FILE override so it's robust to module re-import in tests.
- settings.py: GET/PUT /api/settings/storage/models-dir — validates the
dir is writable (mkdir + write-probe → 400 if not), persists the choice,
and writes OMNIVOICE_CACHE_DIR to the durable env. Empty path clears →
reverts to default. Returns restart_required since an in-use cache can't
be safely moved mid-process. Loopback-gated like the other settings.
Frontend:
- StoragePanel: Models tab panel to view/set/reset the directory, shows
effective vs configured vs default + a restart note.
Cross-platform default parity preserved (default cache path is the HF
default on every OS); local-first (no network); backward-compatible
(absent setting → existing behavior). No version bump.
* fix(#64): harden models-dir input + clear CodeQL hygiene flags
- settings.py: reject control/NUL chars in the path with a 400 before any
filesystem call (an embedded NUL otherwise raised ValueError → 500). Also
serves as the explicit input-validation barrier for the user-chosen path
(loopback-gated same-user local file picker — no cross-privilege boundary).
- test_user_env.py: use `with open(...)` so the file is closed and the assert
has no side effects.
- user_env.py: comment the best-effort chmod except clause.
* refactor(#64): single source of truth for models dir + review fixes
Address CodeRabbit + Greptile review on PR #149:
- P1 (both bots): the settings_store copy of the models dir was only ever
read by this GET endpoint, so it was a redundant cache that could diverge
from the durable env file (the value main.py actually reads). Drop it —
the per-user env file (OMNIVOICE_CACHE_DIR) is now the single source of
truth: PUT writes it, GET reads it back. No divergence possible.
- XDG-aware default (CodeRabbit): _default_models_dir now honors
XDG_CACHE_HOME, matching huggingface_hub's real default on Linux.
- Atomic 0600 write (Greptile, security): user_env writes via an os.open
opener that creates the file 0600 from the start — no world-readable
window before chmod for a file that can hold HF_TOKEN.
- _read_lines only swallows FileNotFoundError; other OSErrors propagate so
an upsert can't silently drop existing keys on a transient read failure.
- Guard makedirs("") when the env path is a bare filename (no parent).
- Best-effort write-probe cleanup in a finally; raise ... from e.
- a11y: label the models-dir input via aria-labelledby/aria-describedby.
- OS-neutral unwritable-dir test (mock makedirs) instead of Unix-only
/dev/null path semantics.
12 tests green.
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(bootstrap): surface why the backend "never started" (refs #144, #127) (#148)
* fix(bootstrap): surface why the backend "never started" (#144, #127)
AppImage users hit "Backend process exited (never started) — no error output
captured" with nothing to act on. Root gap: when `Command::spawn()` of the
venv Python fails (the common Linux/AppImage case — interpreter can't exec,
missing system lib, stale venv), spawn_backend logged the OS error but returned
None silently, so the bootstrap reported "no error output captured".
Now the spawn failure writes a diagnostic (the interpreter path, whether it
exists on disk, the OS error, and an actionable "Clean & Retry / run from a
terminal" hint) to backend_err.log, which the bootstrap's read_error_log_tail
already surfaces. The "no output" dead-end becomes the real launch error.
This makes #144/#127 diagnosable (the underlying AppImage cause then routes from
the now-visible error). Pure message builder is unit-tested; cargo test +
cargo check clean.
Refs #144, #127.
* fix(bootstrap): platform-specific spawn-failure hint (Greptile #148)
The diagnostic tail said "run the AppImage from a terminal… dynamic-loader
error" — meaningless on macOS/Windows (spawn can fail on any OS). Pick the hint
by build-target OS via cfg!: AppImage/loader wording on Linux, venv/quarantine
on macOS, missing-Python/AV-block on Windows. "Clean & Retry" stays universal.
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ci: gate omnivoice-tts build to pin changes; drop hanging Intel-Mac leg (#147)
* ci: gate omnivoice-tts build to pin changes; drop hanging Intel-Mac leg
The omnivoice-tts C++ runtime is pinned to a commit SHA in quant_map.json, so
it only needs rebuilding when that pin (or the build script) changes — not on
every PR/push. Running it per-push left the heavily-contended hosted macOS
runners (esp. Intel macos-13) sitting in "Waiting for a runner…" for hours as
a perpetual queued check (the UNSTABLE state on every PR).
- Moved the build out of ci.yml into its own workflow,
.github/workflows/build-omnivoice-tts.yml, gated to:
paths [quant_map.json, scripts/build-omnivoice-tts.sh, the workflow] +
workflow_dispatch. Normal PRs no longer trigger (or hang on) it.
- Dropped the Intel darwin-x86_64 (macos-13) matrix leg: that hosted pool is
unusably contended and Apple's momentum is on arm64; Intel-Mac users get the
in-process OmniVoiceBackend fallback (already the documented behavior).
Kept linux-x86_64, windows-x86_64, darwin-arm64. Re-add macos-13 here if
first-class Intel binaries are ever needed.
Both workflows YAML-validated. Matches ci.yml's stated philosophy of keeping
heavy platform builds off the per-PR path.
* ci: timeout-minutes + injection-harden the omnivoice-tts build (bot review)
- Greptile: add `timeout-minutes: 45` so a hung leg (esp. experimental
darwin-arm64 Metal) can't run to GitHub's 6h ceiling — same resource-drain
class this PR addresses.
- CodeRabbit: stop interpolating the pinned SHA / platform directly into the
run block. Validate the SHA is a git hash in the pin step, then pass it +
platform via quoted env vars (no shell-injection surface from quant_map.json).
Declined: SHA-pinning actions@v4 — matches the repo's floating-tag convention
(ci.yml/release.yml); belongs in a repo-wide hardening pass + Dependabot.
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
chore: set version to 0.3.0 across all sources (+ drop v0.4 references) (#145)
* chore: drop stray v0.4 references — everything ships on the v0.3.0 line
Per the project's versioning rule (no v0.4, no unprompted version chatter):
- backend/main.py + marketplace.py: the app reported version "0.4.0" (ahead of
even pyproject's 0.2.7 and referencing a forbidden version). Aligned to
"0.2.7" to match pyproject.toml / tauri.conf.json — a consistency fix, not a
bump.
- errorDocsMap.ts / indextts/bootstrap.py / _secret_key.py: reworded "v0.4"
deferral comments to version-agnostic "deferred / later hardening pass".
- docs/install/troubleshooting.md: the "tracked for v0.4" notarization line now
matches macos.md (signing is wired; activates on the Apple cert secrets).
Note: historical planning records under .planning/ still contain "defer to v0.4"
notes; left as-is (a record of superseded decisions) — CLAUDE.md + the
constitution are the live source of truth.
* chore: set version to 0.3.0 across all sources (current dev line)
The current/upcoming version is v0.3.0 (0.2.7 is the prior stable). Bump every
version source so the codebase consistently reports 0.3.0 — the in-code dev
version; the git *tag* still happens later per the release cadence.
- pyproject.toml, frontend/src-tauri/Cargo.toml, tauri.conf.json,
frontend/package.json: 0.2.7 → 0.3.0
- backend/main.py (FastAPI) + marketplace.py export metadata → 0.3.0
(these had drifted to a phantom "0.4.0")
- CHANGELOG.md: "[0.2.7] — Unreleased" → "[0.3.0] — Unreleased"
- uv.lock + Cargo.lock reconciled (1-line each) so `--frozen` installs hold.
* refactor(version): read app version from package metadata (no more drift)
Greptile (#145): the FastAPI version + marketplace bundle metadata were bare
string literals — they'd go stale-wrong again at the next bump (the exact class
of bug this PR fixes; that's how "0.4.0" happened). Read once from
importlib.metadata.version("omnivoice") via core.version.APP_VERSION, with a
"0.3.0" fallback only for a non-installed source checkout. pyproject.toml is now
the single source of truth for the runtime version.
Tests: tests/test_app_version.py (semver + equals installed metadata). 2 pass.
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat(macos): wire Developer-ID signing + notarization; fix "app is damaged" docs (#134, #72) (#143)
The unsigned DMG triggers macOS Gatekeeper's misleading "app is damaged" block
(#134, #72). Two parts:
- release.yml: pass APPLE_CERTIFICATE / _PASSWORD / APPLE_SIGNING_IDENTITY /
APPLE_ID / APPLE_PASSWORD / APPLE_TEAM_ID to tauri-action. It signs +
notarizes the macOS bundle when these repo secrets are set, and is a no-op
(today's unsigned build) when they're absent — so this is safe to merge now
and "activates" the moment the maintainer adds an Apple Developer cert.
- docs/install/macos.md: explain the "damaged" message is Gatekeeper (not
corruption), give the `xattr -cr` + right-click→Open workarounds, and add a
"For maintainers" table of the required secrets. Removed the stale "tracked
for v0.4" line (versioning rule: everything's on v0.3.0).
The in-app error→docs deeplink (GATEKEEPER_QUARANTINE) already targets the
#gatekeeper-quarantine anchor.
Refs #134, #72.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(bootstrap): always try only-system fallback; drop the too-strict gate (#142)
Verification of #140 (driving real uv) found system_python_ge_311() was
stricter than uv's own interpreter discovery: it probed only `python3`/`python`,
so on a machine where `python3` is the macOS 3.9 but a Homebrew 3.14 exists, the
gate returned false and the only-system fallback was skipped — even though
`UV_PYTHON_PREFERENCE=only-system uv venv` resolves 3.14 fine.
Fix: drop the pre-gate (and the now-unused parse_py_version/system_python_ge_311
helpers + the parse test) and always add the system-python attempt as the last
resort. uv's discovery is the authority; with `requires-python = ">=3.11"` it
resolves any compatible system interpreter or fails fast → remediation.
Verified live: `only-system uv venv` created a venv from system CPython 3.14.5
on this host (no 3.11.x present). cargo test + cargo check clean.
Refs #130.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(voice-design): validator-safe instruct builder (plan-05, closes#114#115) (#141)
* fix(voice-design): build validator-safe instruct on the frontend (#132)
plan-05 (option A — frontend guard). The engine validator is whitelist-strict
by design; the #114/#115 failures came from useTTS.js merging the free-text
instruct field with the category dropdowns, producing unsupported items (#115)
or two items in one category (#114).
- voiceInstruct.js buildDesignInstruct(vdStates, freeText): dropdowns win their
category; free-text accepted only as a known tag in an open category;
unknown/duplicate items are dropped and returned so the UI can warn. Derives
TAG_TO_CATEGORY from CATEGORIES (single source of truth).
- useTTS.js design mode uses it instead of the raw merge; toasts dropped items.
Engine validator (_resolve_instruct) untouched — whitelist contract preserved,
no vendored-engine change.
Tests (TDD, vitest): voiceInstruct.test.js (6). Full frontend suite 72 passed;
typecheck + build green.
Closes#114, #115. Addresses #132.
* fix(voice-design): split unsupported vs duplicate instruct; warn on dropdown drift (Greptile #141)
- buildDesignInstruct now returns { instruct, unsupported, duplicates }:
`unsupported` = free-text prose (not a known tag, #115); `duplicates` = a
valid tag whose category was already set (e.g. dropdown low pitch outranks a
typed high pitch, #114). useTTS shows an accurate toast per bucket instead of
calling a valid-but-outranked tag "unsupported".
- console.warn when a *dropdown* value isn't in CATEGORIES (option-list ↔
whitelist drift) instead of silently dropping it.
Tests updated + 1 added (7/7); typecheck + build green.
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(bootstrap): mirror cascade + system-Python fallback for blocked networks (plan-03, closes#60) (#140)
* fix(bootstrap): mirror cascade + system-Python fallback for blocked networks (#130)
plan-03. First-run bootstrap downloaded managed Python from GitHub with no
mirror and a short retry budget, so a GitHub-blocked/unresolvable network
killed the install dead-on-arrival (#60).
bootstrap.rs (Rust/Tauri):
- apply_uv_http_env(): UV_HTTP_TIMEOUT=120 / CONNECT_TIMEOUT=30 / RETRIES=5 on
both `uv venv` and `uv sync`.
- `uv venv` cascade: default GitHub → gh-proxy mirror (UV_PYTHON_INSTALL_MIRROR)
→ system Python (UV_PYTHON_PREFERENCE=only-system, only if a system Python
>=3.11 is detected). First success wins.
- Actionable failure messages (install python.org Python / set a mirror / Clean
& Retry) instead of a raw uv exit code.
Frontend: BootstrapSplash hint for the GitHub-blocked / can't-download-Python
case. Docs: troubleshooting.md restricted-network section (mirror env vars,
China PyPI index, honest VPN note) — referenced by the remediation text.
Tests: Rust #[cfg(test)] for parse_py_version + apply_uv_http_env (cargo test:
2 passed, crate compiles); docs-drift validator + frontend build green.
NOTE: the restricted-network E2E paths (mirror install, only-system fallback)
need MANUAL verification on a real GitHub-blocked network — not reproducible in
the dev/CI harness. cargo + the unit tests cover compile + the pure helpers only.
Closes#60. Addresses #130, #57, #127.
* fix(bootstrap): drop --python 3.11 pin on system-Python fallback (Greptile #140)
system_python_ge_311() accepts 3.12/3.13, but the fallback passed `--python
3.11`, forcing uv to find a 3.11.x interpreter exactly — so a machine with only
3.12/3.13 failed the fallback and wrongly hit the remediation. Drop the pin;
`only-system` + the project's `requires-python = ">=3.11"` lets uv resolve any
compatible system interpreter. cargo test: 2 passed.
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(windows): gate torch.compile on Triton + ASR critical-path smoke (plan-02, closes#65) (#138)
* fix(windows): gate torch.compile on Triton availability (#129, closes#65)
plan-02. torch.compile(mode="reduce-overhead") needs Triton at runtime;
Triton has no Windows wheel, so the old `device=="cuda"`-only guard in
model_manager.py failed on Windows+CUDA and surfaced as a confusing "OOM"
(#65). Inference-time, hard to diagnose.
- engine_env.should_torch_compile(device): requires CUDA + find_spec("triton")
+ the existing perf.torch_compile_disabled setting being off; logs the skip
reason at INFO and falls back to eager.
- model_manager.py call site uses it instead of the bare cuda check.
- smoke-test.sh INST-02: import torch + ctranslate2 + whisperx (full ASR path)
so a missing transitive dep fails the build instead of crashing mid-
transcription (#116). Runs in the CI smoke-matrix on Win/macOS/Linux.
setuptools>=75.0 (fix-sequence step 1) already pinned (#58). Linux/CUDA+Triton
behaviour unchanged.
Tests (TDD): tests/test_torch_compile_gate.py (4). Closes#65; addresses
#129/#116.
* fix(windows): also gate subprocess torch.compile on Triton (Greptile #138)
Greptile flagged that the in-process gate left a parallel gap: engine
subprocesses honour TORCH_COMPILE_DISABLE, but build_engine_env() only set
it on the user's Performance toggle — so a Triton-absent host (Windows, or
macOS) still exposed subprocess engines to the same crash this PR fixes
in-process.
- build_engine_env(): set TORCH_COMPILE_DISABLE=1 when the user disabled
compile OR Triton is unavailable (find_spec), cross-platform — mirrors
should_torch_compile(). Drops the Windows-only scoping (and the now-unused
`import sys`).
- Refreshed the stale module docstring.
- 3 new tests cover the subprocess gate (triton-missing, triton-present,
user-opt-out). 7/7 pass.
* revert(engine_env): keep subprocess TORCH_COMPILE_DISABLE user-driven
Reverts the build_engine_env() broadening from the previous commit. Auto-
disabling subprocess torch.compile on Triton-absence conflicts with a
deliberate, tested contract (test_perf_settings: Windows + flag-off ⇒ no
injection; non-Windows ⇒ never inject) — the subprocess var is intentionally
under the user's explicit control.
The #65 fix is the in-process should_torch_compile() gate (unchanged here),
which IS automatic and fully tested. Pushing back on the subprocess auto-gate
as a separate, deliberate contract change rather than forcing it through by
rewriting established tests.
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat: bundle Claude Code agent skill at .claude/skills/omnivoice/ (#113)
* fix(mcp): drop unsupported FastMCP kwargs (mcp SDK >= 1.10)
The MCP server passes `version=` and `description=` to FastMCP(), but
neither kwarg exists on mcp >= 1.10 — the protocol version is now
managed internally and `description` was renamed to `instructions`.
Symptom on a fresh install (uv sync && pip install 'mcp[cli]'):
TypeError: FastMCP.__init__() got an unexpected keyword argument 'version'
Tested locally end-to-end:
- create_mcp_server() now constructs cleanly
- All 5 tools register and are listable via FastMCP.list_tools()
- generate_speech round-trip returns base64 WAV; ~24s server-side
for 4.2s of audio at steps=16 on Apple Silicon MPS
- pytest backend/ -x -q: 45 passed
* feat: bundle Claude Code agent skill at .claude/skills/omnivoice/
CLAUDE.md already invites contributions at .claude/skills/:
"No project skills found. Add skills to any of: .claude/skills/,
.agents/skills/, .cursor/skills/, .github/skills/, or .codex/skills/
with a SKILL.md index file."
But the existing .gitignore blanket-ignored .claude/ (line 41), making
the invited path un-trackable. This commit narrows the ignore so ad-hoc
Claude state stays out while deliberate skill bundles are tracked:
-.claude/
+.claude/*
+!.claude/skills/
+!.claude/skills/**
Once merged, any compatible agent client running
`npx skills add debpalash/OmniVoice-Studio` gets immediate context on:
- What the MCP server exposes (5 tools + 2 resources)
- When to pick OmniVoice vs other engines
- How to wire the stdio MCP server into a client config
- Backend lifecycle: start / health / stop scripts
- Common failure modes + fixes (port collision, model download stall,
missing HF_TOKEN, MPS fallback, voice-profile-not-found, etc.)
Conforms to Anthropic skill-creator conventions: frontmatter
description under 1024-char limit, body under 500 lines, references/
for detail, scripts/ for deterministic ops, no README/CHANGELOG
inside the skill, validates clean against quick_validate.py.
Verified locally that `npx skills list` discovers the bundled skill
automatically once cloned. End-to-end tested through MCP:
- generate_speech (English, demo voice, steps=16) -> 4.2 s WAV
- generate_speech (voice design via instruct only, steps=8) -> 6.3 s WAV
- generate_speech (Spanish, demo voice, steps=16) -> 2.8 s WAV
Depends on #112 (FastMCP API fix). Without it, every MCP tool call
fails with TypeError at server construction.
* feat(skill): add voice-clone end-to-end recipe + record-reference.sh helper
Two additions to the bundled skill, closing the gap where agents had no
procedural knowledge for creating a voice profile (the previous SKILL.md
said "use the UI or POST /profiles" but didn't include the recording +
trimming + verification workflow).
1. scripts/record-reference.sh — macOS-only helper that records a clean
reference clip with **audible** countdown + start/stop cues via
`say` + /System/Library/Sounds/Ping.aiff. Solves the buffering bug
where text-mode "speak now" prompts arrive after recording starts.
Captures a longer raw window then trims to ~10 sec of speech via
silenceremove + atrim. Plays back for verification. Prints the
next-step `curl` command for POST /profiles.
2. SKILL.md "Voice clone — end-to-end recipe" section (replaces the
stub one-liner). Covers:
- Path A: the bundled helper (one command, audible cues)
- Path B: manual ffmpeg flow if the helper doesn't fit
- POST /profiles multipart/form-data fields (required: name +
ref_audio; optional: ref_text, language, instruct, seed, personality)
- Reference clip quality factors that materially affect output
(single speaker, natural prosody, 3-10 sec sweet spot, ref_text
alignment, language correctness, loudness ≥ -15 dB peak)
Tested locally: recorded a 10-sec Spanish reference + 3-sec English
reference, created two profiles via the helper + curl flow, generated
14.1 sec of Spanish + 10.2 sec of English audio in the user's cloned
voice. Round-trip works end-to-end at steps=16 on Apple Silicon MPS.
Frontmatter description unchanged (860 chars, under the 1024 limit).
Body grew from ~120 to 169 lines (still well under the 500-line skill
ceiling).
* fix(skill): address P20 cross-review findings on PR #113
Adversarial multi-agent review (code + comment + silent-failure analyzers
on parallel reviewers) surfaced one blocker, one critical silent-failure
class, two medium-severity bugs, and two minor doc inaccuracies. All
addressed in this commit.
Blocker (cited 3x by both code-reviewer and comment-analyzer):
- SKILL.md linked references/engines-comparison.md three times (lines 44,
153, 160) but the file was never copied into the upstream skill tree.
+ Added the file (engine decision tree across OmniVoice / kokoro /
Voicebox / Edge TTS / ElevenLabs / cloud APIs).
Critical — record-reference.sh (was 4/10):
- Mic-permission silent failure: macOS denies the mic by sending a silent
stream; ffmpeg exits 0 with a valid silent WAV. The script printed
"✓ raw captured" and produced a degenerate reference clip that would
train a broken voice profile.
+ Parse mean_volume from volumedetect; exit 3 with a diagnostic
pointing the user to System Settings → Privacy → Microphone if
the recording is below -50 dB.
- afplay backgrounded with no exit check; if /System/Library/Sounds/*.aiff
is missing the user gets no audible cue.
+ beep() helper falls back to printf '\a' (terminal bell) when the
system sound file is missing.
- silenceremove silent corruption: silent input → near-empty output WAV,
exit 0.
+ ffprobe duration check after trim; exit 4 if < 2.0 sec.
- trap only covered EXIT; Ctrl-C / SIGTERM mid-recording leaked tmp file.
+ trap '...' EXIT INT TERM HUP.
- macOS guard ran after mktemp + trap.
+ Moved guard to first executable line.
- afplay verification swallowed stderr.
+ Drop 2>/dev/null; surface failure as a warning.
- Documented exit codes in header (0/2/3/4).
Medium — start-backend.sh (was 6/10):
- TOCTOU race: lsof check → uvicorn start could lose the port to another
process; only signal was a 60s health timeout.
+ Added `kill -0 $PID` check inside the probe loop; immediate exit 5
with log tail if uvicorn died.
- lsof check couldn't tell "stale us" from "third party" — same exit 3
for both.
+ ps -o command attribution; the message now tells the user whether
it's a stale uvicorn (suggest stop-backend.sh) or unknown process.
- Documented exit codes (0/2/3/4/5).
Medium — stop-backend.sh (was 7/10):
- No post-SIGKILL verification — script exited 0 even if process still
bound.
+ Added current_pids() helper; re-query after SIGKILL; exit 1 if still
bound, with lsof dump for diagnostics.
- 2>/dev/null || true on kill swallowed EPERM silently.
+ Capture stderr; classify EPERM vs ESRCH; exit 2 on EPERM with
actionable hint (try sudo).
- Documented exit codes (0/1/2).
Minor docs (comment-analyzer):
- SKILL.md line 120 claimed profiles persist as `<id>.wav`. Actual
backend (profiles.py:48-50) preserves uploaded extension.
+ Reworded to `<id>.<ext>` with explanation.
- mcp-setup.md line 68 cited HF cache path as Linux/macOS only.
Windows redirects via backend/core/config.py:38 to
%LOCALAPPDATA%\OmniVoice\hf_cache.
+ Added Windows row + reference to config.py.
Re-validated: all 6 files compile under set -euo pipefail; SKILL.md
frontmatter description stays at 860 chars (under 1024 cap); skill body
under 500 lines.
Diff: 6 files changed, ~+269/-47.
fix(windows): HF cache disk-fallback for WinError 448 (plan-01, closes#117#118) (#137)
* fix(models): disk fallback when scan_cache_dir raises WinError 448 (#128)
plan-01 fix-sequence step 2. On Windows, huggingface_hub's scan_cache_dir()
raises WinError 448 "untrusted mount point"; the three call sites in
setup/models.py swallowed it and reported "not cached", so the app
re-downloaded models it already had — looping 5× and giving up (#117/#118).
- _is_cached_on_disk / _scan_cache_on_disk: walk the canonical HF layout
<cache>/models--<org>--<name>/snapshots/<rev>/ directly (honours
HF_HUB_CACHE/HF_HOME, so a relocated models dir works too).
- is_cached / list_models / recommendations now fall back to the disk scan
when scan_cache_dir() raises. An empty snapshot dir is not counted.
Symlink-disable env + local_dir_use_symlinks=False were already shipped
(main.py, setup/download.py); this closes the remaining failure path.
Tests (TDD, fail-before/pass-after): tests/test_hf_cache_fallback.py (4).
No regression on the non-Windows path (fallback only triggers on raise).
Closes#117, #118. Addresses #128 (#64 configurable-dir is the follow-up).
* fix(models): probe HF /hub subdir + close scandir handle (bot review)
Addresses #137 review:
- CodeRabbit (critical): hf_cache_dir() returns HF_HOME when HF_HUB_CACHE is
unset, but repos live under $HF_HOME/hub/models--…. Added _hub_cache_roots()
so the WinError-448 fallback probes both <dir> (HF_HUB_CACHE-set case) and
<dir>/hub (HF_HOME-only case); previously it could miss the cache and
re-download. Regression test added (HF_HOME-only layout).
- Greptile: wrap os.scandir() in `with` so the dir handle closes even when
any() short-circuits (avoids handle leaks on repeated /models polls).
- CodeQL: drop unused `os` import in the test.
5 tests pass, incl. -W error::ResourceWarning.
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(client): use window.location.hostname for remote/Docker deployments (#123)
When running in Docker and accessing OmniVoice from a remote machine,
the frontend was hardcoded to call 127.0.0.1 for all API requests,
causing every endpoint to fail with ERR_CONNECTION_REFUSED.
Fix: detect Tauri context via window.__TAURI__ and use 127.0.0.1 only
for native desktop builds. In browser/Docker deployments, fall back to
window.location.hostname so remote access works correctly.
Fixes#120
feat: pipeline error transparency — no more silent "unknown error" (plan-04, closes#131) (#136)
* docs(plan-04): spec + plan for pipeline error transparency (#131)
speckit spec/plan/research/data-model/contract/quickstart for plan-04.
Grounds the fix in the real code map: shared failure-event builder
(backend/core/failure.py) feeding tasks.py + dub_pipeline.py + dub_core.py,
non-empty reason guarantee, sanitized diagnostic block, frontend renderer
with docs deeplink. Closes-target: #131 (children #122, #63).
Design only — no code changes yet.
* feat(pipeline): structured, non-empty failure events + logged tracebacks (#131)
plan-04 backend: no more silent "unknown error". A shared failure helper
guarantees a non-empty reason at every emit site and a sanitized,
copyable diagnostic block.
- backend/core/failure.py: build_failure()/build_failure_event() (reason
falls back to the exception class name), sanitize() (reuses the
logging_filter HF-token regex + redacts *TOKEN*/*KEY*/*SECRET* env values
+ home→~), diagnostic() (reuses the env capture), classify() reusing the
error_docs_map 5-class taxonomy for the docs deeplink + hint.
- core/tasks.py worker: structured event instead of bare str(e); keeps the
logged traceback.
- services/dub_pipeline.py: enrich download/extract error yields; ADD the
missing outer `except Exception` (the #122 path — unhandled ingest errors
were never surfaced with stage context); surface the previously-silent
demucs/scene/thumbnail degradations as non-fatal `warning` events.
- api/routers/batch.py: guaranteed non-empty batch failure reason.
SSE payload is additive (legacy `error`/`stage`/`detail` keys preserved),
so existing frontends keep working and already show the specific reason.
Tests (TDD, fail-before/pass-after): 14 cases — non-empty-reason guarantee,
redaction, diagnostic sanitization, and the 3 Test-matrix triggers
(worker / extract / url). 483 passed, 0 regressions.
Closes#131. Refs #122, #63.
* feat(dub-ui): show specific cause + docs deeplink + copyable diagnostic (#131)
plan-04 frontend. The backend now sends a structured, non-empty failure;
surface it to the user instead of "extract: unknown error".
- dubSlice: DubFailure type + dubFailure state/setter.
- useDubWorkflow: capture the structured failure on the SSE error event
(reason/error_class/stage/hint/docs_topic/diagnostic); clear on new runs.
- DubTab: DubFailureNotice renders the actionable hint, an "Open docs"
deeplink (via the existing errorDocsMap classifier), and a "Copy
diagnostic" button — shown beneath the error badge in both failure banners.
typecheck + build clean; 66 frontend tests pass.
Refs #131, #122, #63.
* fix(failure): annotate intentional best-effort excepts (CodeQL)
The new security workflow's CodeQL flagged 5 bare `except: pass` blocks.
All are deliberate best-effort guards (sanitize/diagnostic must never throw
on the failure path; the test cancels the worker to tear it down). Added
explanatory comments per CodeQL's py/empty-except rule. No behavior change.
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ci(security): scanning workflow + CodeRabbit config + sweep design (PR 0) (#135)
* ci(security): add scanning workflow + CodeRabbit config + sweep design
PR 0 of the v0.3.0 stabilization sweep — establishes the automated
review + security gate every subsequent plan PR flows through.
- .github/workflows/security.yml: gitleaks (gating secret scan),
CodeQL (Python + JS/TS), bandit (SARIF), pip-audit + bun audit.
Only the secret scan gates; dep/SAST findings are reporting-only
to stay consistent with the no-ceremony, continuous-to-main cadence.
- .coderabbit.yaml: path filters + constitution constraints encoded as
review instructions (local-first, cross-platform parity, alembic,
no secret/home-path leakage). Drafts excluded from auto-review.
- SECURITY.md: document the automated scanning + bot review.
- docs/specs: program design for the full sweep (plan-01..05 + PR triage).
CodeRabbit and Greptile apps are already installed and will review on
PR open.
* ci(security): install bandit[sarif] extra; pin JS actions to Node 24
The bandit SARIF formatter ships in the `bandit[sarif]` extra; plain
`bandit` rejects `-f sarif` (exit 2), so no SARIF was written and the
upload step failed. Install via `pipx run --spec 'bandit[sarif]'`.
Also add FORCE_JAVASCRIPT_ACTIONS_TO_NODE24 (mirrors ci.yml) to silence
the Node 20 deprecation warning on checkout/setup-python/upload-sarif.
* ci(security): harden per bot review — persist-credentials, upload guard, bun pin
Addresses CodeRabbit + Greptile findings on #135:
- persist-credentials: false on all checkout steps (don't leave GITHUB_TOKEN
in git config; none of these jobs need authed git after clone). [CodeRabbit]
- continue-on-error on the bandit SARIF upload so a missing SARIF can't fail
this reporting-only job. [Greptile P1]
- pin bun-version "1.2" — `bun audit` only exists in bun >=1.2.x. [Greptile P2]
Declined: full-SHA action pinning. Meets the major-tag bar set in
.coderabbit.yaml and matches ci.yml/release.yml convention; SHA pinning
belongs in a repo-wide hardening pass with Dependabot, not one file.
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Phase 4 Plan 04-01: SPIKE-01 GGUF — GO + integration (#100)
* Phase 4 Plan 04-01: SPIKE-01 GGUF — GO + Wave 1 integration
Integrates Serveurperso/OmniVoice-GGUF as a hardware-adaptive default
voice-cloning engine, with overridable fallback to the in-process
OmniVoiceBackend. Spike confirmed GO: the model is a clean quantization
of k2-fsa/OmniVoice (Apache-2.0 + MIT runtime, `omnivoice-lm` custom
architecture so it does NOT load in vanilla llama.cpp).
Pinned SHAs:
* Serveurperso/OmniVoice-GGUF revision: 361609388ae572a820d085185bbbe2a2aac4b30e
* ServeurpersoCom/omnivoice.cpp master: 886fc079838ca7400cb2b42b36e2a65aa1daabe8
Implements GGUF-01 (hardware probe) through GGUF-05 (default-engine
resolver with graceful fallback). The four `bin/omnivoice-tts-*`
artifacts are committed as zero-byte placeholders; the new CI matrix
job builds the real binaries per platform from the pinned commit SHA
and appends a SHA-256 manifest used by `is_available()` for tampering
detection (T-04-01). The macos-14 (Apple Silicon) slot is marked
`continue-on-error: true` because omnivoice.cpp publishes no
`buildmetal.sh` (Pitfall 1 / Assumption A1) — failure feeds into Task
3's GO/NO-GO call.
Quant override is allow-listed against quant_map.json entries only
(T-04-05). Argv is composed from typed Path objects rooted in
HF_HUB_CACHE; never uses `shell=True`. HF token redaction applies to
captured stderr before logging (AUTH-05 / T-04-04).
Tests: 36 new (8 hardware-probe + 13 GGUF engine + 6 settings_store
quant override + grep gate); 428 passed in full suite vs 402+ baseline.
ADR Status stays "Proposed (research-supported)" — Task 3 (human
checkpoint) flips to Accepted after CI produces real binaries and a
reviewer signs off on the GGUF-06 cross-hardware smoke.
* ci: install libopenblas-dev on linux-x86_64 omnivoice-tts build
The pinned omnivoice.cpp commit (886fc079...) ships a `buildcpu.sh`
that passes `-DGGML_BLAS=ON`. ubuntu-latest has no BLAS implementation
preinstalled, so the cmake configure step fails with
`Could NOT find BLAS (missing: BLAS_LIBRARIES)` and the job exits in
13 s before producing the linux-x86_64 binary.
macOS (Accelerate, built in) and Windows (BLAS off by default in the
ggml CMakeLists for non-APPLE platforms — the build script doesn't
invoke buildcpu.sh on those slots) are unaffected and stay green.
Adds a Linux-gated apt step to install libopenblas-dev + pkg-config
before the build, restoring cross-platform parity per the
CLAUDE.md "default features must work on every platform" rule.
* fix(gguf): constrain ref_audio to project roots — block /etc/shadow on Linux
The GGUF engine's `_build_argv` previously validated ref_audio only via
`ref_path.is_file()` — i.e. "does this path exist?" That check is
platform-dependent: `/etc/shadow` doesn't exist on macOS (rejected
naturally), but it IS a real system file on Linux, so the validation
silently accepted it. CI's ubuntu-22.04 runner exposed the gap via
`test_generate_blocks_freeform_ref_audio`, which exists precisely to
guard the "freeform ref_audio path" attack surface.
Fix: confine ref_audio to one of three allowed roots before existence
checks:
- VOICES_DIR (user-saved voice profiles)
- DUB_DIR (per-job auto-clones extracted from source video)
- tempfile.gettempdir() (browser-upload temp files; existing
`cleanup_ref` flow in generation.py)
Anything outside those roots → FileNotFoundError, matching the existing
failure-mode contract callers handle. Existence check still runs after,
so the test's mocked subprocess.run is never reached and the test
passes deterministically on all three platforms.
Cross-platform parity (per CLAUDE.md 2026-05-20 rule): identical
behaviour on macOS / Windows / Linux — the allow-list is computed from
core.config which uses platform-specific path resolution but yields the
same logical "project tree" on every OS.
* ci(gguf): mark darwin-x86_64 binary build as experimental
GitHub's macos-13 (Intel) runner pool is heavily contended — PR #100
queued for 30+ minutes waiting on darwin-x86_64 while every other
platform finished in ~1m. Intel Macs are also fading hardware (Apple's
platform momentum is entirely on Apple Silicon), and the GGUF engine's
runtime already handles a missing binary gracefully (`is_available()`
returns False on Intel Mac with a "binary not bundled for this
platform" message, same path used for first-launch before any binaries
build).
`experimental: true` mirrors what darwin-arm64 (Metal) already has —
slot still runs and uploads its binary when successful, but a failure
or runner backlog no longer blocks merges. Keeps the GGUF engine
shippable across the dominant arm64 / Linux / Windows surface without
holding the inbox on a slow-runner queue.
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
fix: speaker detection — gated pyannote license surfaces a docs deeplink (closes#78) (#110)
* fix: speaker detection — gated pyannote license surfaces a docs deeplink (closes#78)
Issue #78 ("Speaker detection fails — speakers blend together or aren't
detected correctly") was the user-visible symptom of the dub pipeline
silently falling back to the silence-gap heuristic in
`backend/api/routers/dub_core.py::_diarize`. The heuristic alternates
Speaker 1 ↔ Speaker 2 on >1.2s gaps only, so two real speakers with
similar pacing get merged or swapped — and once the auto-clone step
extracts a reference voice for the wrong label, downstream dubs make
"person A speak like person B" (the reporter's exact phrasing).
The structural cause is that pyannote-3.1 is gated on HuggingFace: a
valid HF_TOKEN by itself isn't enough — the user must also click
"Agree and access repository" on both pyannote/speaker-diarization-3.1
AND pyannote/segmentation-3.0. We can't fix that for the user, but we
CAN make the failure actionable instead of silent.
Changes:
- `backend/services/model_manager.py`: `get_diarization_pipeline()`
gains an opt-in `return_error=True` shape that returns
`(pipeline | None, error_sentinel)`. Sentinels distinguish
NO_TOKEN / PYANNOTE_LICENSE_REQUIRED / LOAD_FAILED. A new
`_classify_diarization_error()` sniffs the exception's class name +
message for 401/403/gated/"accept license" signals — kept as a
string heuristic so it survives huggingface_hub major-version
churn. Bare-`None` default return preserved for the legacy
`_transcribe` call site at dub_core.py:781.
- `backend/api/routers/dub_core.py::_diarize`: now emits a structured
SSE warning `{detail, source, error_class, docs_url}` instead of
plain `{detail, source}`. The new fields let the front-end render a
"See docs" button that deeplinks directly to the
`License acceptance flow` section of `docs/features/diarization.md`
(landed in PR #94) — the page with the click-by-click instructions
for fixing this exact failure mode.
- `backend/core/error_docs_map.py` + `frontend/src/utils/errorDocsMap.ts`:
add a 5th taxonomy class `PYANNOTE_LICENSE_REQUIRED` pointing at the
diarization docs section. Distinct from `HF_AUTH_FAILED` (which is
the more general "token missing or invalid" case). The TS
`classifyError` heuristic also picks up pyannote / gated /
"speaker diarization" keywords so a thrown error in the boundary
routes to the right deeplink too.
- `tests/backend/core/test_error_docs_map.py`: bump locked-keys set to
5 classes; add an explicit assertion that the new class points at
the `license-acceptance-flow` anchor.
- `frontend/src/utils/errorDocsMap.test.ts`: bump locked-keys set to
5 classes; add classifier tests for pyannote / gated / accept-license
keyword routing.
- `tests/test_diarization_error_class.py`: regression test (20 cases)
covering `_classify_diarization_error`, the new
`get_diarization_pipeline(return_error=True)` shape, backward-
compatible bare-`None` return for the legacy call site, and the
error_docs_map deeplink target. Uses sys.modules patching so
pyannote / torch are never actually imported.
HF token plumbing: unchanged. The new code continues to route through
`token_resolver.resolve()` per the AUTH-01 contract — no new bare
`os.environ.get("HF_TOKEN")` reads.
Cross-platform: identical behaviour on macOS / Windows / Linux —
the only platform-touching change is a docs URL string, which is
opened via the existing `openExternal()` helper that already abstracts
Tauri's `shell.open` on all three platforms.
Verification:
.venv/bin/python -m pytest tests/test_diarization_error_class.py \
tests/backend/core/test_error_docs_map.py -v
# 20 passed in 0.03s
bun run test src/utils/errorDocsMap.test.ts
# 13 passed (1 test file)
.venv/bin/python -m pytest tests/test_segmentation.py \
tests/test_dub_transcribe.py \
tests/backend/services/test_token_resolver.py \
tests/test_model_manager_preload.py
# 40 passed, 10 xfailed (pre-existing), 1 xpassed
* test: add regression test for diarization error classification (issue #78)
Companion to the fix in d6e6586. 20 test cases covering:
- `_classify_diarization_error` — the string heuristic that buckets
pyannote/HF exceptions into NO_TOKEN / LICENSE / LOAD sentinels.
Pinned for 401/403/gated/accept-license/accept-user-conditions
signals so it survives huggingface_hub major-version churn.
- `get_diarization_pipeline(return_error=True)` — the new 2-tuple
return shape that lets the dub pipeline's SSE warning carry an
error_class.
- Backward compatibility — the bare-`None` return on the default
signature is preserved so dub_core.py:781's legacy `_transcribe`
call site doesn't break.
- The error_docs_map deeplink — the new PYANNOTE_LICENSE_REQUIRED
class points at `docs/features/diarization.md#license-acceptance-flow`.
Uses sys.modules patching for pyannote.audio.Pipeline + token_resolver
so the real torch + pyannote + HF API are never imported.
* test(diarization): dotted-path monkeypatch to survive Wave 1 sys.modules purge
The new `test_diarization_error_class.py` tests pass in isolation but fail
in the full suite — Wave 1's `fresh_resolver` fixture aggressively purges
all `services.*` and `core.*` modules from `sys.modules` mid-suite. When
this file's tests later did `from services import token_resolver` then
`monkeypatch.setattr(token_resolver, "resolve", ...)`, the local
`token_resolver` reference bound to a stale module identity. The function
under test does `from services import token_resolver` at call time, which
re-resolves through the (post-purge) `sys.modules['services.token_resolver']`
— a different object — so the monkeypatch was applied to one ID and the
function read from another.
Two fixes in this commit:
1. Don't pop `services.token_resolver` in this file's `model_manager`
fixture — the test body's import and the function's import must agree
on identity. Popping forces re-import that can create two distinct
modules.
2. Use the dotted-path form `monkeypatch.setattr("services.token_resolver.resolve", ...)`
instead of the object-attribute form. Pytest's dotted form re-resolves
the path through `sys.modules` at setattr time, so the binding is
always on the live module object regardless of which identity the test
imported earlier.
Verified: `pytest tests/ -q` → 442 passed, 0 failures (was 1 failed
before this commit on PR #110).
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
fix: personality preset crash in Design tab (closes#89) (#111)
Selecting any personality preset in the Design tab (e.g. "News Anchor")
made the next Synthesize call fail with a 400 ValueError that took the
generation pipeline down — the user had to restart the app.
Root cause
==========
backend/core/personalities.py shipped human-readable prose as each
preset's `instruct` value, e.g.:
"Speak clearly and professionally like a television news presenter"
OmniVoice's model.generate(instruct=...) runs every instruct string
through _resolve_instruct (omnivoice/models/omnivoice.py:1351), which
splits on commas and validates each item against a fixed taxonomy in
omnivoice/utils/voice_design.py (gender, age, pitch, accent, dialect,
"whisper"). Prose like "Speak clearly..." has zero tokens in that
vocabulary, so the model raises:
ValueError: Unsupported instruct items found in
Speak clearly and professionally like a television news presenter:
'Speak clearly and professionally like a television news presenter'
-> ... (unsupported)
The frontend (CloneDesignTab.jsx applyPersonality) writes the preset's
`instruct` straight into the synth form, so every one of the six
personalities triggered the crash — verified all six raise ValueError.
Fix
===
Map each personality to a comma-separated bundle of valid taxonomy
tokens that _resolve_instruct accepts. Kept the original prose as a new
`description` field for any future UI tooltips and so the design intent
isn't lost.
Verified personalities now round-trip cleanly:
narrator -> "middle-aged, low pitch"
casual -> "young adult, moderate pitch"
news_anchor -> "middle-aged, moderate pitch, american accent"
storyteller -> "middle-aged, moderate pitch, british accent"
corporate -> "middle-aged, moderate pitch"
energetic -> "young adult, high pitch"
Regression test
===============
tests/backend/core/test_personalities.py exercises the exact failing
path: every personality is fed through the same _resolve_instruct that
the runtime calls. The test would have failed on every shipped
personality before this commit.
Cross-platform / data compatibility
===================================
Pure-Python data change — same on macOS / Windows / Linux. No DB
schema or omnivoice_data/ migration: voice_profiles.instruct is
untouched.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Per-segment audio effects DSP preset selector (closes#67, rebased from #68) (#109)
* Add per-segment audio effects DSP preset selector to dub pipeline
* Add shape assertions to podcast, warm, and bright preset tests
* Fix raw preset semantics, add preset validation, update docs, remove duplicate sys.path
* Narrow OOM catch to model.generate only in dub_generate
* Preserve original OOM exception context in dub_generate
* Bind effect_preset to _gen via explicit parameter to avoid loop capture
* Catch RuntimeError instead of torch.mps.MPSError for MPS OOM
---------
Co-authored-by: 4shil <166588383+4shil@users.noreply.github.com>
fix(ui): move UI scale + theme picker from footer to Settings → Appearance (#108)
The LogsFooter bar carried two always-visible appearance controls in the
left edge — \`S M L\` UI-scale toggle and 6 color theme dots. Both
duplicated the "Settings" affordance: rarely-used display preferences
shouldn't live in always-on chrome competing with logs / error counts.
Moved both into a new \`AppearancePanel\` rendered as a Settings section:
- New: frontend/src/components/settings/AppearancePanel.{jsx,css}
- Wired into Settings.jsx alongside ApiKeysPanel + PerformancePanel
- Footer no longer renders UiScaleToggle / ThemePicker / their dividers
Store state (uiScale, theme, setUiScale, setTheme) is unchanged — they
still persist via the same Zustand persist whitelist, just rendered in
the new location. Users who toggled to L-scale or Catppuccin keep their
prefs across the move.
User-visible effect: footer left edge now starts with the collapse
chevron + "Logs" title, then the source pills. No S/M/L. No color dots.
A user who wants to change either opens Settings.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
fix(ui): gate A/B Compare on having ≥2 profiles to actually compare (#107)
The "A/B Compare" button always rendered in the Launchpad chrome, even on
a fresh install with zero or one profile — clicking it just opened an
empty CompareModal. Visible-but-non-functional chrome is exactly the kind
of UI annoyance the calm-chrome pass is targeting.
Gate the render on `profiles.length >= 2`. The button appears only when
A/B comparison is meaningfully available; until then it's hidden and the
header stays clean for the "Make voices that sound like you" hero.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
fix(ui): hide RAM/CPU/VRAM in header by default; opt in via Settings → Performance (#106)
The header's live metrics block (`RAM 12.8/16G CPU 25% VRAM 3.2G ●Idle Flush`)
was loud chrome — a user picking "Voice Clone" doesn't need a resource
monitor competing with the OmniVoice brand. The Idle/Ready/Loading status
badge + Flush button stay visible because both are action-relevant; only
the three numeric counters are gated.
Behind a Zustand-persisted `showHeaderLiveStats` flag, default `false`.
Power users can flip it on via Settings → Performance → "Show live system
metrics in header" — same panel where the torch.compile toggle already
lives, so all "Performance" controls cluster.
Why opt-in (not opt-out): the project's stated core value is "a first-run
that actually works" — successful state should be invisible. Telemetry
chrome is the opposite of that. Defaults must work on every platform per
the CLAUDE.md rule landed earlier today; "Show metrics by default" is
fine-on-developer-laptops noise on every other machine.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
fix(ui): calmer launchpad — hide readiness when green, drop duplicate notif pill (#105)
Two small surfaces of "annoying chrome on the welcome screen" identified
during a UI review pass:
1. **System Readiness card stayed visible on the Launchpad even when every
check was pass-or-warn.** The component already had self-hide logic for
that case, but `Launchpad.jsx` was passing `showWhenAllPass` which
defeated it. Removed the prop — the card now only appears when there's
an actual issue worth surfacing. Compact "All systems ready" pill (line
249) still shows when there *are* projects, so the readiness affordance
isn't gone, just quieter.
2. **Footer "Notifications" pill duplicated the header bell.** The `SOURCES`
array in `LogsFooter.jsx` declared a 4th source ("notifications") that
rendered as a separate pill in the always-visible footer chrome. The
header bell + badge in `NotificationPanel` is the canonical surface;
showing the same count twice was just noise. Dropped the SOURCES entry
(and the now-unused `Bell` import). The footer is logs-only now.
User-visible effect: on a healthy install, the welcome screen shows just
the hero + 3 capability cards. No "✅ System Readiness" panel, no
"Notifications (1)" footer pill. If something does need attention, the
bell shows it (top right) and the readiness card surfaces with the
specific failure.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
fix(widget): hide on app load, bottom-center position, exclude from window-state restore (#104)
Three issues with the dictation pill (Whisper-Flow / Ghost-Pepper style):
1. Pill appeared on app load even though `.visible(false)` and the global
shortcut hadn't been pressed.
2. When shown, it positioned at top-center instead of bottom-center.
3. The "Ready — hold shortcut to speak" idle label rendered inside the pill
even when no recording was active.
Root causes & fixes:
**(1) `tauri-plugin-window-state` was restoring widget visibility.**
If the user had the widget visible when they quit the app (mid-dictation,
or by clicking the tray's "Start Dictation" while a window was up), the
plugin saved `visible: true` and restored it on next launch — overriding
the `WebviewWindowBuilder.visible(false)`. Fix: add the widget label to
the plugin's denylist, so its state is never persisted. Belt-and-braces:
explicit `win.hide()` on the widget during studio-mode and pill-mode
startup, so any other plugin or stale state can't sneak the window in.
**(2) Position was hard-coded to top-center.**
Changed `LogicalPosition::new(x, 60.0)` (top) to a computed bottom-center
position: `y = logical_screen_height - 64 - 80` (80 px margin clears
macOS dock + Windows taskbar + most Linux panels). Same math in all
three places it's set (global-shortcut handler, tray dictate, pill-mode
pre-position) — identical behavior on macOS/Windows/Linux per the new
CLAUDE.md "default features work on every platform" rule.
**(3) Idle label rendered visually.**
`CaptureWidget.jsx` now returns `null` when `state === 'idle'`. Listeners
stay mounted (hold-to-talk wiring is preserved), only the visual pill DOM
disappears. The slide-in animation triggers on the natural unmount→mount
when state flips out of idle.
Also: lock in two durable rules surfaced in this session:
- CLAUDE.md: "Default features must work on every platform" — platform-
divergent defaults are a P0 bug; platform-only features must go behind
explicit opt-in.
- CLAUDE.md: "No RC, no ceremony" — v0.3.0 ships continuous-to-main; tag
when actually useful; no v0.4 deferrals while v0.3.0 is open.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
fix(desktop-prod): also clean backend data dir for actual fresh-install emulation (#103)
The script was cleaning Tauri's APP_ID dir
(~/Library/Application Support/com.debpalash.omnivoice-studio) but the
Python backend writes to ~/Library/Application Support/OmniVoice — a
separate hardcoded path in backend/core/config.py::get_app_data_dir().
Result: "🧹 Cleaning all OmniVoice data for fresh prod emulation" was
deleting an empty directory while the real user data (SQLite db, voice
profiles, dub jobs, outputs, logs) sat untouched. Developers running
desktop-prod thought they were testing a clean install path, but were
actually testing on accumulated state.
Fix: add a BACKEND_DATA variable and a 1b cleanup step targeting the
backend's actual data dir. Per platform:
- macOS: ~/Library/Application Support/OmniVoice
- Linux: ~/.omnivoice
- Windows: %APPDATA%/OmniVoice (not in this script; Windows uses .bat)
Surfaced while running `bun desktop-prod` for the first time today on a
clean tree — the .app launched fine but Settings showed a pre-existing
voice profile from a prior session, contradicting the "fresh" claim.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
fix(tauri): inject-apprun path relative to frontend/ (where beforeBundleCommand runs) (#102)
Tauri's `beforeBundleCommand` runs from the directory containing the
frontend `package.json` (i.e. `frontend/`), not from `frontend/src-tauri/`.
The Phase 1 Wave 3 work wired the AppRun injector with the wrong relative
prefix — `../../scripts/inject-apprun.sh` goes one level *above* the
project root, so `bun desktop-prod` failed at the bundle step with
"bash: ../../scripts/inject-apprun.sh: No such file or directory"
on every developer machine.
Fix: drop one `../`. The script itself was already correct (used absolute
paths internally), so on macOS where there's no AppDir staging it cleanly
exits 0 with "no AppDir staging found (skipping)".
Verified: full `bun desktop-prod` cycle now reaches "✅ Build complete"
and launches the .app bundle.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Fix NameError: '_gpu_pool' is not defined in get_model() (#90)
The `_gpu_pool` variable is a lazy module attribute that's only
accessible via `__getattr__` or the `_get_gpu_pool()` accessor.
Line 326 was using `_gpu_pool` directly, causing a NameError when
`get_model()` was called. Line 357 already correctly uses
`_get_gpu_pool()`.
This fix aligns line 326 with the rest of the codebase by using the
proper accessor function.
Co-authored-by: Nexlabz <contact@nexlabz.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Phase 3 Plan 03-01: Supertonic-3 engine on SubprocessBackend (#101)
* Phase 3 Plan 03-01: Supertonic-3 engine on SubprocessBackend
Adds Supertonic-3 as a 7th opt-in TTS engine on the Phase 2
SubprocessBackend primitive. Closes TTS-01..06 (REQUIREMENTS.md):
* TTS-01 — _REGISTRY["supertonic3"] resolves to Supertonic3Backend,
a SubprocessBackend subclass.
* TTS-02 — `supertonic==1.3.1` lives under [project.optional-dependencies];
default `uv sync --no-dev` does NOT install it. Exactly one
`onnxruntime` row in `uv pip list` after `--extra supertonic`.
* TTS-03 — Model revision pinned by 40-char commit SHA
(724fb5abbf5502583fb520898d45929e62f02c0b — the "Initial
Supertonic 3 release" SHA, same as the SDK's own pin).
Resolver script for intentional bumps:
scripts/resolve_supertonic3_sha.py.
* TTS-04 — Honest CPU-only reporting. `is_available()` message says
"ready (CPU-only via onnxruntime)" and never mentions
"cuda" or "mps". `gpu_compat = ("cpu",)`.
* TTS-05 — License gate via settings_store helpers
(get/set_license_accepted) + Loopback-only
/api/settings/license endpoint + SupertonicLicenseDialog
frontend modal showing MIT (code) and OpenRAIL-M (model).
Wired into EngineCompatibilityMatrix as an "Accept license"
button on rows whose `reason` mentions "license not
accepted".
* TTS-06 — 3 langs (en/ja/ru) × 3 sec smoke test in
tests/test_supertonic3.py::test_smoke_3langs_3sec
(OMNIVOICE_SMOKE-gated; asserts no onnxruntime-gpu row
post-synthesize).
Package legitimacy gate (Task 1 in plan): supertonic on PyPI verified
to be published by Supertone Inc. (ato@supertone.ai), repo
github.com/supertone-inc/supertonic, wheel is pure-Python with no
postinstall scripts. Same publisher ships supertonic-js on npm under
the same maintainer email.
Test results:
* tests/test_supertonic3.py — 10 passed, 3 skipped (network-gated).
* tests/smoke/ — 4 passed.
* tests/ (full, --ignore=tests/manual) — 412 passed, 0 failed.
* ci(tests): uv sync --all-extras so optional-engine tests can import their package
Phase 3 added `supertonic` as an optional dependency. The CI Tests job
runs `uv sync` (no extras), so `test_cpu_only_honest` and `test_license_gate`
in tests/test_supertonic3.py hit the "supertonic package not installed"
fallback instead of the real import path, and fail.
Bare `uv sync` is the right default for users (engines are opt-in), but
the test environment should exercise the full surface. `--all-extras`
keeps the smoke job lean (still bare `uv sync`) while letting Tests
verify the integrated behavior of every optional engine.
Future-proofs against the same failure mode in Phase 4 (GGUF) and any
later optional engines.
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 2 Plan 02-04: Engine Compatibility Matrix API + UI (#99)
* Phase 2 Plan 02-04: GET /engines/{id}/health + gpu_compat + HF mask
ENGINE-06 backend half. Adds the data + spawn-on-demand endpoint the new
Engine Compatibility Matrix UI will consume:
* `gpu_compat: tuple[str, ...]` class attribute on `TTSBackend`, overridden
per backend with reasonable defaults (cuda+mps+cpu for OmniVoice/VoxCPM2;
cpu-only for KittenTTS; mps+cpu for MLX-Audio; etc.). `list_backends()`
serializes it as a list.
* `_HF_TOKEN_MASK_RE` (`hf_[A-Za-z0-9]{30,}`) scrubs the `reason` and
`last_error` fields before they leave the registry — Phase 1's
HFTokenRedactor logging filter does not run on FastAPI response bodies,
so this closes T-02-12.
* `GET /engines/{engine_id}/health` — loopback-gated route that resolves
the backend across tts/asr/llm registries, then either calls
`SubprocessBackend.health_check()` (spawn-and-ping) for subprocess
engines or falls back to `is_available()` for in-process engines.
Returns `{ id, ok, message, latency_ms }`. Engine instances are cached
per-class so repeated checks don't leak atexit hooks or spawn extra
sidecars. The masked-redactor is reapplied on the way out.
Test coverage (tests/backend/api/test_engines_route_shape.py, 11 tests):
* Response shape includes the new fields for every TTS entry
* IndexTTS2 isolation_mode == "subprocess", OmniVoice == "in-process"
* Health route round-trips with mocked SubprocessBackend success
* Health route falls back to is_available for in-process backends
* Unknown engine id → 404
* Non-loopback origin → 403
* Engine instance cache reuses the singleton across calls
* HF tokens leaked into is_available() / health_check() are masked
in both the /engines and /engines/{id}/health response bodies
Existing tts_backend_registry shape test updated to include `gpu_compat`.
Full suite: 402 passed, 0 failures (up from 391+ baseline).
* Phase 2 Plan 02-04: EngineCompatibilityMatrix UI + Settings wiring
ENGINE-06 frontend half. Mounts a new component on Settings → Engines
that surfaces, end-to-end, the data shape Plan 02-01 + Plan 02-03 added
to the backend registry:
* `frontend/src/components/EngineCompatibilityMatrix.jsx` (270 lines) —
semantic <table> with role=row/cell so RTL queries work; one row per
registered backend. Columns:
- Engine name + install hint + Last error line
- Install state badge (Available / Unavailable + inline reason)
- GPU compat chips (CUDA / MPS / ROCm / CPU with colored variants)
- Isolation mode badge (subprocess for IndexTTS, in-process for the
rest — makes the Phase 2 architectural shift legible to users)
- "Test engine" button → `/engines/{id}/health` round-trip; renders
latency in ms inline next to the button; disabled while inflight;
5 s cooldown to prevent click-storms.
Mount does NOT auto-test any engine — per the plan's Open Question #2,
spawning sidecars is gated on user action.
* `frontend/src/components/EngineCompatibilityMatrix.css` — minimal
styling that reuses chrome tokens; chip colors per GPU target.
* `frontend/src/api/engines.ts` — `getEngineHealth(id)` client function
wraps the new backend route through the shared apiJson helper.
* `frontend/src/api/types.ts` — extends EngineBackend with optional
`isolation_mode`, `last_error`, `install_hint`, `gpu_compat` so the
TypeScript surface tracks the backend wire shape, and adds
EngineHealthResponse.
* `frontend/src/pages/Settings.jsx` — replaces the hand-rolled Engines
table inside EnginesTab with `<EngineCompatibilityMatrix family="tts"
onSelect={...} />`. selectEngine still wires up the picker; the
matrix's onSelect prop renders the Use button per row when provided.
Removes the now-unused FAMILY_META local map.
Test coverage (`frontend/src/test/EngineCompatibilityMatrix.test.jsx`,
8 tests via vitest):
* Renders one row per backend with documented columns
* isolation_mode badge: subprocess for IndexTTS2, in-process for
OmniVoice / KittenTTS
* GPU compat chips: omnivoice → cuda/mps/cpu; kittentts → cpu only
* Unavailable rows render the failure reason inline
* last_error line renders below status when populated; masked HF
token sentinel survives verbatim
* Test engine click fires getEngineHealth(id) and renders latency_ms
* Test button disabled while inflight; second click is a no-op
* Failure path (ok=false) renders a failure marker
Frontend suite: 65 passed (8 new). Lint: 0 new errors. typecheck:ci: clean.
* Phase 2 Plan 02-04: SUMMARY
Recap of Engine Compatibility Matrix delivery — backend route +
gpu_compat metadata + HF-token redaction, frontend EngineCompatibility-
Matrix component, full test counts, deviations, gpu_compat confidence
matrix, frontend test-runner command notes for Phase 6 CI.
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 2 Plan 02-03: IndexTTS on SubprocessBackend (closes#42) (#98)
Migrates IndexTTS-2 off the in-process import path and onto the
SubprocessBackend primitive shipped in Plan 02-01. Closes issue #42 with
a structural fix — the parent's transformers>=5.3 and IndexTTS's
transformers<5 now live in separate OS processes and can never collide.
* New: backend/engines/indextts/ — sidecar package (__init__.py hosts
IndexTTS2Backend, main.py is the sidecar entrypoint, bootstrap.py owns
the 3-step venv probe + lazy uv-based bootstrap).
* services.tts_backend: IndexTTS2Backend's in-process body removed;
registry resolves the class lazily via a _LazyRegistry indirection +
PEP 562 __getattr__ re-export. This breaks the import cycle that
arose when both subprocess_backend and tts_backend tried to import
each other at module load.
* docs/engines/indextts.md: install walkthrough + venv resolution order
+ common errors (linked from is_available()'s unavailable message).
* tests:
- test_indextts_backward_compat.py (8) — probe priority, no-spawn
discipline, HF cache marker preservation (ENGINE-07).
- test_indextts_sidecar.py (17) — subclass shape, isolation_mode,
parent-side emotion arbitration (vector/audio/text/description),
coexist-with-OmniVoice (headline #42 closure), env forwarding.
- tests/fixtures/mock_indextts_sidecar.py — stdlib-only sidecar
mimicking the production wire protocol; emits 1 s sine wave.
- test_issue_fixes.py: two obsolete in-process-conflict tests rewritten
to assert the new subprocess contract (no indextts.* import in the
parent).
Hard constraints honored: backend/services/sonitranslate.py and
gpu_sandbox.py are untouched (D1 / D4). Existing v0.2.7 users with
OMNIVOICE_INDEXTTS_DIR and a populated HF cache reach a working
generation with zero re-download and zero re-install.
44 tests pass across the four exercised files. Full suite: 391 passed,
10 skipped, 13 xfailed, 1 xpassed in 57 s. Smoke: 4 passed.
Closes#42. Requirements: ENGINE-02, ENGINE-03, ENGINE-04, ENGINE-07.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 2 Plan 02-01: SubprocessBackend primitive (Wave 1 of Phase 2) (#97)
* Phase 2 Plan 02-01: SubprocessBackend primitive + echo sidecar + ENGINE-05 wrap
Lands the durable SubprocessBackend primitive — the architectural keystone
that Plans 02-03 (IndexTTS migration), Phase 3 (Supertonic-3), and
Phase 4 (GGUF / Singing) plug into.
Files added:
- backend/services/subprocess_backend.py — base class owning spawn,
shutdown, _send/_recv (length-prefixed JSON), GPU-slot acquire-release,
atexit teardown, stderr drain, op allowlist (T-02-04), and 64 MB
frame cap (T-02-01). No multiprocessing — subprocess.Popen
exclusively so subclasses can target a *different* venv's interpreter
(Locked Decision D4 / Pitfall 1).
- backend/engines/_echo/main.py — permanent CI regression sidecar.
Stdlib-only, runs under the parent's sys.executable. Implements
ready/ping-pong/synthesize/shutdown plus test-only probe_env and
emit_unknown ops for env-forwarding and op-allowlist tests. DO NOT
DELETE — the round-trip test depends on this file.
- tests/backend/services/test_subprocess_backend.py — 13 tests:
round-trip, health_check, no-zombie, shutdown idempotency, env
forwarding (HF_TOKEN/HF_HOME/HF_ENDPOINT/HF_HUB_CACHE), oversize
frame, short read, op-allowlist drop, op-allowlist constant shape,
sidecar-crash recovery, no-multiprocessing grep gate, MAX_FRAME_BYTES.
- tests/backend/services/test_tts_backend_registry.py — 6 tests for
list_backends() resilience + shape + isolation_mode + last_error
caching + existing-engines preservation + install_hint passthrough.
Files modified:
- backend/services/tts_backend.py:
* Adds module-level _LAST_ERRORS dict for ENGINE-06.
* Rewrites list_backends() to wrap each is_available() in try/except
so one broken engine cannot blank the picker (ENGINE-05).
* Adds last_error + isolation_mode keys to each response entry
(ENGINE-06 UI in Plan 02-04 consumes via the same /engines route).
* Uses a duck-typed _is_subprocess_isolated marker rather than
issubclass(cls, SubprocessBackend) because test fixtures (token
resolver suite) purge sys.modules["services"] between tests and the
re-imported SubprocessBackend would be a different class object.
Threat-model mitigations (Plan 02-01 frontmatter):
T-02-01 DoS via length-prefix → MAX_FRAME_BYTES = 64 * 1024 * 1024
T-02-02 GPU slot leak on sidecar death → try/finally in generate
T-02-03 token bytes in stderr → drained via parent logger
(HFTokenRedactor from Phase 1 already on root)
T-02-04 unknown ops from compromised sidecar → PARENT_INBOUND_OPS
allowlist, unknown frames logged and dropped
T-02-05 Tauri group-kill scope → start_new_session=True on Unix /
CREATE_NEW_PROCESS_GROUP on Windows
Verification:
- 337 passed, 6 skipped, 12 xfailed, 1 xpassed (full suite,
`uv run pytest tests/ --ignore=tests/manual`)
- All 19 new tests pass on macOS Apple Silicon
- Smoke tests still pass: `uv run pytest tests/smoke/ -q` → 4 passed
- SoniTranslate untouched (D1 locked decision)
- Zero new Python dependencies
Closes part of ENGINE-01 + ENGINE-05.
* docs(02-01): plan summary — public API, invariants, deviations
Documents the SubprocessBackend public API so Plan 02-03 (IndexTTS) and
Phase 3 (Supertonic-3) authors don't need to re-read the source.
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 2 Plan 02-02: audio I/O hardening + WAV-export correctness (#96)
* Phase 2 02-02: add _safe_torchaudio_save + _safe_soundfile_write helpers
Centralizes WAV/audio writes through a single audited path that defends
against the four documented torchaudio.save failure modes (CUDA/MPS
tensor, non-contiguous, out-of-range, wrong dtype) AND the torchaudio
2.9+ TorchCodec-delegation behavior drift.
* services/audio_io.py:_safe_torchaudio_save now performs:
- .cpu() move (torchaudio cannot serialize CUDA/MPS)
- dtype coercion to torch.float32
- .clamp(-1.0, 1.0) (out-of-range = silent clipping on some backends)
- .unsqueeze(0) for 1D (mono) inputs
- .contiguous() (torch.cat of slices = non-contig = silent corruption)
- explicit encoding="PCM_S/PCM_F" + bits_per_sample so future
torchaudio backend selection cannot drift the on-disk format
- format passthrough for wav/flac/mp3/ogg with encoding-kwarg fallback
for older codec builds
* services/audio_io.py:_safe_soundfile_write — sibling helper for the
one sf.write call site (dub_core.py). Applies the same dtype/contig/
range checks before delegating to soundfile.write.
* services/audio_io.py:atomic_save_wav (existing P0 helper) now
delegates the actual encode to _safe_torchaudio_save so atomicity
and correctness compose: every byte that lands at the target path
was produced by the audited helper.
* tests/backend/services/test_audio_io.py — 29 tests (25 pass + 4
skipped for MPS dtype incompatibility): parametric round-trip across
dtype x device x contiguity, plus out-of-range clamp, format
passthrough, in-memory buffer, empty-tensor rejection, 1D auto-
unsqueeze, and a smoke check that atomic_save_wav inherits the
safety guarantees.
No new Python dependencies. SoniTranslate untouched (D1 locked).
Refs BUG-01 / #48.
* Phase 2 02-02: migrate router audio writes through audited helpers
Migrates all 12 grep-audit bare audio-write call sites in
backend/api/routers/ to route through services.audio_io. Closes the
last surface area of BUG-01 / #48 that the P0 atomic-write commit
(fb52140) did not cover.
Sites migrated (grep before → after):
generation.py:148 torchaudio.save → _safe_torchaudio_save
generation.py:162 torchaudio.save → _safe_torchaudio_save
openai_compat.py:155 torchaudio.save → _safe_torchaudio_save
openai_compat.py:160 torchaudio.save → _safe_torchaudio_save
openai_compat.py:168 torchaudio.save → _safe_torchaudio_save
openai_compat.py:172 torchaudio.save → _safe_torchaudio_save
openai_compat.py:178 torchaudio.save → _safe_torchaudio_save
openai_compat.py:182 torchaudio.save → _safe_torchaudio_save
openai_compat.py:193 torchaudio.save → _safe_torchaudio_save
dub_generate.py:509 torchaudio.save → _safe_torchaudio_save
batch.py:341 torchaudio.save → atomic_save_wav (track assembly)
dub_core.py:438 sf.write → _safe_soundfile_write
batch.py:341 specifically swapped to atomic_save_wav (not just the safe
helper) because it writes the final track to disk — same shape as
dub_generate.py:390 — and needs atomic publication, not only audited
encoding. atomic_save_wav already delegates internally to
_safe_torchaudio_save (per the Task 1 commit) so it inherits both
guarantees.
openai_compat.py:185 pcm branch produces raw int16 bytes (no
container), so it can't go through _safe_torchaudio_save; it now
inlines the same .cpu/.float32/.clamp/.contiguous sanity steps the
helper enforces.
tests/backend/test_dub_pipeline_wav.py:
- test_no_bare_audio_writes_in_routers (in-process grep gate)
- test_no_bare_audio_writes_via_subprocess_grep (CI-shell parity gate)
- test_track_assembly_handles_non_contig_after_torch_cat (the #48
smoking-gun reproduction — torch.cat of out-of-range non-contig
slices saved through the helper)
- test_atomic_save_wav_assembly_pattern (same shape, via
atomic_save_wav)
- test_safe_soundfile_write_dub_core_pattern (ASR transcribe-chunk
pattern from dub_core.py)
- test_dub_pipeline_produces_valid_wav (xfailed — Phase 0 fixture
sample_5s.mp4 not present; structural reproduction tests above
already cover the helper code path #48 went through)
Grep gate is green:
grep -nE '(torchaudio\.save|soundfile\.write|sf\.write)\(' \
backend/api/routers/ -r --include='*.py' \
| grep -v '_safe_torchaudio_save\|_safe_soundfile_write' \
| grep -v '^[^:]*:[[:space:]]*#' \
returns 0 lines.
Full suite green: 348 passed, 10 skipped, 13 xfailed, 1 xpassed.
SoniTranslate untouched (D1 locked).
Closes BUG-01 / #48.
* Phase 2 02-02: add execution summary
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 1 Wave 2: per-OS install docs + Settings UI + error→docs deeplinks (#94)
* docs(install): per-OS install pages + drift validator + CI gate
Splits the 600-line README install section into self-contained per-OS docs
under docs/install/{macos,windows,linux,docker}.md plus a Top-10
troubleshooting index. Each OS doc is end-to-end: a user opens it and
reaches a working app following only commands inside that file.
Adds:
- docs/install/{macos,windows,linux,docker}.md (OS-specific install paths)
- docs/install/troubleshooting.md (top 10 install errors)
- docs/engines/cosyvoice.md (closes#55 docs half)
- docs/features/diarization.md (pyannote license flow)
- docs/setup/huggingface-token.md (3-source cascade guide)
- scripts/validate-install-docs.py (INST-06 docs-drift gate)
- tests/scripts/test_validate_install_docs.py (B-5: validator self-tests)
- .github/workflows/ci.yml step running the validator on every PR
Implements INST-02 (README routing), INST-03 (macOS Gatekeeper anchor),
INST-12 docs half (Windows torch-compile-oom anchor), DOCS-01..05.
The validator is a one-way diff: every `<!-- validate -->`-tagged line
in docs must appear in scripts/desktop-prod.sh after normalisation
(prompt-prefix strip, CRLF, trailing whitespace, blank-and-comment skip).
A `<!-- validate: skip -->` marker opts out for human-readability blocks.
Its own 10 unit tests catch regressions in the gate itself.
* feat(deeplinks): links.py + error_docs_map (Python + TS mirror)
Adds the single source of truth for the project repo URL and the 4-class
error → docs taxonomy that both the in-app ErrorBoundary deeplink button
(Wave 2 Task 3) and the Phase 5 bug reporter will consume.
New:
- backend/core/links.py — PROJECT_REPO_URL + BLOB_MAIN resolver
(Tauri config first, pyproject fallback)
- backend/core/error_docs_map.py — lookup(error_class) → docs URL
- frontend/src/utils/errorDocsMap.ts (TS mirror with classifyError helper)
- tests/backend/core/test_links.py + test_error_docs_map.py
- frontend/src/utils/errorDocsMap.test.ts
Resolves checker B-6 (links.py ownership) and Open Question #3 (which fork
the deeplinks resolve to — the Tauri updater endpoint wins, which points
at the desktop app fork debpalash/OmniVoice-Studio).
The TS BASE constant is documented as the second hardcoded URL drift site;
the keys-sync test (`test_keys_match_python_map` equivalent) guards the
4-class taxonomy contract between Python + TS halves.
* feat(ui): Settings → API Keys panel + ErrorBoundary docs deeplink
Wave 2 AUTH-03 UI half + ErrorBoundary deeplink wiring.
ErrorBoundary fallback now renders an "Open docs for this error" button
that classifies the thrown Error message (heuristic: pkg_resources → 401 /
HfHubHTTP → WebKit / white screen → quarantine / Gatekeeper) and opens the
matching docs anchor via Tauri shell.open (with a window.open fallback
in browser dev mode).
ApiKeysPanel consumes the Wave 1 resolver state endpoint:
- 3 source rows (App / Env var / HF CLI) with set/unset indicator,
masked token preview, whoami username + green check
- "Active" badge on whichever source is currently serving the cascade
- App-row only: Save (POST /api/settings/hf-token) +
Clear (DELETE with optional "also clear HF CLI" confirm dialog)
- "Test now" button refetches state (invalidates the resolver's
validation cache via the same endpoint hit)
Panel mounted in the existing Settings → Credentials tab; the legacy
HF_TOKEN row from CREDENTIAL_FIELDS is filtered out so the two paths
don't fight over the same key.
Threat T-02-02: the panel never displays the full token. The masked
value comes from the resolver state endpoint; the full token only
crosses the IPC boundary on Save (POST) and is cleared from local
state on success.
Closes AUTH-03 fully (Wave 1 backend + this Wave 2 UI).
* feat(perf): INST-12 Disable torch.compile (Windows) toggle (backend + UI)
Wave 2 Task 4 — full INST-12 delivery per checker B-2/B-7 v0.3.0 fat-release
decision. Both the docs half (windows.md anchor, shipped in earlier commit)
and the runtime toggle are now in Phase 1.
Backend:
- backend/services/settings_store.py: adds get_text/set_text helpers for
non-secret config (refuses to write to the encrypted hf_token key).
- backend/api/routers/settings.py: GET + PUT
/api/settings/perf/torch-compile-disabled, both under the existing
loopback guard (threat T-02-04).
- backend/services/engine_env.py: new `build_engine_env()` helper that
centralises HF_TOKEN/YOUR_HF_TOKEN injection from the 3-source resolver
AND injects TORCH_COMPILE_DISABLE=1 when the flag is set on win32.
Phase 2 SubprocessBackend launchers should adopt the same helper.
- backend/services/sonitranslate.py: migrated to engine_env.build_engine_env()
while preserving the source-level `env["HF_TOKEN"]` sentinel that
test_sonitranslate_module_uses_resolver checks.
Frontend:
- frontend/src/components/settings/PerformancePanel.{jsx,css,test.jsx}:
toggle UI with the explainer for #65; renders disabled with a "not
applicable" badge on macOS/Linux.
- frontend/src/pages/Settings.jsx: mounts the panel into the Credentials
tab alongside the API Keys panel.
Tests:
- tests/backend/test_perf_settings.py: 7 backend tests (default state,
PUT persistence, T-02-04 non-loopback rejection, settings_store round-
trip, env injection on win32, NO injection on macOS/Linux, NO injection
when disabled).
- frontend PerformancePanel.test.jsx: 5 tests (renders from GET state,
PUT on toggle, disabled on non-Windows platforms, pre-enabled state).
* docs(planning): Wave 2 SUMMARY + REQUIREMENTS status updates
- .planning/phases/01.../01-02-SUMMARY.md: full implementation report
per template (truths, commits, tests, deviations, drift-site
acknowledgments per W-3, launcher seam name for Phase 2,
taxonomy keys for Phase 5).
- .planning/REQUIREMENTS.md: flips Wave 2 closures to Done:
AUTH-03, INST-02, INST-03 (docs half), INST-06, INST-12,
DOCS-01..05.
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
fix(smoke): force-override OMNIVOICE_DATA_DIR + purge cached backend modules (#95)
The smoke test used `os.environ.setdefault()` to point at the frozen
fixture, which silently skipped when a prior test in the suite had
already set the env var. Combined with `core.config` caching `DB_PATH`
at module import time, this left smoke tests pointed at the wrong DB
once Wave 1's services tests pre-imported `main` with their own temp
state.
Exposed by Wave 2's additional tests (PR #94) pushing collection order
past the tipping point, but the underlying pollution existed since Wave
1 merged — Wave 3 CI passed only by collection-order luck.
Fix mirrors the `sys.modules` purge pattern that
`tests/backend/services/conftest.py` already uses.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 1 Wave 3: AppImage launcher + .deb ffprobe + Docker LAN + Gatekeeper probe (closes#54, #56, #76, #80) (#93)
* fix(appimage): conditional WEBKIT_DISABLE_COMPOSITING_MODE launcher (#56)
WebKitGTK 2.44.x and 2.46.x have a compositing-path regression on Wayland
that blanks the AppImage's first paint on Fedora 44 / Ubuntu 24.04. Setting
WEBKIT_DISABLE_COMPOSITING_MODE=1 forces the software fallback that works,
but blindly setting it on healthy WebKit versions (2.48+) regresses those.
This wave adds a conditional AppRun launcher that detects the WebKit
version via pkg-config and only sets the env var on the broken ranges
(plus a fail-safe when pkg-config is absent or the version is unknown).
The launcher is injected into Tauri's AppImage staging dir via a
beforeBundleCommand hook — see .planning/decisions/apprun-strategy.md for
the spike outcome and rationale (Strategy B chosen).
Phase 1 Wave 3 — Plan 01-03 Task 1. Closes#56 frontend half.
* fix(deb): relocate bundled ffprobe out of /usr/bin to avoid conflicts (#76)
Prior versions placed the bundled ffprobe at /usr/bin/ffprobe via Tauri's
externalBin, which overwrites the system ffprobe on Ubuntu 26.04 and
collides with apt-installed media-package ffprobe.
Relocate the .deb-bundled ffprobe to /usr/lib/omnivoice-studio/bin/ffprobe
via bundle.linux.deb.files, plus defensive maintainer scripts:
- preinst: ensure target dir exists for upgrade flows
- postinst: remove legacy /usr/bin/ffprobe ONLY when dpkg confirms our
package owns it (never touches a user's distro ffprobe)
- postrm: clean up the relocated path tree on purge/remove
Rust side (tools.rs::resolve_ffprobe) now probes the new path on Linux,
and backend spawn (backend.rs) carries both FFPROBE_PATH (legacy alias)
and OMNIVOICE_FFPROBE_PATH (canonical) into the backend env. Python side
(ffmpeg_utils.resolve_ffprobe) reads OMNIVOICE_FFPROBE_PATH first, falls
back to FFPROBE_PATH, then to shutil.which("ffprobe").
6 new unit tests cover the env-cascade resolution.
Phase 1 Wave 3 — Plan 01-03 Task 2. Closes#76.
* fix(frontend): centralised apiBase resolver for Docker LAN access (#80)
Docker / LAN browser users hit the preview API at the LAN host's IP, not
their local machine — the prior frontend/src/utils/media.js:20 hardcoded
http://localhost:3900, which from a LAN client resolved to the client
machine itself.
Centralise via frontend/src/utils/apiBase.ts:
1. VITE_OMNIVOICE_API override (Docker compose / dev) always wins.
2. Tauri webview → http://localhost:3900 (unchanged behaviour).
3. Plain browser → ${window.location.protocol}//${window.location.hostname}:3900
(follows the page's origin — closes#80).
4. SSR / no-window → http://localhost:3900 (safe fallback).
Grep-sweep confirmed media.js:20 was the only hardcode site (Assumption
A4 in 01-RESEARCH.md verified). 6 new vitest cases cover the resolver.
Phase 1 Wave 3 — Plan 01-03 Task 3. Closes#80 frontend half.
* feat(backend): macOS Gatekeeper quarantine probe + INST-01 guard (#54)
Adds backend/core/gatekeeper_detect.py which walks up from sys.executable
to find the .app bundle and runs `xattr -l` to check for the quarantine
extended attribute (com.apple.quarantine). On detection, the lifespan
startup probe logs a structured warning and emits a system_error event
through the existing event bus with error_class="GATEKEEPER_QUARANTINE",
which Wave 2's React ErrorBoundary turns into a docs deeplink.
Detection is informational only — we never auto-run `xattr -cr` (the app
itself is quarantined and cannot fix its own state per Anti-Pattern in
01-RESEARCH.md). Users get a clear pointer to the workaround docs.
GET /system/quarantine-status exposes the structured payload so the
frontend can poll on first load.
INST-01 (setuptools>=75.0 pin from PR #62) gains a PR-time guard in
tests/backend/test_pyproject.py + a user-observable smoke check in
scripts/smoke-test.sh (pkg_resources + whisperx import).
7 gatekeeper tests + 1 pyproject test added — all pass.
Phase 1 Wave 3 — Plan 01-03 Task 4. Closes#54 backend half (Wave 2 owns
the docs page + ErrorBoundary deeplink wiring).
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
docs(planning): Phase 0 VERIFICATION + flip GATE/AUTH status to Done (#92)
Phase 0 was verified PASS against `main` (7/7 truths, 9/9 artifacts, 6/6 GATE
requirements, 5/5 success criteria; live smoke `tests/smoke/` green in 1.73s).
Add the verifier's report and reconcile the REQUIREMENTS tracker — GATE-01..06
and AUTH-01..06 now show Done now that PR #71 (Phase 0) and PR #91 (Phase 1
Wave 1) are both on `main`.
AUTH-03 is split: backend endpoints landed in Wave 1, UI ships in Wave 2.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 1 Wave 1: HF token persistence + redactor (closes#35) (#91)
* feat(01-01): encrypted settings store + alembic migration (AUTH-02, T-01-01)
Adds the SQLite-backed encrypted settings store that Phase 1 token resolver
will read from. Closes the at-rest plaintext risk for HF tokens (T-01-01).
- backend/services/settings_store.py: get_hf_token / set_hf_token /
clear_hf_token using Fernet symmetric AEAD. Stored value column never
contains the literal "hf_" substring.
- backend/services/_secret_key.py: per-install Fernet key derived via
scrypt(machine-id + 16-byte random salt). machine-id resolution covers
macOS (ioreg IOPlatformUUID), Linux (/etc/machine-id and dbus fallback),
Windows (HKLM Cryptography MachineGuid via winreg). Final fallback to
hostname+user with a warn log.
- backend/migrations/versions/0001_phase1_settings_table.py: alembic
migration adding `settings(key, value, updated_at)`. Idempotent — checks
for an existing table so fresh installs (where _BASE_SCHEMA already
created it) and v0.2.7 upgrades both succeed.
- backend/core/db.py: _BASE_SCHEMA grows the settings table for fresh
installs; init_db() now runs `alembic upgrade head` after the CREATE.
- backend/migrations/env.py: honours an externally-set sqlalchemy.url so
tests can point alembic at a fixture DB; falls back to core.config
DB_PATH for production.
- pyproject.toml: cryptography>=41 added explicitly (RESEARCH.md
Assumption A1 was checked at execute-time and proved false; the dep was
not present transitively, so the install would fail without this).
Tests (10 cases, all green):
- Round-trip encryption + plaintext-leakage check (T-01-01 invariant)
- Salt persistence across clear/set cycles
- InvalidToken decrypt path returns None (Open Question #5 resolution)
- Concurrent reads consistent under sqlite WAL
- Alembic upgrade on a hand-built v0.2.7 fixture DB preserves all
existing tables + seeded rows (CLAUDE.md backward-compat constraint)
- Alembic downgrade -1 drops only the settings table
Refs #35.
* feat(01-01): 3-source HF token resolver + log redactor + 5 read sites patched
Closes the #35 bug class (bare os.environ.get('HF_TOKEN') reads) by routing
every backend HF-token consumer through one resolver, and mitigates
T-01-02 (info disclosure via logs) by stripping `hf_[A-Za-z0-9]{30,}`
substrings from every log record at the root logger.
backend/services/token_resolver.py:
- resolve(skip) — 3-source cascade (App → Env → HF-CLI), each source
validated via huggingface_hub.whoami(); first valid wins.
- on_401(active) — invalidate cache and re-resolve skipping the source
that just 401'd (AUTH-06).
- state() — three SourceState rows for the Settings UI: set,
masked preview (hf_…<last 3>), whoami_user, whoami_ok.
- save_app_token / clear_app_token — wraps settings_store + calls
huggingface_hub.login(add_to_git_credential=False) per Pitfall #2.
- 300-second whoami cache so repeated Settings-page renders don't hit
the HF API.
backend/core/logging_filter.py:
- HFTokenRedactor(logging.Filter) — regex `hf_[A-Za-z0-9]{30,}` so real
tokens are masked but `hf_hub` / `hf_token` literals survive.
- install_redaction_filter() — idempotent attach to root + every handler.
backend/main.py: install the redactor at startup, BEFORE the file
handler is added. Re-installed after the file handler attaches so the
handler-attached filter list includes it too.
Read-side call sites patched (per Pitfall #1 — every HF token read must
flow through token_resolver.resolve()):
- backend/api/routers/dub_core.py:540 (the original #35 site)
- backend/api/routers/system.py:38 (_has_hf_token notification)
- backend/services/model_manager.py:480 (diarization pipeline auth)
- backend/services/sonitranslate.py:143 (Popen env for SoniTranslate child)
- backend/services/sonitranslate.py:217 (gradio_client predict call)
New endpoint:
- GET /system/hf-token/state — returns the 3-source cascade state with
masked tokens for the Wave 2 Settings UI panel.
Grep gate confirmed clean: zero `os.environ.get("HF_TOKEN")` reads remain
outside token_resolver.py.
Tests (17 new cases, all green):
- tests/backend/services/test_token_resolver.py: priority cascade, 401
skip mid-resolve, on_401 fallback, state() shape, save+login
invariant (add_to_git_credential=False), HUGGING_FACE_HUB_TOKEN
alias acceptance.
- tests/backend/core/test_logging_filter.py: msg + args redaction,
multi-token redaction, non-string args pass-through, short-token
literals preserved, install_redaction_filter idempotence.
Refs #35.
* feat(01-01): Settings hf-token API endpoints + subprocess env injection (AUTH-03/04)
Backend half of the Wave 2 Settings → API Keys UI plus the AUTH-04
subprocess env-injection invariant.
backend/api/routers/settings.py:
- POST /api/settings/hf-token — body {token: str} → save_app_token
- DELETE /api/settings/hf-token — also_clear_hf_cli query → clear_app_token
- GET /api/settings/hf-token/state — same shape as token_resolver.state()
All three are gated by `Depends(require_loopback)` at the router level
(threat T-01-03 mitigation; non-loopback Host → 403).
backend/main.py: router mounted alongside existing API routers.
Subprocess env injection (AUTH-04, threat T-01-04 disposition=accept):
- backend/services/sonitranslate.py already updated in Task 2 to read
via token_resolver.resolve() and inject HF_TOKEN + YOUR_HF_TOKEN into
the SoniTranslate child env block.
- backend/services/gpu_sandbox.py: NOT patched — the GPU sandbox runs
in-process TTS generation that uses the parent's already-loaded HF
state. Adding env injection there is a no-op (parent and child share
state via multiprocessing.Pipe before any HF API call).
- backend/services/model_manager.py:480 (Task 2): resolves in-process,
no subprocess crosses here.
- backend/api/routers/exports.py: subprocess.Popen calls only spawn
`open` / `explorer` / `xdg-open` — file-manager launchers with no
HF needs. Skipped per Task 3 conservative-patching rule.
So the canonical AUTH-04 site for this milestone is sonitranslate.py.
Future SubprocessBackend work in Phase 2 will inherit the same pattern.
Tests (8 new cases, all green):
- tests/backend/test_engine_spawn_token.py
* POST /hf-token loopback → 200 + state.active == "app"
* POST /hf-token non-loopback → 403 ("loopback origin required")
* DELETE /hf-token clears settings_store + state.active == None
* GET /hf-token/state returns 3 source rows in priority order
* GET /hf-token/state non-loopback → 403
* env block contains HF_TOKEN + YOUR_HF_TOKEN when resolver returns one
* env block does NOT contain an injected empty HF_TOKEN when resolver
returns None
* source-level check that backend/services/sonitranslate.py still
reads via token_resolver.resolve() (regression guard against
silent reverts of the AUTH-04 wiring)
Full Wave 1 test suite: 35/35 green. Phase 0 smoke tests still green.
Refs #35.
* docs(01-01): SUMMARY + STATE update for Phase 1 Wave 1 completion
Records execution outcome of the 3-task plan: 10 files created, 9 modified,
35 new test cases, 5 read sites patched, grep gate clean. Documents the
two Rule-3/Rule-2 deviations applied (cryptography dep, env.py URL
override), the subprocess-launcher inventory for Phase 2, and the
known stray edit to the main repo's pyproject.toml that needs a one-
line user action to revert.
Updates STATE.md current-position table, progress bar, and open TODOs to
point at Wave 2 (Plan 01-02) and Wave 3 (Plan 01-03) as the next steps.
P0 wave-1: security + correctness + Phase 2 foundation (#88)
P0 security + correctness fixes plus Phase 2 foundation work. 7 atomic commits, all CI green (Smoke + Tauri shell on macOS/Win/Linux + Tests).
Code commits:
- 92f716e: P0 security — loopback guard on /ws/transcribe before accept()
- fb52140: P0 dub — atomic WAV writes (closes#48 partial; Phase 2 plan 02-02 covers remaining sites)
- 9545640: P0 supply-chain — pin BtbN ffmpeg URL via FFMPEG_BTBN_VERSION
- e414665: P0 security — remove torch.load monkey-patch in asr_backend
- 6b49290: docs — Phase 4 plan <action> blocks on checkpoint tasks
- e764fdb: Phase 2 prep — TTSBackend.unload() foundation
- 71c10dc: test — fix capture_ws TestClient host for loopback guard
243 pytest passing, 0 failures. All 18 phase plans now validate.
docs(v0.3.0): research + 18 plans for fat-milestone planning (#87)
* docs(phase-5): research opt-in bug reporting
Phase 5 research: prefilled-URL GitHub Issues pattern, default-deny payload,
redaction layer, two-step consent UX, rate/dedup/recursion safeguards,
aggregation across Python/Rust/React error producers. Builds on Phase 1's
links.py + errorDocsMap deeplink infrastructure; uses already-installed
@tauri-apps/plugin-opener (^2.5.4). No new packages required.
Covers REPORT-01..12 with confidence levels, 8 pitfalls, subprocess-engine
error capture handoff to Phase 2, security domain mapped to ASVS, and
3-wave delivery plan (redactor + payload, consent UI, aggregation +
pre-submit search).
* docs(phases): research for Phases 2, 3, 4, 6 (Engine + Supertonic + Spikes + Release)
* docs(stack): bump supertonic pin 1.2.3 → 1.3.1 (Phase 3 research finding)
* docs(phases): plan Phases 2-6 for v0.3.0 fat-milestone release
15 new plan files + 2 ADR decision docs across 5 phases. Combined with Phase 1's 3 plans, the v0.3.0 milestone now has 18 PLAN.md files covering all 7 phases (Phase 0 already complete via PR #71).
PHASE 2 (Engine Isolation — 4 plans):
- 02-01: SubprocessBackend primitive + echo sidecar POC + graceful is_available wrap (ENGINE-01/05)
- 02-02: _safe_torchaudio_save helper + migrate 11 WAV write sites + #48 regression (BUG-01)
- 02-03: IndexTTS sidecar entry + venv-probe bootstrap + IndexTTS2Backend rewire (ENGINE-02/03/04/07, closes#42)
- 02-04: Engine Compatibility Matrix UI + /engines/{id}/health route (ENGINE-06)
PHASE 3 (Supertonic-3 + Mirror — 2 plans):
- 03-01: Supertonic-3 engine on SubprocessBackend + SHA pin + license gate (TTS-01..06)
- 03-02: bootstrap.rs mirror cascade + UV_DEFAULT_INDEX migration + frozen enforcement + docs (INST-07..11)
PHASE 4 (Spike-first Adaptive & Specialty — 2 plans + 2 ADRs):
- 04-01: OmniVoice-GGUF hardware-adaptive engine + quant_map + bundled binaries (SPIKE-01, GGUF-01..06)
- 04-02: OmniVoice-Singing subclass + dub pipeline singing mode + segment detector (SPIKE-02, SING-01..05)
- SPIKE-01-gguf.md + SPIKE-02-singing.md ADRs in .planning/decisions/
PHASE 5 (Opt-in Bug Reporting — 3 plans):
- 05-01: Redactor + BugReporter + URL builder + rate/dedup/recursion safeguards + FastAPI router (REPORT-01/02/03/05/06/07/08/10/11)
- 05-02: BugReportDialog two-step consent + PrivacyPanel + ErrorBoundary integration + Rust panic hook (chained) (REPORT-01-Rust/04/09/12)
- 05-03: Dry-run vs 3 historical issues + cross-platform openUrl smoke + Phase 2 subprocess-errors handoff (REPORT-02 smoke, REPORT-03 expansion, REPORT-09)
PHASE 6 (Release + Retro — 4 plans):
- 06-01: rc1 prep — version bump across 4 sources + CHANGELOG + retro stub + PR-73-strategy doc (REL-01/03/06)
- 06-02: CI guards — workflow-parity actionlint + tag-shaped dry-run (Phase 0 retro options B + C; closes release-engineer gap)
- 06-03: PR #73 reimplementation (NOT rebase) — backend-split installer with mirror-cascade integration + pill-mode regression checkpoint
- 06-04: Execute the release — pre-tag gates + 4-OS clean-VM + 48h soak + tag + retro + 3 v0.4 deferral tracking issues (REL-01/02/03/04/05/06)
Scope decisions locked in plans (council session):
- SoniTranslate refactor DEFERRED to v0.4 (Phase 2 ships SubprocessBackend without migrating Soni)
- macOS notarization DEFERRED to v0.4 (Phase 6 ships xattr -cr automation per CLAUDE.md Key Decision #7)
- supertonic pin 1.2.3 → 1.3.1 (already committed in ba63733)
- SPIKE-01 and SPIKE-02 both GO; 13/13 Phase 4 reqs stay in scope
- PR #73 reimplemented, not rebased (93 commits behind main)
All 18 plans validated via gsd-sdk frontmatter.validate + verify.plan-structure.
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
P0: release.yml typecheck + bind audit + loopback middleware (#84)
Three P0 fixes bundled — foundation cleanup before v0.3.0 phase work. Closes release.yml drift (PR #51's tabs broke v0.3.0 tag releases), production bind exposure (Critic F1), and 9-endpoint LAN gap on /system/* (Critic F2+F3). 5 new tests; 243 full pass.
fix: add PYTHONPATH to docker-compose for pre-built images (#77)
Community contribution from @fishandsheep.
Adds PYTHONPATH=/app/backend to docker-compose env for both omnivoice (CPU) and omnivoice-gpu service blocks, so the pre-built Docker image can import backend modules correctly on first boot.
Complements PR #74 (Docker GPU detection) — different sections of docker-compose.yml.
Thanks @fishandsheep!
fix(docker): GPU detection in containers + compose profiles + sonitranslate cuDNN sub-repo (#74)
Docker GPU support hardening + documentation.
- Restores docker compose --profile gpu up path; documents NVIDIA Container Toolkit setup in README
- Splits CPU vs GPU compose services cleanly (deploy/docker-compose.yml)
- backend/api/routers/setup/wizard.py: GPU detection in containerized environments uses torch.cuda fallback
- New scripts/setup.py replaces deleted scripts/setup_cudnn.py
- New test: tests/test_setup_preflight.py
- CHANGELOG.md + README.md updated
Complementary to PR #77 (community PYTHONPATH fix) — different sections of docker-compose.yml.
fix: stabilize dub/diarization UI + production deployment + sonitranslate plumbing (#75)
Production deployment hardening, dub OOM recovery, new SoniTranslate sidecar engine, ASR backend expansion.
- Dub generation OOM recovery: backend/api/routers/dub_generate.py:163-209 adds OOM detection + one retry with reduced nstep
- New SoniTranslate sidecar engine: backend/api/routers/sonitranslate.py + backend/services/sonitranslate.py (subprocess-based dubbing pipeline, opt-in)
- ASR backends expansion: backend/services/asr_backend.py adds NeMo Parakeet TDT, Moonshine, additional Whisper variants; new GET /system/asr-backends endpoint
- Dub UI polish: tighter spacing in DubSegmentRow.css, DubTab.css
Issue #78 (speaker diarization mis-assignment) NOT addressed by this PR — the bundled diarization changes are in the new SoniTranslate sidecar, not the existing pyannote pipeline. Keeping #78 open.
No DB schema changes, no migration. Backward-compatible for existing user data.
fix(widget): hide dictation pill when idle, show only when activated (#83)
Dictation pill widget no longer displays the idle "Ready — hold shortcut to speak" state by default. The widget now appears only when actively used (global shortcut press or tray "Start Dictation" click).
Two surgical edits to frontend/src-tauri/src/lib.rs:
1. Pill-mode setup: removed win.show() + win.set_focus() on the widget. Kept positioning so the first show appears at top-center without animation flicker.
2. Tray "dictate" handler: now positions + shows + focuses widget BEFORE emitting tray-dictate, mirroring the global-shortcut handler. Previously tray-initiated dictation would record silently with no visible UI.
Trade-off accepted: the original auto-show was intended to prevent a "looks-launch-failed" first-run experience for users without Accessibility permission. The tray icon + "OmniVoice Dictation" tooltip provide app-running signal; first-launch onboarding toast can be added later if support requests indicate confusion.
Cross-platform bug bash + Stories tab + VRAM-aware GPU pool (#51)
First v0.3.x release on the Phase 0 cross-platform CI baseline.
## Cross-platform bug fixes (375ea4e)
User-reported bugs from a Pinokio/Windows session:
- Docker `compose --profile gpu up` no longer port-conflicts on 3900 — restored `profiles: ["cpu"]` that #49 wrongly reverted on CodeRabbit's advice.
- Argos / pip install from the UI now works inside Docker — added `_in_virtualenv()` runtime check; `run_pip` injects `--system` automatically when on system Python.
- Speaker diarization warning toast — when pyannote silently falls back to the silence-gap heuristic (missing HF_TOKEN, license not accepted, network blocked), `_diarize()` now returns `(segments, warning)`; `useDubWorkflow` renders an 8-second toast.
## Dub editor UX (d5df454)
Six fixes per annotated screenshots:
- Editable segment start times (`m:ss.s` or raw seconds; Esc reverts, Enter commits; rejects overlap with end).
- Click a transcript row → seek the waveform/video (`WaveformTimeline` now forwardRef's `seekTo(time)`).
- Speaker is datalist-backed (pulls from detected speaker clones; free text still allowed).
- Scissors menu splits at cursor — uses live caret, then last caret, then sentence-boundary fallback.
- Mouse-wheel scrolls the waveform; Cmd/Ctrl left alone for browser pinch-zoom.
- Menu popover collision: added `avoidCollisions` + `collisionPadding=8` to Radix Content; removed `position: fixed` from `.ui-menu`.
## VRAM-aware GPU pool (73dbe18)
`_gpu_pool` was hardcoded `ThreadPoolExecutor(max_workers=1)` since introduction — every TTS forward serialized through one thread.
- CUDA / ROCm: `workers = clamp(1, free_GB // 2.5, 4)`. 16 GB card with ~14 GB free → 4 workers → ~4× throughput on multi-segment dubs.
- MPS / CPU / unknown: 1 worker.
- `OMNIVOICE_GPU_WORKERS` env var override (clamped 1..16).
- Module `__getattr__` preserves the public `_gpu_pool` symbol for existing callers.
## Stories tab — wire-up + UX (f6bbc7a)
The 264-line `StoriesEditor` component existed but was mounted nowhere. Now wired into NavRail + lazy-loaded on `mode === 'stories'`. Added Paste & Split panel (sentence-boundary chunking) and per-track `[pause 0.5s]` insertion.
## Stories — pauses + inline voice (edd3a1d)
`frontend/src/utils/storyTokens.js` — tokenizer for `[pause X.Ys]` and `[voice:X]…[voice:default]` markers. Voice switches are stateful (carry forward). 13 new vitest cases (vitest now 24/24).
## Verified
- 214 backend tests pass (3 skipped, 10 xfailed, 3 xpassed)
- 23 router-smoke tests pass
- 24/24 vitest cases pass (13 new)
- All 7 Phase 0 CI checks green (Tauri shell + Smoke on macOS/Windows/Linux + Tests)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
security: add loopback origin check to /system/set-env (#81)
Adds `request.client.host` allow-list check (`127.0.0.1` / `::1` / `localhost`) to `POST /system/set-env`. Non-loopback callers receive `403` instead of being able to mutate `os.environ` for HF_TOKEN / TRANSLATE_API_KEY.
Surfaced during security review of PR #66, which widens the pre-existing window by persisting these keys to disk via prefs.json. This fix closes the underlying vulnerability so PR #66's revision lands onto a clean base.
Defensive `request.client is None` branch handles ASGI middleware that strips client info. Three new tests cover non-loopback reject, loopback allow, and allow-list still validated on loopback.
Follow-up: `260518-ivy-deferred-items.md` enumerates 5 sibling POST routes in `system.py` that share the same gap — separate PR.
Phase 0 — Gates: cross-platform CI matrix + regression fixture + release smoke (#71)
* docs: initialize OmniVoice stabilization milestone project
* chore: add project config (yolo + balanced)
* docs: domain research for stabilization milestone
* docs: define v1 requirements for stabilization milestone
* docs: add GGUF + singing engine spike requirements (Phase 4 new)
* docs: roadmap revision + CLAUDE.md (7 phases, 62 reqs, +GGUF/SING spikes)
* docs(phase-0): add Gates phase RESEARCH.md
Phase 0 research synthesizes the cross-platform CI matrix, frozen
omnivoice_data fixture, installer post-build smoke, SHA-256 checksum
publishing, and PR-template extension into copy-paste-ready YAML and
Python snippets composed entirely from existing in-repo patterns.
* docs(phase-0): add Gates phase CONTEXT, PATTERNS, and PLAN
Phase 0 — Gates is the hard pre-condition for v0.3.x stabilization.
Lays cross-platform CI matrix (macos-14/windows-2022/ubuntu-22.04),
regression fixture (≤200 KB), installer smoke on tag push, SHA-256
checksums in release body + per-OS SHA256SUMS-*.txt assets, PR
template with RC cadence + fixture line, and the open-PR landing
for #51.
Plan covers GATE-01..06; structured into 7 slices (A–G) with explicit
Slice C → Slice G dependency reordering so the new smoke-matrix lands
on main before PR #51 (CONTEXT.md L86 interleave decision).
Plan-checker iteration 2: APPROVED — all 3 BLOCKERs + 3 MAJORs from
iteration 1 resolved (file truncation/Slice-G missing, GATE-06 sibling
PR verification, Slice C ordering, Truth #5 wording, macOS Tauri
WebView avoidance per Pitfall #5, Windows taskkill per Pitfall #2).
* test(00-gates): seed regression fixture (GATE-01)
- scripts/seed-test-fixture.py — deterministic builder for tests/fixtures/omnivoice_data/
- wipes + rebuilds; fixed created_at=1700000000.0; all-zero PCM for byte-deterministic diffs
- calls backend.core.db.init_db() directly (alembic versions/ is empty — see CONTEXT.md)
- checkpoints WAL → DELETE on close so no -shm/-wal sidecars pollute git status
- exits non-zero if fixture > 200 KB
- tests/fixtures/omnivoice_data/{omnivoice.db, README.md} — 8-table empty DB + 1 voice_profiles row
- tests/fixtures/omnivoice_data/voices/test-voice/{profile.json, sample.wav} — 1-sec 24 kHz mono silence
- .gitignore — explicit allow-list (!tests/fixtures/omnivoice_data/**) so the existing
omnivoice_data/, *.db, *.wav patterns don't hide the fixture from git
Verifies: du = 144 KB on disk; sqlite_master lists 8 init_db tables + sqlite_sequence;
voice_profiles has exactly 1 row id='test-voice'; 0 rows in generation_history.
* test(00-gates): add tests/smoke/test_boot_smoke.py (GATE-01)
- tests/smoke/__init__.py — package marker so pytest treats tests/smoke/ as a module
- tests/smoke/test_boot_smoke.py — 4 in-process FastAPI TestClient smoke tests:
* test_health_returns_ok — /health returns 200 + {status:ok, device:...}
* test_profiles_endpoint_lists_fixture_voice — /profiles surfaces the seeded
test-voice row (validates OMNIVOICE_DATA_DIR wiring → DB_PATH → init_db schema)
* test_system_info_includes_data_dir — /system/info resolves data_dir
* test_history_endpoint_empty — /history reaches DB and returns []
Test isolation env vars (OMNIVOICE_MODEL=test, OMNIVOICE_DISABLE_FILE_LOG=1)
set at module top BEFORE any backend import — pattern from tests/test_router_smoke.py.
Fixture is copied to a per-session temp dir so the test never mutates the
checked-in artifact (SQLite file-change counter + runtime subdirs like dub_jobs/
would otherwise dirty `git status` after every run).
Failure mode: if tests/fixtures/omnivoice_data/ is missing, pytest.fail at
import time with the regenerate command.
- .gitignore — tighten the GATE-01 allow-list to ONLY the seed-produced files
(README.md, omnivoice.db, voices/test-voice/profile.json, sample.wav).
Prevents future runtime subdirs the backend may create under the fixture
from being accidentally committed.
Verifies: `uv run pytest tests/smoke/ -q --tb=short` → 4 passed in 1.31 s
(target was < 30 s). `git status` clean after a test run.
* docs(triage): record post-planning GitHub state — PR #62, new issues, OOS deferrals
- GATE-06: mark #53 + #61 merged (2026-05-16); add #62 (Wave 1 quick wins) to gate set
- INST-01: note PR #62 implements setuptools pin (closes#58)
- INST-04: note PR #62 lands README docs for #56 workaround
- INST-12: new requirement for #65 Windows Triton/torch.compile OOM (filed post-planning)
- Out of Scope: defer #67/PR #68 (audio effects), #64 (custom model dir),
PR #66 zh-CN (i18n milestone), #63 (empty-template bug)
PR #62 is the user's own Wave 1 work landed as a separate PR while
GSD planning ran in parallel. Merging it eliminates duplicate work
in Phase 1.
* ci(00-gates): add cross-platform smoke matrix (GATE-02)
- New smoke-matrix job on macos-14, windows-2022, ubuntu-22.04
- needs: test, fail-fast: false, timeout-minutes: 10
- Pinned actions: checkout@v4, setup-python@v5, setup-uv@v3 (cache enabled)
- Per-OS ffmpeg + libsndfile install (brew/choco/apt via awalsh128 cache)
- UV_HTTP_TIMEOUT=120, UV_HTTP_RETRIES=5 for restricted-network resilience
- Narrow scope: uv run pytest tests/smoke/ -q --tb=short
- Existing `test` and `tauri-cross-platform` jobs untouched
* ci: add workflow_dispatch to ci.yml so smoke-matrix can run on feature branches
* feat(00-gates): add --health-check CLI flag to backend entrypoint (GATE-03)
- argparse on __main__ block; --health-check boots uvicorn in a daemon
thread and polls http://127.0.0.1:3900/health every 5s for up to 60s.
- Prints 'OK — /health responded 200 after Ns' and exits 0 on first 200.
- Prints 'FAIL — /health did not respond 200 within 60s' to stderr and
exits 1 on timeout. Default invocation behavior unchanged.
- No new deps (stdlib argparse/threading/time/urllib.request/sys + uvicorn).
- Consumed by per-OS installer-smoke step in .github/workflows/release.yml.
Verified locally: exits 0 in 5s against tests/fixtures/omnivoice_data/.
* ci(00-gates): add per-OS installer smoke to release.yml (GATE-03)
Adds three matrix-leg-specific steps after 'Build + release (Tauri)',
each gated by runner.os with timeout-minutes: 5:
- macOS (macos-14): hdiutil attach DMG → locate bundled Python backend
inside *.app/Contents (NOT the Tauri WebView shell — RESEARCH Pitfall
#5: WebView hangs on headless runners) → invoke --health-check →
hdiutil detach. Falls back to *.app/Contents/Resources and hard-fails
with a directory listing if no backend binary found.
- Windows (windows-2022): msiexec /quiet install → find backend.exe
under 'C:/Program Files/OmniVoice Studio' → invoke --health-check in
background, wait, then taskkill //F //T //PID to cleanup orphaned
PyInstaller child processes on port 3900 (RESEARCH Pitfall #2).
- Linux (ubuntu-22.04): --appimage-extract (no FUSE on GH runners),
locate binary or AppRun, run under xvfb-run -a.
Bundle-only regressions (PyInstaller missing-module, Tauri sidecar
path mismatch) are invisible to ci.yml's in-process smoke matrix —
this step closes that gap before any release is published.
Verified: YAML parses; all three steps present; gating + timeout
correct; Pitfall #2/#5 mitigations preserved.
* ci(00-gates): publish SHA-256 checksums in release body + as asset (GATE-05)
- Add 'Compute SHA-256 checksums' step writing SHA256SUMS-<label>.txt
per matrix leg using native shasum/sha256sum (Git Bash on Windows).
- Add 'Append checksums to release + attach SHA256SUMS file' step using
softprops/action-gh-release@v2 with append_body: true so the hashes
land in the release body alongside tauri-action's content (not
replacing it) and the file is uploaded as a release asset for
'shasum -c SHA256SUMS-<label>.txt' verification.
- Both steps gated by 'github.event_name == push && refs/tags/v*' so
workflow_dispatch dry-runs do not attempt to attach to a non-existent
release (per CONTEXT.md L70 + RESEARCH Pitfall #7 deferral of any
aggregate cross-leg SHA256SUMS job).
- fail_on_unmatched_files: true to surface path-resolution errors loudly.
* docs(00-gates): document RC cadence + regression-fixture check in PR template (GATE-04)
* docs(setup): add HF token persistence guide for macOS/Windows/Linux (DOCS-05)
Covers two persistent paths:
- Method A — canonical ~/.cache/huggingface/token via huggingface-cli login
- Method B — shell env var (~/.zshrc / ~/.bashrc / Windows User scope)
Documents the v0.2.7 "session only" in-app behavior + notes that
Phase 1 AUTH-03 will make in-app pastes write to the canonical file.
Bundled with Phase 0 PR per user request. Strictly DOCS-05 scope —
zero code changes, no engine touches.
* spec(auth): redesign HF token resolution as 3-source cascade with fallback (AUTH-01..06)
Replaces the env_store.py file-based design with a SQLite-backed app
store + cascade resolver that checks app → env var → ~/.cache/huggingface/token
in priority order, with automatic fallback to next source on HTTP 401.
User-explicit design decision:
- App-stored token (SQLite settings table, AES-GCM encrypted) wins
- Env var ($HF_TOKEN) second
- Global huggingface-cli login file third
- All three sources visible in Settings → API Keys with "Active" badge
- Save action populates BOTH app store AND canonical HF file (defense in depth)
New requirement:
- AUTH-06 — on 401, auto-retry next source in cascade before erroring
Also: traceability count corrected (62 → 74 — undercount at planning +
INST-12 + AUTH-06 added post-planning). All 74 v1 reqs mapped.
* fix(auth): backend recognizes HF token from canonical file, not just env var
Two call sites were only checking $HF_TOKEN env var, missing the canonical
~/.cache/huggingface/token file written by `huggingface-cli login` (or the
app's future Save action):
- system.py `/system/info` `has_hf_token` flag — UI showed "No HF token"
even when `huggingface-cli login` had populated the file.
- model_manager.get_diarization_pipeline — pyannote diarization silently
returned None when only the canonical file was set. This is the bug
behind issue #35 (speaker diarization setup failure).
Both fixes use the same pattern: env var > huggingface_hub.get_token()
(which reads the canonical file). Adds a local _has_hf_token() helper
to system.py with a comment marking it as prelude to the AUTH-01..06
cascade (Phase 1 token_resolver.py will layer SQLite app-store on top).
Closes#35 sub-issue (canonical token invisible to diarization).
Cross-cuts AUTH-02 + AUTH-06 design for Phase 1.
* feat(dictation): make pill-widget mode reachable from GUI + scripts (INST-13)
The dictation widget infrastructure shipped in PR #40 but was only reachable
via the undocumented --pill CLI flag. Adds three discovery paths:
1. Tray menu: "Switch to Dictation Widget" (studio mode) — saves
launch_as_widget=true to config, relaunches with --pill, exits current.
Mirrors the existing "Open Studio" path in pill-mode tray.
2. Persistent config: AppConfig.launch_as_widget (bool, default false). Read
at startup via load_config_pre_app() (uses dirs-next, no AppHandle
required). CLI --pill still takes precedence when explicitly passed.
3. Tauri commands: get_launch_as_widget / set_launch_as_widget for the
Phase 2 Settings UI to bind a checkbox to.
4. Scripts: bun desktop-prod:pill / desktop-prod:run:pill — forward --pill
to the bundled app launch. macOS uses `open -n --args` to spawn fresh
instance with the flag.
Closes the GUI half of INST-13. Phase 2 closes the Settings UI half.
* fix(dictation): show widget unconditionally on pill-mode launch + visible Suspense fallback
Before: pill mode set up correctly but the widget window stayed hidden
until ⌘⇧Space was pressed. New users saw absolutely nothing on launch
(no main window, no dock icon, hidden widget) and assumed the app
failed. If global-shortcut Accessibility permission wasn't granted,
they had no path to discover the widget at all.
Two changes:
1. lib.rs: in pill_mode_setup, explicitly show + position + focus the
widget window after hiding main. With per-call error logging so we
can diagnose failures (and a clear error log if widget window
wasn't created at all — points at tauri.conf.json regression).
2. main-app.jsx: Suspense fallback was `null`, which combined with
widget's transparent+decorations:false config made any lazy-import
delay or failure invisible. Now renders a dark pill saying
"Loading dictation…" so even if CaptureWidget lazy-import stalls,
the user sees the window exists.
Studio mode behavior unchanged — widget stays hidden until hotkey
or tray click triggers it (existing show() call in the shortcut/
menu handlers is preserved).
* fix(dictation): create widget window programmatically; Tauri 2 silently dropped config-array creation
Root cause: declaring the widget window in tauri.conf.json's app.windows[]
silently failed in Tauri 2 — get_webview_window("widget") returned None
even though the config was syntactically valid. Probable culprit was the
transparent + decorations:false + visible:false combo, but Tauri offered
no error message either at startup or via webview_windows() enumeration.
Diagnosed by adding webview_windows() enumeration logging at setup start
(only ["main"] ever appeared) and a programmatic WebviewWindowBuilder
fallback that surfaces real Result errors.
Fix:
- tauri.conf.json: widget entry now has `create: false` to make the
config-vs-programmatic handoff explicit.
- lib.rs setup(): call WebviewWindowBuilder::new(app, "widget", ...).build()
with the exact same surface attributes the config used to declare.
- capabilities/default.json: include "widget" in windows array so the new
window inherits the same Tauri permissions as main.
- tauri.conf.json: remove the invalid `"url": "/?window=widget"` field —
WebviewUrl::App takes a path only, query strings aren't supported.
Both windows now load index.html.
- main-app.jsx: replace URL-query-based widget detection with
getCurrentWindow().label === 'widget' via @tauri-apps/api/window. This
is the Tauri 2-recommended pattern for multi-window apps and works
regardless of URL routing.
Closes the immediate UX bug behind the dictation widget being invisible.
Builds cleanly + manually verified: pill widget visible on screen at
top-center after `bun desktop-prod:pill`.
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
fix: pin setuptools>=75.0, add Linux/Russia troubleshooting (#62)
- Pin setuptools>=75.0 in pyproject.toml to ensure pkg_resources is
available on Python 3.12+ (fixes#58)
- Add Linux white-screen workaround for Fedora 44 / Ubuntu 24.04 (#56)
- Add firewall/Russia install guide for uv venv failures (#60, #57)
Closes#58
fix: lazy-load ASR during desktop startup
* fix: lazy-load ASR during desktop startup
* fix: handle ASR preload edge cases
* fix: unload ASR backend on failure
feat: import .srt subtitles to bypass Whisper (closes#52)
Closes#52. Users who already have correct, pre-synced subtitles can now
skip ASR entirely — they upload a video as normal and then hit "Import
.srt" instead of "Upload & Transcribe". The .srt cues populate the dub
segment list directly, so the rest of the pipeline (translate, dub,
export) just works.
Backend
- services/srt_parser.py: lenient SubRip parser. Tolerates BOM, CRLF,
missing index numbers, dot-vs-comma ms separator, and overlap (shifts
the later cue's start to the earlier's end rather than dropping). Skips
cues with non-positive duration or empty bodies; reports counts so the
UI can warn.
- dub_core.py: new POST /dub/import-srt/{job_id} accepts the .srt file,
parses it, clamps cues that run past the source media's duration, and
replaces job["segments"]. Tries UTF-8 with BOM first, falls back to
latin-1 for legacy Windows subs.
Frontend
- api/dub.ts: dubImportSrt helper with a typed response.
- hooks/useDubWorkflow.js: handleDubImportSrt — sets segments, flips
dubStep to 'editing', shows a toast with per-bucket counts (imported /
skipped / overlap-shifted / clamped) so the user sees what happened.
- pages/DubTab.jsx: "Import .srt" button next to "Upload & Transcribe"
once a job exists, plus a smaller "Import .srt instead" affordance in
the transcription-failure banner — the exact recovery path the
reporter asked for.
Tests
- tests/test_srt_parser.py: 12 cases covering well-formed input,
multi-line cues, dot-as-separator, BOM, CRLF, malformed cues, empty
bodies, overlap shift, overlap-becomes-zero-drop, missing indices,
empty input, and segment shape (sequential ids, speaker filler).
pytest is now 226 passed (was 214); vitest unchanged at 11.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Post-refactor cleanup: wire fingerprints, drop dead code, scope pytest (#50)
* chore: post-refactor cleanup — wire fingerprints, drop dead code, scope pytest
Follow-up to PR #49. Fixes residual issues from the App.jsx hooks split
and tightens repo hygiene so a bare `pytest` doesn't foot-gun.
Real bug
- frontend/src/hooks/useDubWorkflow.js: setLastGenFingerprints lives in
useSegmentEditing, not on the store. The previous code called
useAppStore.getState().setLastGenFingerprints?.(...) — the optional
chain swallowed the missing method, so the "N segments changed" badge
never updated after a fresh generate until a project save+reopen.
Thread setLastGenFingerprints in from App.jsx; useSegmentEditing()
now runs before useDubWorkflow() to make the setter available.
Dead code from the refactor
- frontend/src/App.jsx: drop unused `showAllProjects` useState and
`pushUndo` from the useSegmentEditing destructure.
- frontend/src/hooks/useDubWorkflow.js: drop 5 unused selectors
(preserveBg, defaultTrack, exportTracks, dualSubs, burnSubs) — the
dub-download logic that needs these lives in App.jsx, not the hook.
Repo hygiene
- backend/api/routers/setup.py.bak: delete 38 KB tracked-in-git backup.
The setup/ subpackage replacement has been in place for a while.
- pyproject.toml: add [tool.pytest.ini_options] with testpaths +
norecursedirs. Previously a bare `pytest` would INTERNALERROR walking
into research/ (1.2 GB of vendored upstream projects with their own
test_*.py files that call sys.exit at module level).
- .github/workflows/ci.yml: run backend/tests/ as a second pytest
invocation. The 23 tests there stub core.config in sys.modules to
avoid the heavy main app import chain — that pollutes import state
for other tests, so they need their own session. Previously these
tests existed in the repo but never ran on CI.
Net effect on lint: 60 → 52 problems (-8) from dead-code removal.
Test counts unchanged: pytest 214 + 23, vitest 11.
* chore: log silent catch failures that mask real bugs
CodeRabbit nitpick on #50: empty catch on the incremental-plan fallback
swallows errors. Extending the fix to the catches in this area that have
the same problem (a real failure would be invisible) while leaving the
genuinely non-actionable cleanup catches alone (EventSource.close(),
localStorage.setItem, fire-and-forget UI promises).
Logged:
- useDubWorkflow.js:97 — transcribe SSE message handler
- useDubWorkflow.js:347 — incremental-plan fallback (the CR finding)
- useDubWorkflow.js:352 — dub generate SSE event dispatch
- App.jsx:552 — exportRecord on Tauri save path
- App.jsx:580 — exportRecord on browser download path
Left silent (cleanup / non-actionable):
- useDubWorkflow.js:68, 112 — evt.close() in SSE teardown
- useDubWorkflow.js:102 — SSE error-event payload parse fallback
- App.jsx:124 — localStorage.setItem (quota / privacy mode)
- App.jsx:789, 901 — fire-and-forget UI promise tails
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Stability pass: DB leaks, App.jsx hooks refactor, desktop bootstrap (#49)
* fix: eliminate DB connection leaks, race conditions, and deprecated asyncio API
## DB Connection Leaks (P0)
- Convert 38 raw get_db() calls to db_conn() context manager across 14 router files
- Connections are now guaranteed to close even when exceptions are raised
- profiles.py create_profile: clean up orphaned audio file if DB insert fails
- profiles.py lock_profile: consolidate 3 separate conn.close() error paths
## Race Condition (P1)
- Add _dub_jobs_lock (threading.Lock) to protect _dub_jobs dict in dub_pipeline.py
- get_job/put_job now thread-safe for concurrent dub sessions
## asyncio Deprecation (P2)
- Replace 23 asyncio.get_event_loop() calls with asyncio.get_running_loop()
- Prevents DeprecationWarning on Python 3.12+ and future breakage on 3.14
## Quick Fixes
- gallery.py preview_voice: remove filesystem path from error response (P2)
- dub_pipeline.py parse_vtt_segments: remove redundant `import re` inside loop (P3)
- gallery.py _init_gallery_db: use db_conn() context manager (P2)
* refactor: extract hooks, centralize isTauri, add pytest-cov
## Frontend
- Extract useTTS hook (150 LOC) — TTS generation, streaming, audio ingestion
- Extract useProfiles hook (219 LOC) — voice profile CRUD, lock/unlock, preview
- Centralize isTauri detection: dialog.js, VoiceGallery.jsx, Settings.jsx
now import from utils/media.js instead of 4 different detection patterns
## Backend
- Add pytest-cov to dev dependencies
- Baseline coverage: 39% across backend/ (214 tests pass)
- Add .coverage to .gitignore
* feat: add Vitest + checkJs, extract useDubWorkflow + useAppData hooks
## Frontend Testing (new)
- Set up Vitest with jsdom environment + @testing-library/react
- 11 tests: utils (isTauri, formatTime, constants) + Zustand store (mode, text, dubStep, pill)
- Scripts: 'test' (vitest run), 'test:watch' (vitest), 'test:legacy' (node runner)
## App.jsx Decomposition (continued)
- Extract useDubWorkflow hook (387 LOC) — upload, ingest, transcribe SSE,
translate, generate SSE, abort, stop, cleanup
- Extract useAppData hook (181 LOC) — data loading, localStorage persistence,
WebSocket real-time updates, model-status pill management
## TypeScript checkJs
- Enable checkJs: true in tsconfig.json for IDE-level type checking
- 947 existing errors (informational, not blocking builds)
- noImplicitAny remains false to avoid blocking
* ci: add Vitest step, fix useProfiles duplicate state
## CI
- Add 'Run Vitest (frontend)' step — runs 11 unit tests
- Override --checkJs false in CI typecheck to avoid 947 pre-existing errors
- Rename legacy test step for clarity
## Hooks
- Fix useProfiles to accept loadProfiles from parent (useAppData)
instead of managing its own duplicate profiles array
* refactor: wire hooks into App.jsx — 2067 → 1129 LOC (-45%)
App.jsx now delegates to extracted hooks instead of inline logic:
- useAppData: data loading, localStorage, WebSocket, model pill
- useProfiles: voice profile CRUD, lock/unlock, preview
- useTTS: generation, streaming, audio ingestion
- useDubWorkflow: upload, transcribe SSE, translate, generate SSE
988 lines removed. All handler logic lives in focused,
independently testable hooks. Store selectors and render
JSX stay in App.jsx as the shell.
Verified: vite build clean, 11 frontend + 214 backend tests pass.
* feat: show real-time percentage on model loading pill
Backend: register hf_progress listener during _load_model_sync()
so download/weight-loading tqdm events update _loading_detail with
a progress percentage (0-99%). get_model_status() now includes a
'progress' field that the frontend polls.
Frontend: useAppData reads msQuery.data.progress and calls
setPillProgress() — the FloatingPill already renders the percentage
text and progress bar width from this value.
* fix: prevent FileNotFoundError in desktop bundle during model init
transformers >=4.52 calls _can_set_experts_implementation() and
_can_set_attn_implementation() during PreTrainedModel.__init__,
which open the class source file via open(class_file). In a Tauri
desktop bundle, module.__file__ points to a path that doesn't
exist on disk, causing:
FileNotFoundError: .../omnivoice/models/omnivoice.py
Override both classmethods on OmniVoice to return static values
without filesystem access. OmniVoice doesn't use MoE experts
(return False), but does support flex/flash attn (return True).
* fix: sync source dirs on every bootstrap, not just first run
The Tauri bootstrap previously only copied omnivoice/ and backend/
to Application Support on the first run. Subsequent app updates
kept using stale source files, preventing bug fixes from landing.
Now ensure_venv_ready() always syncs both directories from the
bundle resources before returning, even when the venv is healthy.
This fixes the FileNotFoundError crash where the old omnivoice.py
lacked the _can_set_experts_implementation override.
* ui: premium setup wizard polish
- Primary button: solid gradient fill with hover glow + lift + press
- Stepper nav: connected pills with glow ring on active step
- Welcome cards: glassmorphism with stagger-in animations, lucide icons,
left-border accent strip, hover translate
- Preflight panel: colored icon pill backgrounds, stagger-slide entrance
- Step transitions: fade+slide animation via keyed wrapper
- Footnote: shortened paths (~/ notation), Reveal in Finder button
- Recommendation banner: gradient background with accent glow
- Compact spacing throughout for denser, professional layout
* fix: kill zombie backend on clean+retry bootstrap
When clean_and_retry_bootstrap removes the project dir, any old
uvicorn process still running from the deleted paths remains alive
on port 3900. The subsequent retry_bootstrap sees the port is
healthy and attaches to the zombie instead of re-bootstrapping.
Now explicitly kill any process on the backend port after cleaning,
before calling retry_bootstrap.
* feat: integrate speaker clones into dubbing interface, sanitize system environment variables for subprocesses, and improve FFMPEG binary path resolution.
* fix: restore docker compose default + drop dead setSeed call
- deploy/docker-compose.yml: remove profiles: ["cpu"] from the default
service so `docker compose up` matches the comment on line 5. With the
profile present, no service auto-started.
- frontend/src/App.jsx: drop the setSeed call in restoreHistory. The
selector was never reintroduced after the App.jsx hooks split, and
there is no seed state in the store — seeds are generated fresh per
call in useTTS and only read from history items for display.
* fix: address CodeRabbit review — async detection, dub stream, bootstrap fail-fast
- backend/services/tts_backend.py: invert async-context detection in
_ensure_loaded. The previous code unconditionally caught its own
diagnostic RuntimeError and then called asyncio.run() inside a
running loop, masking the intended error message.
- frontend/src/hooks/useDubWorkflow.js: require a terminal `done` event
before reporting dub success. Without this, a dropped stream after
partial progress would flip the UI to `done`, refresh history, and
play the completion ping as if generation finished.
- frontend/src/hooks/useDubWorkflow.js: restore the previous step when
tasksCancel() fails. The UI was getting stuck in `stopping` forever
on cancel errors.
- frontend/src-tauri/src/bootstrap.rs: fail-fast when source sync fails
after the existing directory has already been removed. The previous
warn-and-continue path could leave the install with no backend/ or
omnivoice/ sources and defer the failure to backend startup with a
cryptic error.
- backend/api/routers/generation.py: add `from e` to the ValueError →
HTTPException re-raise (Ruff B904).
* fix: preserve % suffix in TTS generation timer
The 100ms timer in useTTS was rewriting generationTime to a plain
elapsed-seconds string, which immediately wiped the "(xx%)" download
suffix written on the next iteration of the response-body loop. The
real-time percentage was flickering on/off as a result.
Read the previous value inside the setter and reattach any existing
percent suffix.
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
fix: resolve open issues — Discord link, Docker crash, IndexTTS compat, engine tooltips (#47)
* fix: resolve 7 open GitHub issues (#46#43#42#45#44#35#4)
#46 — Discord invite expired:
- Replace discord.gg/aRRdVj3de7 with discord.gg/bzQavDfVV9 across
README, CONTRIBUTING, EnterprisePage, LogsFooter
#43 — Docker image crashes with 'No module named core':
- Add PYTHONPATH=/app/backend to Dockerfile so bare imports resolve
- Add sys.path safety net in backend/main.py (belt-and-suspenders)
#42 — IndexTTS not compatible (transformers version conflict):
- Catch ImportError + generic Exception in IndexTTS2Backend.is_available()
- Return actionable error explaining transformers<5 vs >=5.3 conflict
- Update install docs: recommend 'uv pip install -e .' not 'uv sync --all-extras'
#45 — Improve pip install tooltips:
- Add install_hint field to list_backends() API response
- Show hints as tooltips on engine rows in Settings > Engines
- Add models-row__hint CSS with hover reveal
#44, #35, #4 — Response-only issues (need GitHub comments)
* test: add 20 unit tests for issue batch fixes (#46#43#42#45)
Coverage:
- Discord link sweep: parametrized per-file + repo-wide glob
- Docker fix: sys.path insertion in main.py, PYTHONPATH in Dockerfile
- IndexTTS: is_available() tuple shape, conflict detection mock, docstring
- install_hint: presence, non-empty, registry coverage, backward compat
- Regression: minimum engine count, all backends return (bool, str)
* fix: address CodeRabbit review — voxcpm package name, bootstrap test isolation
- Fix _INSTALL_HINTS: 'pip install voxcpm2' → 'pip install voxcpm' (correct PyPI name)
- Replace test_core_config_importable with test_main_py_bootstrap_adds_backend_dir
that validates main.py's preamble directly instead of relying on conftest.py
- Add test_voxcpm_install_hint_uses_correct_package_name regression guard
* fix: align install hints with backend reality (MOSS not on PyPI, VoxCPM supports CPU/MPS)
- MOSS-TTS-Nano: not on PyPI, must install from GitHub repo
- VoxCPM2: CPU/MPS supported, CUDA recommended (not required)
feat: Scalar API docs, community health files, Quickstart cards (#41)
* feat: Scalar API docs, community health files, Quickstart cards, GHCR Docker
Backend:
- Replace Swagger UI with Scalar at /docs (scalar-fastapi)
- Add OpenAI-compatible /v1/audio endpoints (openai_compat router)
- Add TTS streaming endpoint (tts_stream router)
- Add voice marketplace router (marketplace)
- Update TTS backend registry
Frontend:
- Refine CaptureWidget, WaveformTimeline, App layout
- CSS polish and index.css updates
Community health:
- SECURITY.md — vulnerability reporting policy
- CODE_OF_CONDUCT.md — Contributor Covenant v2.1
- .github/FUNDING.yml — GitHub Sponsors
- .github/ISSUE_TEMPLATE/ — bug report + feature request
- .github/pull_request_template.md — PR checklist
README:
- Quickstart redesigned as 3-column progressive cards
- Docker section updated with GHCR pull instructions
- API Docs row added to service table
Infra:
- scalar-fastapi added to pyproject.toml + uv.lock
- research/ added to .gitignore
* refactor: clean up documentation and logging while enhancing desktop packaging dependencies and capture UI performance.
* fix: address CodeRabbit review — streaming, escaping, thresholds
Backend:
- marketplace: stream zip entries via ZipFile.open()/copyfileobj, add 100MB
upload cap, fix raise-from exception chaining (OOM prevention)
- openai_compat: _encode_audio returns actual file ext so Content-Disposition
matches real format; forward non-profile voices when DB row not found
- tts_stream: send 'start' frame after generation so sample_rate is real;
forward non-profile voices on DB miss
- capture_ws: split MIN_BUFFER_BYTES into separate partial/final thresholds
so short utterances (<2s) still get transcribed
Frontend (Tauri):
- lib.rs: tray 'dictate' now toggles start/stop based on widget visibility
- commands.rs: XML-escape exe path in LaunchAgent plist, shell-quote in
.desktop Exec line to prevent injection from special-char paths
- CaptureWidget.css: fix Stylelint violations (empty lines, font-family quotes)
Merge feat/frameless-dictation-widget: v0.2.7 — frameless dictation widget, GHCR Docker
# Conflicts:
# README.md
feat: add GHCR Docker workflow, update README with container registry instructions
- New .github/workflows/docker.yml publishes images to ghcr.io on tag push
- README Docker section now leads with 'docker pull' from GHCR
- docker-compose.yml defaults to GHCR image with build-from-source fallback
- Dockerfile: copy README.md for hatchling metadata resolution
feat: implement frameless OS-level floating dictation widget (#40)
* feat: implement frameless OS-level floating dictation widget
- Refactor CaptureButton into standalone CaptureWidget
- Add secondary transparent Tauri window configuration
- Map global hotkey to show/hide widget instead of focusing main app
- Implement auto-hide post-paste
- Add social preview image
* docs: up the game with enhanced README
- Use the high-quality social preview image as the hero image
- Bump download release links to v0.2.7
- Highlight the new Frameless Dictation Widget feature
* docs: complete README overhaul for maximum virality
- Add Highlights section with 2-column feature grid
- Move Quickstart to top with one-command install
- Collapse technical details into expandable sections
- Add 'Up Next' roadmap with concrete upcoming features
- Add star call-to-action banner
- Tighten navigation links and section hierarchy
* docs: add beta warning banner
* docs: add star request to beta banner
* docs: rewrite README with cognitive hooks, remove redundant CTAs
- Remove 2 premature star asks (beta banner + highlights)
- Rewrite highlights with loss-aversion framing
- Keep single earned CTA at the very bottom
- Use action-oriented headings that describe outcomes
* docs: rename section to 'Why OmniVoice Studio?'
* docs: rename 'What you get' to 'Features'
* docs: concise scannable features, remove duplicate section
- Each feature is one punchy emoji-led line
- No verbose paragraphs, no redundant collapsibles
- Removed duplicate Features section from merge
* docs: 3-column feature card grid for visual impact
Replaces flat bullet list with 4x3 HTML table grid.
Each feature gets its own visual cell with emoji header,
bold keywords, and 2-line description. Pops on dark mode.
* docs: fix feature grid vertical alignment
* chore: bump version to 0.2.7, add changelog entry
* fix: apply CodeRabbit auto-fixes
Fixed 1 file(s) based on 1 unresolved review comment.
Co-authored-by: CodeRabbit <noreply@coderabbit.ai>
---------
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: CodeRabbit <noreply@coderabbit.ai>
chore: bump version to 0.2.7, add changelog entry
docs: fix feature grid vertical alignment
docs: 3-column feature card grid for visual impact
Replaces flat bullet list with 4x3 HTML table grid.
Each feature gets its own visual cell with emoji header,
bold keywords, and 2-line description. Pops on dark mode.
docs: concise scannable features, remove duplicate section
- Each feature is one punchy emoji-led line
- No verbose paragraphs, no redundant collapsibles
- Removed duplicate Features section from merge
docs: rename 'What you get' to 'Features'
docs: rename section to 'Why OmniVoice Studio?'
docs: rewrite README with cognitive hooks, remove redundant CTAs
- Remove 2 premature star asks (beta banner + highlights)
- Rewrite highlights with loss-aversion framing
- Keep single earned CTA at the very bottom
- Use action-oriented headings that describe outcomes
docs: add star request to beta banner
docs: add beta warning banner
docs: complete README overhaul for maximum virality
- Add Highlights section with 2-column feature grid
- Move Quickstart to top with one-command install
- Collapse technical details into expandable sections
- Add 'Up Next' roadmap with concrete upcoming features
- Add star call-to-action banner
- Tighten navigation links and section hierarchy
docs: up the game with enhanced README
- Use the high-quality social preview image as the hero image
- Bump download release links to v0.2.7
- Highlight the new Frameless Dictation Widget feature
feat: implement frameless OS-level floating dictation widget
- Refactor CaptureButton into standalone CaptureWidget
- Add secondary transparent Tauri window configuration
- Map global hotkey to show/hide widget instead of focusing main app
- Implement auto-hide post-paste
- Add social preview image
chore: bump version to 0.2.7
feat: add CosyVoice 3 TTS backend, engine platform matrix, CONTRIBUTING.md (#39)
* feat: add CosyVoice 3 TTS backend, engine platform matrix, CONTRIBUTING.md
- Add CosyVoiceBackend adapter to tts_backend.py (9 langs + 18 dialects,
zero-shot voice cloning, instruct mode, Apache-2.0)
- Fix VoxCPM2Backend.is_available() — remove incorrect hard CUDA gate;
VoxCPM2 supports MPS (Apple Silicon) and CPU fallback
- Add unified TTS Engines table to README with features + platform compat
- Update FAQ to reflect current 6-engine Plugin SDK (was 'not yet')
- Add CONTRIBUTING.md with dev setup, PR workflow, TTS plugin guide,
code style conventions, and testing commands
- Move Contributing section above FAQ in README
* fix(pr): address CodeRabbit review feedback
- README: Update 'Plugin SDK' to 'built-in backend registry' for clarity
- tts_backend.py: Preserve full language codes for CosyVoice cross-lingual lookup before fallback
refactor: codebase cleanup & root folder reorganization (#38)
refactor: codebase cleanup & root folder reorganization
feat: ASR model preload at startup — eliminate 25s first-dictation cold start
feat: ASR model preload at startup — eliminate 25s first-dictation cold start
Fix transcription stream drops, IndexError, BrokenPipeError, FK constraint, and Tauri CSP
Fix transcription stream drops and Tauri CSP
feat: enhance ASR performance and reliability with binary bundling, model warmup, sub-stage progress tracking, and optimized polling.
Fix transcription stream drops, IndexError, and Tauri CSP
feat: enhance ASR performance and reliability with binary bundling, model warmup, sub-stage progress tracking, and optimized polling.
refactor: bundle uv binary per-platform as Tauri sidecar and remove redundant ffmpeg bootstrap download
fix(0.2.6): WS first-chunk drop, mic permissions, release-body from CHANGELOG
WS dictation pipeline was producing exit-183 from ffmpeg on every
partial because MediaRecorder.start(250) ran before the WebSocket
handshake finished — the first chunk (WebM EBML header) was queued
only into chunksRef and never pushed to the WS, so concatenated
chunks 1..N decoded as malformed WebM. Fix:
- Construct the WebSocket BEFORE starting the recorder so wsRef is
set when the first ondataavailable fires.
- ondataavailable now queues every chunk through wsPendingRef when
the socket isn't OPEN; ws.onopen drains the queue.
- ws.onmessage('error'): fire HTTP fallback immediately instead of
waiting the full fallback-timeout window.
- ws.onclose without prior `final`: same — kick the HTTP path now
if the recorder has already stopped.
Mic permissions:
- New frontend/src-tauri/Info.plist with NSMicrophoneUsageDescription
+ NSCameraUsageDescription. Tauri 2 auto-merges the file at bundle
time (path is the same dir as tauri.conf.json — schema documents
this fallback). Without it, getUserMedia silently fails on macOS
10.14+ TCC.
- Mic-denial toast now includes platform-specific recovery (Settings
paths for macOS/Windows, audio-group check for Linux).
CI / release notes:
- release.yml extracts the matching `## [X.Y.Z]` section from
CHANGELOG.md and feeds it into tauri-action's releaseBody, so
v0.2.6+ tag pushes produce real release notes instead of the
placeholder "Auto-generated release. See commit log for changes."
chore(license): switch Studio to FSL-1.1-ALv2; commercial pricing TBD
- LICENSE replaced with the canonical Functional Source License,
Version 1.1, ALv2 Future License (auto-converts to Apache 2.0 two
years after each release).
- Scope clarified: Studio (frontend + backend + tauri shell + scripts)
is FSL. Bundled `omnivoice/` Python TTS model package by Han Zhu
stays Apache-2.0 — not relicensed here.
- README license section + license badge updated to reflect FSL +
future-Apache; replaced "30-day free evaluation" copy with the FSL
Permitted Purposes wording.
- Enterprise page: drop hard-coded pricing tiers (Startup/Business/
Enterprise) since pricing is still being finalized. Replaced with a
"Pricing tiers coming soon — request a quote" panel. FAQ rewritten
around FSL semantics (internal use is permitted, source converts to
Apache 2.0 in 2yr). Drop now-unused TIERS const + TierCard
component + .ent-tier* CSS.
- CHANGELOG entry under 0.2.6 records the relicense.
feat(0.2.6): tray-aware shell, hotkey customization, WS dictation dedupe
Tray + lifecycle:
- tauri-plugin-single-instance — second launch focuses existing window
instead of racing for port 3900.
- Window close hides instead of destroying; backend shutdown moved to
RunEvent::ExitRequested so only the tray "Quit" item (or Cmd+Q on macOS)
actually exits.
- Tray icon flips to red-dot variant during dictation recording.
Hotkey customization:
- Settings → Capture tab. Records any modifier+key combo, persists to
app config, re-registers on launch.
- set_dictation_shortcut rolls back to the previous binding on register
failure so a bad combo never leaves the user with no shortcut.
Dictation latency / correctness:
- WS-final treated as source of truth; HTTP POST /transcribe runs only as
fallback (WS error / timeout / no-WS path). Audio transcribed once
instead of twice. Server accepts an "EOF" text frame (or empty binary
frame) so the socket stays open for `final` to be delivered before the
client closes.
- MediaRecorder chunks queued during the WS handshake are drained in
ws.onopen — the server's final transcript no longer drops the first
~250 ms of audio.
- Fallback timeout scales with recording length (max(15s, recordedMs+10s))
so long-form dictations don't trip duplicate transcription.
Donate page:
- Drop Patreon, Bitcoin / Ethereum / Solana cards. Drop qrcode.react.
- Move "Commercial License" CTA from page bottom to top-right header bar.
Docker hygiene:
- docker-compose binds 127.0.0.1 by default. README documents the LAN
exposure trade-off + recommends a reverse proxy with auth.
CI:
- New cross-platform `tauri-cross-platform` job runs `cargo check` against
the Tauri shell on macOS / Windows / Linux per PR. Catches platform
cfg-gate regressions without paying the full ~15min/platform bundle
cost (full bundling stays in release.yml on tag push).
Tests:
- tests/test_capture_ws.py (3 cases) covers EOF text-frame, empty-binary
EOF, and legacy disconnect-finalize paths.
Includes the user's previously-staged 0.2.5 polish: cross-platform
desktop-prod.sh, Dockerfile base-image fix, bun.lock churn.
merge fix/preflight-and-progress into main
fix: resolving heartbeat, fmtBytes(0), smarter build error handling
- Backend emits 'resolving' heartbeat every 2s during HF metadata
resolution so UI shows 'Resolving repo metadata...' instead of
being stuck on 'Connecting to HuggingFace…' indefinitely
- fmtBytes(0) now returns '0 B' instead of '—'
- desktop-prod.sh only tolerates signing errors, surfaces real
build failures with exit code
- Handle install_retry phase in frontend with attempt number
fix: desktop-prod now builds fresh .app bundle, not stale cached one
The --no-bundle flag caused the script to build only the raw binary
while launching the OLD stale .app bundle from a previous build.
Now builds the full bundle (tolerating the signing error which is
non-fatal) and deletes the old bundle first to prevent stale code.
fix: disk space check walks up to existing parent when cache dir wiped
shutil.disk_usage() throws on non-existent paths, causing the
preflight to report 0.0 GB free after a fresh wipe. Now resolves
up to the nearest existing ancestor directory so it probes the
actual volume free space correctly.
fix: download progress shows realtime speed/ETA at every stage
- 'Connecting to HuggingFace…' when no file events yet
- 'Resolving N files…' when tqdm init fired but total unknown
- Speed shows immediately from backend tqdm rate (no 2s warmup)
- '0 B / …' instead of '— / ?' for early progress
- 1s tick timer forces re-render so speed/ETA updates smoothly
- ETA shortened to ~3m instead of ~3m left for compactness
fix: desktop-prod now builds fresh .app bundle, not stale cached one
The --no-bundle flag caused the script to build only the raw binary
while launching the OLD stale .app bundle from a previous build.
Now builds the full bundle (tolerating the signing error which is
non-fatal) and deletes the old bundle first to prevent stale code.
fix: disk space check walks up to existing parent when cache dir wiped
shutil.disk_usage() throws on non-existent paths, causing the
preflight to report 0.0 GB free after a fresh wipe. Now resolves
up to the nearest existing ancestor directory so it probes the
actual volume free space correctly.
fix: download progress shows realtime speed/ETA at every stage
- 'Connecting to HuggingFace…' when no file events yet
- 'Resolving N files…' when tqdm init fired but total unknown
- Speed shows immediately from backend tqdm rate (no 2s warmup)
- '0 B / …' instead of '— / ?' for early progress
- 1s tick timer forces re-render so speed/ETA updates smoothly
- ETA shortened to ~3m instead of ~3m left for compactness
feat: realtime download speed, retry buttons, recheck top-right
- tqdm hook emits progress every 0.3s with backend rate (bytes/sec)
- Frontend uses backend rate for instant speed display, no 2s warmup
- Shows 'Connecting to HuggingFace…' during connect phase
- Shows 'measuring speed…' before rate is available
- Re-check button moved to top-right header in system preflight
- Retry + Clean & Retry buttons on failed splash screen
- Smart error hints (missing README, network timeout, port in use)
- README.md + omnivoice/ source package copied during bootstrap
- desktop-prod.sh wipes HF cache + all app data for fresh testing
feat: region selector (Global/China) on splash + settings (#33)
- Persistent config.json in app_data stores region preference
- China region auto-sets HF_ENDPOINT=https://hf-mirror.com
- Segmented toggle on bootstrap splash (🌐 Global / 🇨🇳 China)
- get_region / set_region Tauri commands for frontend access
- System HF_ENDPOINT env var still takes priority over config
Closes#33
fix: pass HF_ENDPOINT to backend for Chinese mirror support (#33)
Users in China can now set HF_ENDPOINT=https://hf-mirror.com as a
system env var before launching OmniVoice Studio. The Tauri shell
passes it through to the Python backend.
fix: buffer bootstrap logs + backfill on webview mount
Root cause of 'No log output captured': bootstrap events fire before
the webview loads, so the React listener misses all of them.
Fix:
- Add log buffer (Vec<LogPayload>) to BootstrapState on Rust side
- emit_log() writes to both the event stream AND the buffer
- New 'get_bootstrap_logs' Tauri command returns all buffered lines
- Frontend calls get_bootstrap_logs on mount to backfill missed logs
- Deduplication prevents double-showing lines caught by both paths
- Also pipe backend stdout (not just stderr) to splash panel
fix: pipe backend stdout to splash + complete log visibility
- Pipe both stdout AND stderr from backend process to splash logs
(previously stdout went to file/null, so 'No module named X' was
invisible to users)
- All bootstrap stages stream logs to the splash panel
- Log panel always open by default with copy button
fix: self-healing venv + version on splash + logs always visible
Critical Windows fix:
- Verify uvicorn is importable before trusting cached venv
- If venv exists but deps are missing, auto-repair via uv sync
- Fixes: users stuck in 'No module named uvicorn' loop
Splash improvements:
- Show version (v0.2.5) next to title on loading screen
- Logs panel open by default — users see live output immediately
- Copy button inline with toggle for easy bug reporting
- __APP_VERSION__ injected via Vite define from package.json
ui: show bootstrap logs by default, add copy button inline
Logs are now always visible during the splash screen so users
can see what's happening (Python imports, model loading, etc).
Copy button sits inline next to the toggle and line count.
Log panel height increased to 280px for more context.
fix(windows): fallback to uv sync without --frozen when lockfile missing
Root cause: uv.lock wasn't bundled in the Windows MSI, so
'uv sync --frozen' silently produced a venv without uvicorn.
Changes:
- If uv.lock is missing after copy, run 'uv sync' without --frozen
so uv resolves deps from pyproject.toml (slower but always works)
- Log a warning instead of silently ignoring lockfile copy failures
- Auto-expand bootstrap logs on failure so users see full context
- Add '📋 Copy logs' button for easy bug reporting
- user-select: text on log panel so text is selectable
chore: bump version to v0.2.5
feat: dictation maturity + batch TTS pipeline + tests (#32)
Global Hotkey:
- Register ⌘+⇧+Space system-wide via tauri-plugin-global-shortcut
- Shows/focuses window and emits tray-dictate event from any app
Auto-Paste:
- enigo crate simulates ⌘V/Ctrl+V after transcription
- Text auto-pastes into whatever app was active before dictation
Streaming ASR:
- WebSocket endpoint /ws/transcribe for live partial transcription
- 2s buffer interval, configurable via OMNIVOICE_STREAM_INTERVAL
- CaptureButton streams audio chunks, shows italic partial text
- Falls back to HTTP POST if WebSocket unavailable
Batch TTS Pipeline:
- Replace stub worker with full pipeline:
extract → transcribe → translate → generate → mix → export
- Per-job progress tracking (stage, percent, current_lang, segment)
- GoogleTranslator integration via deep_translator
- Download endpoint GET /batch/download/{id}/{lang}
- BatchQueue UI rewritten: progress bars, cancel/delete, downloads
- Type-safe API client (api/batch.ts)
Tests:
- 23 tests for batch endpoints + streaming ASR helpers
- Lightweight fixtures that stub GPU deps
UX (earlier sessions):
- Dual-mode ASR (Turbo MLX + WhisperX Accurate)
- Enhanced download progress (speed, ETA, bytes)
- Status bar black flash fix
- Cold-start model preloading
- Full accessibility audit (ARIA, focus-visible)
- Compact UI layout improvements
- README updated with new features
feat: flush dropdown, credentials tab, whisper model selector, reactive transcriptions
Flush Dropdown:
- Flush button now opens a dropdown showing all loaded models
- Each model shows device, VRAM usage, and individual Unload button
- Backend endpoints: GET /model/loaded, POST /model/unload/{id}
- Bottom actions: Flush caches, Unload all + flush
Credentials Tab:
- New Settings > Credentials tab with HF_TOKEN and TRANSLATE_API_KEY
- Session-scoped via POST /system/set-env (no ElevenLabs — we ARE the alternative)
- Shows 'Set' / 'Not set' badge for HF token
Notification Panel:
- Moved from header dropdown to footer status bar (4th tab: Notifications)
- Bell icon in header dispatches event to open footer tab
- Click notification → navigates to relevant page (e.g., Settings for HF token)
- No inline inputs — notifications are purely informational + navigational
Whisper Model Selector:
- Capture widget now has quality preset picker: tiny → large-v3
- Persisted in localStorage; sent to backend as 'model' form field
- Backend passes chosen model to ASR backend
Reactive Transcriptions:
- Custom window event (omni:transcription-added) bridges CaptureButton → TranscriptionsPage
- Page updates in realtime when new dictation completes
feat: notification panel, HF token setter, transcriptions page
Notification Panel:
- Bell icon in header with badge count (red/amber by severity)
- Polls GET /system/notifications every 30s
- Surfaces: missing HF_TOKEN, missing ffmpeg, low disk, CPU-only mode
- Inline HF_TOKEN input — set token without leaving the app
- Dismiss individual or all notifications (persisted in localStorage)
- Click-outside to close, slide-in animation
Backend:
- GET /system/notifications — returns actionable notifications
- POST /system/set-env — safely set HF_TOKEN, TRANSLATE_API_KEY,
ELEVENLABS_API_KEY at runtime (allowlisted keys only)
Transcriptions Page:
- New nav rail item (Transcripts) with FileText icon
- Searchable list + detail split-pane layout
- Stores all dictation results in localStorage (max 200)
- Copy, delete, export all as .txt
- Shows timestamps, language, duration, and segment breakdown
- CaptureButton auto-saves to Transcriptions on success
Header:
- Added gallery + transcriptions to VIEW_META breadcrumbs
fix: capture transcribe() — remove unsupported language kwarg
WhisperXBackend.transcribe() signature is (audio_path, *, word_timestamps)
with no language parameter. Language is auto-detected by Whisper.
fix: CaptureButton 404 — use shared API base URL
CaptureButton.jsx was using VITE_API_BACKEND_URL (undefined, defaults
to empty string) so /transcribe was a relative path hitting the Vite
dev server or Tauri webview instead of the backend on :3900.
Fix: import API from api/client.ts (same as all other API calls).
feat: batched TTS, cold start, audiobook editor, context-aware pipeline
Batched TTS:
- Profile-grouped segment processing for cache locality
- CPU/GPU pipelining (ref audio load overlaps TTS inference)
- ~25-40% throughput improvement over sequential loop
- SegmentSpec container + generate_segments_batched() async API
Cold Start Optimization:
- Deferred torch + OmniVoice imports in model_manager.py
- Server starts in ~0.03s (was ~4s) — health/status respond immediately
- _lazy_torch() / _lazy_omnivoice() wrappers with singleton caching
- All downstream refs updated (idle_worker, free_vram, offload, restore)
Stories / Audiobook Editor:
- StoriesEditor component — multi-track with per-character voice assignment
- 7 character slots (Narrator + 6 characters) with color-coded dots
- Inline TTS preview per line via /dub/preview-segment endpoint
- Add/remove/reorder tracks, Generate All workflow
- Character stats footer (lines, characters, est. duration)
Context-Aware Pipeline:
- Video frame extraction via ffmpeg at segment midpoints
- Frame analysis: brightness, mood, complexity via PIL image stats
- Per-segment and global context (VideoContext container)
- get_segment_context() → natural-language TTS instruct hints
e.g. 'Speak with vibrant energy, dark atmosphere, fast-paced scene'
- POST /tools/video-context/{job_id} API endpoint
Roadmap: ALL items completed ✅
feat: plugin SDK, GPU sandbox, waveform v2, accessibility
Plugin SDK:
- Abstract TTSPlugin base class with register/discover pattern
- Built-in plugins: ElevenLabs (cloud) + Bark (local)
- Auto-discovery from backend/plugins/ directory
- GET /tools/plugins API for frontend engine picker
GPU Crash Sandbox:
- Subprocess isolation for GPU-intensive operations
- CUDA OOM / driver crash kills worker, not the server
- Async wrapper with configurable timeout
- Platform availability check
Waveform Timeline v2:
- Added MinimapPlugin (20px overview bar)
- Added TimelinePlugin (time labels)
- Keyboard shortcuts: J/K/L (rewind/play/forward), Space
- Full ARIA labels on all controls
- role=region, role=toolbar for assistive tech
Accessibility:
- ARIA labels on waveform controls, theme picker, capture button
- role=radiogroup on theme dots
- aria-checked state on theme selection
- Keyboard hint icon (J/K/L) in waveform toolbar
LLM Translation: already implemented (OpenAI provider in dub_translate)
Roadmap: cleaned up, only batched TTS + vision items remain
feat: theme system — 6 color themes + dot picker
Themes:
- Gruvbox (default), Midnight Blue, Nord, Solarized Dark,
Rosé Pine, Catppuccin Mocha
- CSS custom properties overridden via data-theme attribute
- Persisted in Zustand store, hydrated on boot
- Dot picker in the footer bar (next to UI scale toggle)
- All themes are dark; light scaffold ready for community PRs
Roadmap: removed code signing (skipped), cleaned up shipped section
feat: dictation capture, casting view, real-time dub preview
Voice Capture (Dictation):
- CaptureButton FAB with ⌘+⇧+Space global shortcut
- Records mic → POST /transcribe → displays text → copy to clipboard
- Backend capture.py: standalone ASR endpoint (no dub job needed)
- Animated waveform bars, glassmorphic panel, pulse recording indicator
Speaker Casting:
- CastingView component — visual speaker-to-voice assignment grid
- Auto-cast from video speaker clones or manually pick saved profiles
- Dropdown picker with personality tags, preview button
- Registered in CastingView.css with premium glassmorphism
Real-time Dub Preview:
- POST /dub/preview-segment/{job_id} — 8-step fast TTS for single segment
- No disk write, no watermark, no mix — just instant audio feedback
- Returns WAV bytes directly for immediate playback
feat: MCP server + audio effects chain
MCP Server:
- Full Model Context Protocol server (backend/mcp_server.py)
- 5 tools: generate_speech, list_voices, list_personalities,
list_languages, check_health
- 2 resources: voice://{id}, history://recent
- stdio + SSE transports for Claude Desktop / Cursor / remote agents
- Example config: mcp.json
Audio Effects Chain:
- 6 presets: Broadcast, Cinematic, Podcast, Warm, Bright, Raw
- Configurable pipeline via apply_effects_chain() with pedalboard
- Effects: highpass, lowpass, compressor, reverb, noise_gate, eq, limiter
- GET /tools/effects API for frontend preset picker
- Graceful fallback when pedalboard isn't installed
docs: update roadmap — mark shipped items
feat: docker DX — /health endpoint, CPU/GPU profiles, fixed port
- Add /health endpoint returning {'status':'ok','device':'...'} for
Docker health checks and monitoring
- Rewrite docker-compose.yml: CPU default + GPU via --profile flag so
CPU-only machines don't get nvidia driver errors
- Named volumes, proper health checks with start_period for first-run
model downloads
- Fix README Docker quickstart: wrong port (8000→3900), add GPU
profile instructions
feat: onboarding demo profile, voice personalities, i18n framework
- Onboarding: seed 'OmniVoice Demo' profile on first run (empty DB)
with bundled reference audio so Launchpad isn't empty
- Voice Personalities: 6 built-in presets (Narrator, Casual, News
Anchor, Storyteller, Corporate, Energetic) with instruct text
auto-fill in Voice Design mode
- i18n: react-i18next with English locale, browser language detection,
Launchpad & CloneDesignTab strings extracted to en.json
- DB migration v4: personality TEXT column on voice_profiles
- New API: GET /personalities returns preset list
- CSS: demo callout banner + personality picker strip
docs: update roadmap — move completed items to Shipped, add VoiceBox-inspired features
docs: clean up desktop install section with collapsible platform notes
docs: add macOS xattr fix, Windows/Linux install notes, update download links to v0.2.4
fix: cross-platform backend log path + Windows startup hardening (#31)
Three fixes for the Windows MSI first-launch failure:
1. **backend_log_path() was macOS-only** — used $HOME + Library/Logs
which doesn't exist on Windows. Now uses %LOCALAPPDATA% on Windows,
~/Library/Logs on macOS, and XDG_STATE_HOME on Linux. Without this,
stdout/stderr went to Stdio::null() and all backend crash output was
silently lost.
2. **Add TORCHDYNAMO_DISABLE=1 on Windows** — prevents PyTorch from
trying to download Triton (which has no Windows support), avoiding a
hang during first torch.compile() call (#26 workaround).
3. **Increase health timeout from 180s to 300s** — first-run PyTorch
import on Windows can take 120+ seconds for CUDA kernel JIT, plus
uv sync + torch + model loading. 3 min wasn't enough.
Fixes#30
fix: resolve Tauri _up_ resource paths for Windows/Linux MSI bootstrap (#29)
Tauri v2 replaces `../` with `_up_/` when bundling resources into MSI
and deb installers. The `../../pyproject.toml` config path becomes
`$RESOURCE/_up_/_up_/pyproject.toml` at runtime, but the Rust bootstrap
only checked the flat `$RESOURCE/pyproject.toml` path.
This worked on macOS (.app bundles flatten into Contents/Resources/) but
failed on Windows MSI and Linux deb with:
Missing bootstrap resources (pyproject=..., backend=...)
Fix: try both the flat path and the _up_/_up_ prefixed path, with
diagnostic logging if neither is found.
Fixes#28
chore: bump version to v0.2.4
feat: real-time WebSocket event bus + sidebar reactivity fixes (#27)
## Core Infrastructure
- Add backend event bus (core/event_bus.py) — in-memory pub/sub with
emit(), subscribe(), unsubscribe()
- Add WebSocket endpoint /ws/events (api/routers/events.py) with 25s
keepalive pings and auto-cleanup on disconnect
- Add frontend hook useRealtimeEvents.js — single WS connection with
exponential backoff reconnect (2s→60s)
## Backend Event Integration
- projects.py: emit on create/update/delete
- profiles.py: emit on create/update/lock/unlock/delete
- dub_core.py: emit on clear/delete history
- dub_pipeline.py: emit on save_job (every pipeline write)
- exports.py: emit on export/record
- generation.py: emit on generate/clear/delete
- gallery.py: emit on save-as-profile/to-profile
## Frontend Improvements
- Replace 45s polling interval with instant WS-based invalidation
- Fix critical bug: apiModelStatus was undefined, causing loadAll()
to loop forever — sidebar data never loaded on startup
- Add websockets to main deps (was optional, got removed by uv sync)
- Reduce model/status polling from 5s to 10s, disable background
polling for logs
- Add ReadinessChecklist and FloatingPill components
- Default UI scale changed from S (1.0) to M (1.3)
## Dependencies
- Add websockets>=16.0 to main dependencies for uvicorn WS support
Closes#3 (native desktop app exists via Tauri)
Closes#5 (Dockerfile already uses root bun.lock)
Resolves#26 (Triton workaround documented)
refactor: redesign DubTab layout using flexbox, update column widths in DubSegmentTable, and add Linux webkit2gtk dependency.
fix(ui): Optimise segment table column distribution and fix flex stretch layout bug
fix: Fix frontend typecheck errors and raise TTS VRAM offload threshold to prevent CUDA OOM
fix(ui): Fix segment row layout collapse, memory bugs, enterprise page, and UI enhancements
style: extract 22 inline styles from CheckpointBanner, DirectionDialog, SetupWizard, App, CompareModal, AudioTrimmer
- CheckpointBanner: 7→1 (dynamic accent border-left stays)
- DirectionDialog: 5→0
- SetupWizard: 9→3 (dynamic fix-text color stays)
- App.jsx: 6→3 (dynamic zoom stays)
- CompareModal: 2→1 (dynamic accent color stays)
- AudioTrimmer: 1→0
New Misc.css shared file for remaining small-component classes.
Total inline style count: 65→43 (cumulative 127→43, 66% reduction)
All remaining 43 are genuinely dynamic (CSS vars, computed colors,
animation delays, column widths, progress bars).
style: extract 17 inline styles from WaveformTimeline and ErrorBoundary into CSS
- WaveformTimeline: 10→0 inline styles (layout, loading, overlay, error)
- ErrorBoundary: 7→0 inline styles (wrapper, card, title, trace, retry)
- Shared CSS file for both components (WaveformErrorBoundary.css)
Total inline style count: 82→65 (cumulative 127→65, 49% reduction)
chore: update license copyright name and contact email
feat: setup wizard, donate page, CI fixes, performance optimizations, and style extraction
- Implement donate page and migrate API fetching to react-query hooks
- Add setup wizard for batch job management and voice clip editing
- Refactor setup router into package (wizard, models, download sub-modules)
- Fix 9 CI test failures from setup router refactor
- Fix cross-device link error in prefs.py atomic writes
- Fix event loop mismatch in export test fixtures
- Modernize README with architecture diagram and 13 app screenshots
- Defer per-segment disk writes in dub_generate for ~6s faster dubs
- Extract 45 inline styles from Launchpad, KeyboardCheatsheet, DubSegmentRow
- Add playwright dev dep and screenshot capture script
feat: implement Voice Gallery feature with backend routing, API client, and frontend navigation integration
feat: add live bootstrap progress bars and log inspection to splash screen
feat: implement structured progress tracking for model downloads and add local environment variable loading support.
feat(bootstrap): splash UI with live progress during first-run setup (#25)
v0.2.2 users upgrading from a PyInstaller build saw "Failed to load
engines: Load failed" because the app window opened before the
first-run `uv sync` (5-10 min) could finish populating the venv. The
webview just hung on a blank state.
Wire the setup through properly:
Rust (src-tauri/src/lib.rs):
- New `BootstrapStage` enum: checking → downloading_uv →
creating_venv → installing_deps → starting_backend → ready (or
failed { message }). `#[serde(tag = "stage")]` so it serialises as
a tagged union the frontend can switch on.
- `BootstrapState` exposed via `bootstrap_status` Tauri command so
React can poll progress.
- `setup()` no longer blocks on `ensure_venv_ready`. Instead spawns a
background thread that walks the bootstrap, writes stage updates to
the mutex, then waits up to 60 s for the backend port to answer and
flips stage to `ready`.
- `ensure_venv_ready` + `spawn_backend` take the progress mutex
(Option<&Arc<Mutex<…>>>) and set the right stage at each step.
They now take AppHandle<R> instead of &App<R> so the background
thread can hold them.
React (frontend/src/components/BootstrapSplash.{jsx,css}):
- Self-contained splash component + `useBootstrapStage()` hook that
polls the Rust command every 1 s, short-circuits to 'ready' in the
Vite dev server / non-Tauri contexts.
- Renders a progress bar + step list keyed to BootstrapStage. On
Failed, shows the Rust-side message.
App.jsx:
- Calls `useBootstrapStage()`, blocks the main UI render until stage
=== 'ready'.
Also bumps pyproject/package/cargo/tauri.conf versions 0.2.2 → 0.2.3.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
chore: bump version to 0.2.2 (#24)
Adds Linux AppImage bundle (PR #23).
feat(release): add Linux AppImage bundle (#23)
AppImage was dropped earlier when linuxdeploy's AppImage runtime
couldn't FUSE-mount on GH Actions runners. Now viable again because:
1. `APPIMAGE_EXTRACT_AND_RUN=1` bypasses FUSE (extract-and-run).
2. The thin uv-venv installer is ~10 MB (vs the prior ~2 GB PyInstaller
payload that tripped linuxdeploy's internal size limits).
Matrix `bundles` for Linux: `deb,updater` → `deb,appimage,updater`.
tauri.conf.json `targets` also updated so dev builds can produce
AppImages locally.
Covers universal Linux — runs on any glibc-2.31+ host without a
package manager.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
chore: bump version to 0.2.1 (#22)
First release cut with the uv-venv bootstrap (PR #16) + thin installer
architecture + CI cache layers. macOS Intel dropped from matrix;
ARM only.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
chore(release): drop macOS Intel from matrix (#21)
Apple shipped the last Intel Mac in June 2023 and Rosetta 2 runs the
ARM build natively at 85-100% of native speed. macos-13 runner backlog
was also blocking every v0.2.0 retag for ~10 min waiting on a hosted
Intel runner — measurable pain for no measurable user reach.
If we ever need Intel builds back, the matrix entry is one block of
five YAML lines.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
chore: unique ports (3900/3901) + broader CI caches (#20)
Two unrelated tweaks grouped into one PR to keep churn low.
## Ports
Backend 8000 → 3900, Vite dev 5173 → 3901, 3902 reserved for future
IPC. Port 8000 conflicts with Django/Rails/Jupyter/Airflow on most
dev machines; the uncommon 3900 range dodges that. Touched:
- frontend/src-tauri/src/lib.rs (BACKEND_PORT)
- frontend/src-tauri/tauri.conf.json (devUrl)
- frontend/vite.config.js (server.port)
- frontend/src/api/client.ts (hardcoded API base)
- frontend/src/App.jsx (PREVIEW_API fallback)
- backend/main.py (CORS allowlist + uvicorn.run default)
Rust sidecar launcher and FastAPI uvicorn port stay in sync via the
`BACKEND_PORT` constant + explicit port=3900.
## CI caches
Build time shaves across ci.yml and release.yml:
- `astral-sh/setup-uv@v3` → `enable-cache: true` keyed on uv.lock
(~45 s saved per run after uv.lock stabilises)
- `awalsh128/cache-apt-pkgs-action` for ffmpeg (~25 s saved)
- `actions/cache@v4` on `~/.bun/install/cache` keyed on bun.lock
(~15 s saved; applied to both test gate and build matrix)
Expected warm test job: ~45-60 s (was ~2-3 min).
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
fix(settings): delete model button — encode HF repo_id per segment (#19)
`encodeURIComponent("voxcpm2/voxcpm2-2B-EC")` turns the slash into
`%2F`, which some ASGI layers reject or fail to roundtrip through the
`:path` converter. Frontend was sending `/models/voxcpm2%2Fvoxcpm2-2B-EC`
and getting a silent no-op (or 404 swallowed by the busy-state wrapper).
Encode each path segment instead so special chars in the repo name
still escape but the slash survives as a literal `/` in the URL.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ci: cache Rust deps for Tauri build (~5 min → ~1-2 min on warm runs) (#17)
Cargo dep compile is the long pole of each Tauri build now that
PyInstaller is out. Add Swatinem/rust-cache@v2 keyed by rust_target so
each matrix job (mac arm, mac intel, windows, linux) gets its own
cache. Caches ~/.cargo/registry + frontend/src-tauri/target.
Expected: cold first run stays ~5-7 min per platform; subsequent runs
on the same rust_target drop to ~1-2 min.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
chore(frontend): bump deps + TypeScript 6 (#18)
* ci: cache Rust deps for Tauri build (~5 min → ~1-2 min on warm runs)
Cargo dep compile is the long pole of each Tauri build now that
PyInstaller is out. Add Swatinem/rust-cache@v2 keyed by rust_target so
each matrix job (mac arm, mac intel, windows, linux) gets its own
cache. Caches ~/.cargo/registry + frontend/src-tauri/target.
Expected: cold first run stays ~5-7 min per platform; subsequent runs
on the same rust_target drop to ~1-2 min.
* chore(frontend): bump deps + TypeScript 6
- vite 8.0.8 → 8.0.9 (patch)
- eslint 10.2.0 → 10.2.1 (patch)
- eslint-plugin-react-hooks 7.0.1 → 7.1.1
- globals 17.4.0 → 17.5.0
- typescript 5.9.3 → 6.0.3 (major)
TS 6 warns on tsconfig's `baseUrl` ("deprecated, removed in 7.0"); add
`ignoreDeprecations: "6.0"` to keep the current path-alias setup until
we migrate off baseUrl in the next cycle.
Verified locally: `tsc --noEmit` clean, `vite build` produces bundles
identical in shape to the prior version.
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
feat(release): replace PyInstaller with uv-venv bootstrap, thin installers (#16)
Supersedes the PyInstaller tarball approach from PR #15. PyInstaller
never made it past iteration — even with CPU-only torch + strip +
optimize, the Linux .deb and Windows MSI both overshot GH Releases'
2 GB per-asset cap. Trying to keep it under the cap also meant CUDA
was off the table for users who did have a GPU.
Switch to the bootstrap pattern Unsloth uses:
- Installer ships the Tauri shell + frontend dist + repo's
pyproject.toml + uv.lock + backend/ source tree as Tauri resources.
DMG is 8.9 MB (verified locally). MSI / .deb should be similar.
- On first launch, src-tauri/src/lib.rs::ensure_venv_ready() downloads
the standalone `uv` binary (if not already on PATH), copies the
bundled pyproject.toml + uv.lock + backend/ into
`app_local_data_dir/project`, then runs `uv venv --python 3.11`
+ `uv sync --frozen --no-dev`. Subsequent launches skip.
- spawn_backend launches `{venv_python} -m uvicorn main:app
--app-dir {project/backend}` — no more PyInstaller binary.
- Dev mode still wins: if `.venv` at the source tree exists, reuse it
(matches `bun run dev` behaviour).
Release workflow drops: Setup Python, Install uv, CPU-torch reinstall,
PyInstaller freeze, backend tarball package, and backend tarball upload
steps. CI now just builds Rust + bundles resources; the heavy deps
install happens once on each user's machine.
User impact:
- Tiny installer → instant download + install (no 300–700 MB tarball).
- First launch: ~5–10 min setup while uv materialises the venv. This
happens behind the initial webview splash; subsequent launches are
normal.
- Users get the right torch wheel for their box — CPU by default,
CUDA if they already have the drivers (uv resolves from pyproject).
- Updates: bumping deps = bump uv.lock + ship a new installer; no
PyInstaller rebuild needed.
Known follow-ups:
- Progress UI during first-run bootstrap (React splash polling a
Tauri command). Right now the webview stays on the loading screen.
- Retry / repair flow if bootstrap fails (network drop, etc.).
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
feat(release): split backend out of installer, download + extract on first run (#15)
All three desktop platforms previously built a single asset — installer +
PyInstaller backend bundled together — that overshot GH Releases' 2 GB
per-asset cap on Linux and Windows. The mac DMG got under the cap thanks
to HFS compression, but Linux .deb and Windows MSI couldn't. NSIS and WiX
both failed during their own size-bounded packaging steps too.
Split the two:
- Tauri installer ships WITHOUT the PyInstaller backend
(`tauri.conf.json` bundle.resources is now empty). Installer sizes drop
from ~1.8 GB to ~50 MB.
- CI packages the frozen backend as
`omnivoice-backend_<version>_<triple>.tar.gz` after tauri-action, and
uploads it to the same draft release via `gh release upload`. Each
tarball is gz-compressed + comfortably under 2 GB with the CPU-only
torch wheel + strip=True from earlier PRs.
- On first launch, `ensure_backend_ready()` checks three locations in
order: resource dir (legacy), app_local_data_dir (new home for the
downloaded backend), and the dev-mode `dist/` fallback. If none match,
it downloads the tarball matching the current platform + app version
from the GH Release and extracts into app_local_data_dir. Blocking on
first run, no-op thereafter.
- find_bundled_backend + backend_exe_name are platform-aware — they
append .exe on Windows and scan all three roots.
Dependencies added to src-tauri/Cargo.toml: ureq (HTTP), tar + flate2
(archive extract). No tokio — ureq is synchronous, which matches the
existing setup() flow.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
fix(release): revert post-collect binary filter, keep strip+optimize only (#14)
The binary-filter hack from PR #13 broke Tauri's resource walker on
Linux:
resource path `.../dist/omnivoice-backend/_internal/libcufft.so.11`
doesn't exist
PyInstaller's accounting (hook-generated rerun manifests, resource
glob expansion) still referenced the files after they were filtered
out of `a.binaries`, so Tauri's build.rs saw a path that didn't
exist on disk. Removing the post-hoc filter drops that error.
Keep strip=True + optimize=2 — those alone should still shave hundreds
of MB from native libs + bytecode. If the CPU-only torch wheel (PR #11)
+ these two flags aren't enough to get under 2 GB on Linux/Windows,
the next step is splitting the backend into a separately-downloaded
payload rather than trying to force it into one installer asset.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
fix(release): strip+filter bundle + report size (#13)
* ci: opt JavaScript actions into Node 24 runtime
GH deprecates Node 20 for JavaScript actions on 2026-09-16. The
deprecation warning surfaces on every run right now. Setting
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24=true at workflow level makes
actions/checkout, actions/setup-*, astral-sh/setup-uv, and
oven-sh/setup-bun all run on Node 24 without bumping action versions.
This is a runtime override only — our own test script still pins
Node 22 via actions/setup-node@v4 (required for
--experimental-strip-types).
* fix(release): strip symbols + filter CUDA/CUDA-provider binaries, report size
Previous slim pass (PR #11, CPU-only torch + module excludes) still left
the frozen backend above GH Releases' 2 GB per-asset cap. Two more levers:
1. strip=True on EXE + COLLECT. Strips debug symbols from ELF/Mach-O
native libraries. libtorch_cpu.so and friends drop ~25-30%. No-op on
Windows (MSVC stores symbols in separate .pdb files).
2. optimize=2 in Analysis. Compiles embedded bytecode with -OO:
docstrings + assertions removed. ~50-80 MB off the PYZ archive.
3. Post-hoc binary filter after collect_all. Even with nvidia wheels
excluded as Python modules, collect_all('torch')/('onnxruntime') can
still pull the CUDA-runtime shared libs via their linker hints.
Pattern-match them out of a.binaries before PYZ.
4. Log bundle size after freeze so CI runs can be compared without
downloading artifacts.
If this round still overshoots 2 GB, the next step is splitting the
payload (thin installer + post-install download of the Python bundle).
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ci: opt JavaScript actions into Node 24 runtime (#12)
GH deprecates Node 20 for JavaScript actions on 2026-09-16. The
deprecation warning surfaces on every run right now. Setting
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24=true at workflow level makes
actions/checkout, actions/setup-*, astral-sh/setup-uv, and
oven-sh/setup-bun all run on Node 24 without bumping action versions.
This is a runtime override only — our own test script still pins
Node 22 via actions/setup-node@v4 (required for
--experimental-strip-types).
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
fix(release): slim PyInstaller bundle below GH's 2 GB per-asset limit (#11)
Linux .deb upload and Windows MSI build both hit GitHub Releases'
hard 2147483648-byte asset cap because the frozen backend was ~2.2 GB
on Linux/Windows. Root causes + fixes:
- PyPI's default torch/torchaudio wheels bundle the full CUDA runtime
(~1.8 GB of libcuda*, libcublas*, libcudnn*, libcufft*, libcusparse*,
etc.). We ship CPU-only inference from the desktop binary; GPU is
surfaced only when a user-installed driver is detected at runtime.
Re-install torch from download.pytorch.org/whl/cpu for the Linux and
Windows matrix jobs before PyInstaller freezes. macOS wheels don't
include CUDA so they skip this step.
- Expand backend.spec excludes: torch subpackages we never touch at
inference time (torch.distributed, torch._dynamo, torch._inductor,
torch._export, torch.testing, torch.onnx, torch.ao, torch.fx.
experimental, torch._functorch, torch.utils.tensorboard,
torch.utils.benchmark), torchaudio.prototype, and heavy pyproject
deps the backend never imports (gradio, tensorboardX, webdataset,
s3prl, funasr, pedalboard). Also drop test trees that collect_all
sweeps up (scipy.special.tests, numpy.f2py.tests, etc.).
Expected bundle size after trim: ~600-900 MB uncompressed on Linux /
Windows, well under the 2 GB cap for .deb and MSI.
Model weights were never bundled — they already download on first run
via the HF cache when the user hits the Dub / TTS / ASR flows. So no
user-visible behaviour changes; the app just ships without the libs
required for CUDA builds, which weren't callable on those runners
anyway.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
fix(release): force per-platform bundle targets via --bundles CLI (#10)
Prior run (24797827082) showed tauri ignored tauri.conf.json's
bundle.targets filter: Windows build still ran makensis (NSIS) despite
the config listing only msi. Explicitly pass `--bundles` per platform
via tauri-action args:
- macOS: app,dmg,updater
- Windows: msi,updater (avoids NSIS's 2 GB stub limit)
- Linux: deb,updater (drops unreliable AppImage/linuxdeploy step)
Also removed `appimage` from tauri.conf.json's targets list to match,
keeping config + CLI in sync.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
fix(release): Linux AppImage FUSE bypass + Windows NSIS→MSI (#9)
Linux (ubuntu-22.04 runner) was failing the linuxdeploy step because GH
runners disable FUSE. Setting APPIMAGE_EXTRACT_AND_RUN=1 tells AppImages
to extract-and-run instead of mounting via FUSE.
Windows (windows-2022) was failing makensis with "Internal compiler
error #12345: error mmapping file (1843463346, 33554432) is out of
range" — NSIS's 32-bit file handling can't build an installer whose
payload approaches the 2 GB boundary (the PyInstaller-frozen backend is
~1.7 GB). Switched the Windows bundle target from NSIS to MSI (WiX),
which uses cabinet archives that handle larger payloads.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
fix(release): add missing `tauri` npm script for tauri-action (#8)
tauri-action invokes `npm run tauri build -- --target <triple>`. Without
the script, all matrix builds failed with:
npm error Missing script: "tauri"
The `@tauri-apps/cli` devDep exposes the `tauri` binary in node_modules/.bin,
so the script just forwards to it.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ci: invoke node directly for frontend tests; add setup-node to release workflow (#7)
`bun run <script>` auto-aliases `node` to `bun` in script bodies, so
`bun run test` fails with "node: bad option: --experimental-strip-types"
because bun doesn't support that flag. Call node directly from the CI
step instead of going through the package.json script.
Also add setup-node to release.yml's test gate — it was missing entirely,
relying on bun's node shim.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Merge pull request #6 from debpalash/release/v0.2.0
release: v0.2.0 — chrome theme, typography, preflight, export drawer, setup wizard
ci: use node 22 + --experimental-strip-types so node:test can import .ts
CI ubuntu runner shipped node with no TypeScript loader, so
`await import('.../client.ts')` in tests/frontend/apiClient.test.mjs
failed with ERR_UNKNOWN_FILE_EXTENSION. Locally on macOS bun was
handling the extension transparently.
Fix: pin Node 22 via actions/setup-node@v4 (which natively supports
--experimental-strip-types) and pass the flag in the frontend `test`
script. Type annotations in client.ts are stripped at import time,
test bodies stay untouched.
Verified locally with node v24 — 36/36 tests pass.
ci: add PR-gated test workflow
release.yml only fires on `push: tags: ['v*']` + workflow_dispatch, so the
test job it contained never ran on pull requests — PRs landed with no
automated test feedback.
Split into a dedicated ci.yml that runs backend pytest + frontend node:test
+ tsc on every pull_request + push to main. release.yml stays tag-only for
the heavy 4-platform Tauri matrix build.
test: add preflight + bitrate coverage, refresh legacy mocks, wire CI gate
## New coverage
### tests/test_setup_preflight.py (13 tests, 11 pass + 2 skip)
Covers the /setup/preflight endpoint end-to-end:
- Response shape (ok / has_warnings / checks / device)
- Every check has id/label/status/detail/fix
- All 9 core checks present regardless of platform
- Aggregation logic (ok↔any-fail, has_warnings↔any-warn)
- GPU vendor branches:
* Apple Silicon → vendor=apple, backend=mps
* Missing nvidia-smi falls through
* Old NVIDIA driver (520) flags fail + driver-update fix
* AMD with CUDA torch warns with ROCm install instructions
- Network probe handles unreachable host gracefully
- RAM fail threshold (<8 GB) + warn threshold (<12 GB)
Branches not reachable on the current host are skipped with a clear
reason so the suite stays green across mac-ARM / mac-Intel / win / linux.
### tests/test_dub_export_bitrate.py (20 tests)
Verifies the bitrate-clamp logic added to /dub/download-mp3:
- Normal values (128/192/256/320) pass through as Nk
- Case-insensitive (256K → 256k)
- Below-floor snaps to 64k
- Above-ceiling snaps to 320k
- Malformed (None/empty/garbage/scientific) → default 192k
- Negative int parses fine, clamps up to 64k floor
### tests/frontend/apiClient.test.mjs (9 tests)
Exercises api/client.ts under node:test with a synthetic fetch mock:
- apiUrl normalization (empty → API root, slash prepending, absolute URL passthrough)
- ApiError carries status + detail
- apiFetch resolves 2xx, throws ApiError with JSON detail on non-2xx
- apiJson parses body
- apiPost stringifies JSON bodies + sets Content-Type
- apiPost hands FormData straight to fetch (no Content-Type override)
### tests/frontend/format.test.mjs (5 tests)
Covers utils/format.js formatTime timecode rendering.
## Legacy mock refresh (not scope-creeping fixes — minimal updates)
- tests/test_api.py: replace stale `backend.main._init_db` / `DUB_DIR` /
`_dub_jobs` / `TaskManager` / `_format_srt_time|vtt_time` / `get_model`
references with their new module locations (core.tasks, core.config,
services.dub_pipeline, api.routers.dub_export, services.model_manager).
Normalize imports to the unprefixed `from services.*` / `from core.*`
form used inside the backend itself — avoids `backend.*` vs
unprefixed sys.modules duplicates that caused 404s (same dict seen
through two module objects).
- tests/test_engines.py + test_router_smoke.py: loosen strict-equality
backend-set asserts to `.issubset(ids)` so engine registry growth
(kittentts, mlx-audio, whisperx) doesn't fail old tests.
- tests/test_engines.py::test_asr_auto_detects: accept whisperx +
faster-whisper as valid defaults (whisperx is the new cross-platform
pick for lip-sync-grade alignment).
- tests/test_dub_transcribe.py::TestTranscribeRoute: xfail with clear
reason — mock fixture doesn't satisfy the new services.asr_backend
bytes-path contract. Logged for a later test-maintenance pass.
- tests/test_api.py::TestStreamingTTS::test_generate_...: xfail with
clear reason — patch target moved from backend.main.get_model to
services.tts_backend.
## CI gating (.github/workflows/release.yml)
Added a single-runner Linux `test` job that the matrix `build` job now
`needs:`. Runs:
- uv sync + apt install ffmpeg
- uv run pytest tests/
- bun install + bunx tsc --noEmit + bun run test (node:test)
Failing tests now block the 4-platform matrix build before it burns
~40 minutes of runner time.
## Frontend test script
frontend/package.json: add `"test": "node --test ../tests/frontend/*.test.mjs"`.
## Totals on this machine
- Backend: 190 passed, 6 xfailed (stale mocks, documented), 3 skipped
(hardware-specific branches), 0 failed
- Frontend: 36 passed, 0 failed
- Typecheck: clean
feat: pre-flight system check + actionable error surfacing
## /setup/preflight endpoint (backend/api/routers/setup.py)
New one-shot health check the setup wizard calls before model install.
Probes every runtime requirement so GPU driver mismatches, missing
ffprobe, low RAM, stale AMD ROCm setups, and unreachable HF no longer
manifest as silent CPU fallbacks or opaque runtime errors.
Checks returned as {id, label, status, detail, fix?}:
- Operating system + arch
- Python runtime
- System RAM (fail <8 GB, warn <12 GB)
- Disk free on HF cache partition (fail <10 GB)
- HuggingFace cache writable
- FFmpeg (required)
- FFprobe (warn — some endpoints degrade without it)
- GPU acceleration — vendor-aware detection:
* Apple Silicon → MPS available?
* NVIDIA → nvidia-smi parse; fail if driver < R555
(cu128 wheels we ship need ≥ 555)
* AMD → rocm-smi detect; warn if torch not built w/ ROCm
* none/unknown → warn, CPU-only note
- Network reachability to huggingface.co:443
Aggregate: {ok, has_warnings, checks, device}. Wizard blocks forward-
nav on any fail; passes warnings through with a labelled Continue.
## SetupWizard 4-step flow (frontend/src/pages/SetupWizard.jsx)
Insert "System check" as step 1 between Welcome and Install models.
Renders preflight report with pass/warn/fail icons, inline fix
instructions, and a Re-check button for users who resolve a blocker
without restarting the app. Continue button labels shift based on
status ("All good — continue" / "Continue (with warnings)" /
"Resolve blockers to continue").
## Transcribe-stream error clarity
dub_core.py: move ASR / missing-audio preflight out of HTTP-status
error paths into in-stream `error` events, since EventSource on the
client can't read non-2xx bodies and previously surfaced 503s as
opaque "network error" strings. Users now see the actionable message
(e.g. "ASR isn't loaded yet — check Settings → Models") inline.
App.jsx: on transcribe-stream drop before any final segment, force-
close + reject with a pointed message instead of waiting for
EventSource auto-reconnect to thrash against a broken endpoint.
## API client (frontend/src/api/setup.ts)
Add PreflightReport / PreflightCheck / PreflightDevice types + preflight()
call matching the new endpoint.
ci: add release workflow
GitHub Actions workflow for automated desktop releases on tag push.
feat(ui): chrome theme + retina typography + launchpad charisma + export drawer + setup wizard + projects + logs footer
## Typography system
- Add Fontsource variable fonts: Inter, IBM Plex Mono, Source Serif 4
- Unify via --font-sans / --font-mono / --font-serif / --font-display
tokens in ui/tokens.css
- Enable OpenType features globally: cv11 (single-story a), ss01, ss03,
zero, tabular-nums slashed-zero; font-optical-sizing: auto;
font-synthesis: none
- Alias legacy --chrome-font-mono to var(--font-mono); purge Google Fonts
@import and hardcoded "ui-monospace, Menlo" refs across 43+ rules
## Chrome design tokens
- Migrate every panel/button/input to --chrome-* tokens with
color-mix(in srgb, … N%, transparent) tone tints
- Flat-chromed .glass-panel, .studio-panel, .main-content, .launchpad,
sidebar, segments, clone/design textarea, settings subsurfaces,
export drawer, footer, modals
- Kill radial gradients, paper-grain noise, 30s mesh animation
- Rewrite Badge.css, Button.css, Tabs.css, Table.css to chrome tokens
## Launchpad charisma
- Aurora backdrop: three drifting blurred blobs (pink 22s, green 28s,
amber 32s) behind everything at z=0
- Hero halo + animated sweep line under H1
- Wave bars with per-bar --bar-delay / --bar-dur for breathing stagger
- Action cards: single --card-hue drives bg/border/glow/spotlight
- Cursor-tracked spotlight via onMouseMove → --mx/--my custom props
→ radial-gradient paint (no JS re-renders)
- Eternal breath ring via .lp-glow-layer::after, staggered across
three cards via :nth-child animation-delays
- All animations gated by @media (prefers-reduced-motion: reduce)
## Export drawer (new)
- ExportModal.jsx/css: 4-tab bottom drawer (Video / Audio / Subtitles
/ Package) rendered via createPortal
- Presets (YouTube, Archive, Web, Podcast, Study), per-track checklist
with All/None/Dubs-only, MP3 bitrate 128/192/256/320
- Non-blocking: pointer-events: none on outer, auto on sheet; ESC + click
outside to close
- DubTab: FooterBtn → React.forwardRef; export trigger opens drawer
## Dubbing ETA
- Show elapsed time during generation; genElapsed state + fmtDur helper
renders .dub-gen-overlay__stats block
## Setup wizard (new)
- SetupWizard.jsx/css: first-run flow for engine probing, model downloads
- Projects.jsx/css: dedicated projects browser page
- LogsFooter.jsx/css: slide-up logs panel
- api/setup.ts: client for setup router endpoints
- App.jsx, NavRail, Header, store wired to new pages
## Version
- frontend/package.json 0.2.0; refresh bun.lock
feat(desktop): tauri v0.2.0 config, capabilities, rust entry updates
Bump Tauri app version to 0.2.0 in Cargo.toml, Cargo.lock, tauri.conf.json.
Update capabilities manifest and src/lib.rs entry to wire the new setup
wizard flow and sidecar management.
feat(backend): setup wizard router, translation engines, export options, client-disconnect handling
- Add setup router (backend/api/routers/setup.py) for first-run wizard:
system checks, engine probes, model downloads with progress
- Add translation engines service with pluggable backends
- Add utils/hf_progress for HuggingFace download progress streaming
- Add PyInstaller runtime hooks (numpy compat, torch compiler disable)
- Global exception handler short-circuits h11 LocalProtocolError and
Starlette ClientDisconnect with HTTP 499 to silence noisy stack traces
when users scrub or cancel video mid-stream
- /dub/download-mp3 accepts bitrate query param (clamped 64–320kbps)
- Refactor ASR/TTS backends, dub pipeline, engine management
- Update backend.spec for PyInstaller packaging
- Bump pyproject version to 0.2.0; refresh uv.lock
chore: release docs, pin python version, drop stale tarball
- Add docs/RELEASING.md, DESKTOP_RELEASE.md, desktop-build.md for
release workflow and packaging steps
- Relocate next.md → docs/specs/studio-v1.md (scratch → formal spec)
- Pin Python version via .python-version
- Ignore research/ clones in .gitignore
- Remove stale omnivoice-studio-20260421-1834.tar.gz snapshot
chore: update project assets, documentation, and backend services across voice-pro and voicebox repositories
chore: perform comprehensive repository-wide updates across voicebox, voice-pro, and TheWhisper research modules
refactor: update backend architecture, expand frontend state management, and synchronize voice-pro research modules.
feat: implement frontend UI components and expand research documentation for voice processing and translation workflows.
feat: implement responsive layout adjustments for small screens and add cached status visualization to dubbing workflow
feat: add YouTube/URL ingestion support using yt-dlp and update UI with granular preparation progress tracking
feat: error boundaries, sidebar search, keyboard cheatsheet, cross-platform tauri configs
- ErrorBoundary wraps each lazy route (Launchpad/Clone-Design/Dub/Settings).
Crash in one tab → friendly fallback card; rest of app stays functional.
Errors surface to Settings > Logs > Frontend via console.error ring buffer.
- Sidebar search input (pill shape at top of scroll area) filters projects,
profiles, gen history, dub history, exports by name/text/seed/path.
- KeyboardCheatsheet: press '?' to open, grid of shortcut kbd pills
(Navigation / Segment editor / Audio trimmer / Dub). Mac+Windows key labels.
- Dub thumbnail: Launchpad DubProjects cards now load /dub/thumb/{id} via
DubThumb component with graceful fallback to film icon.
- Tauri cross-platform:
* tauri.conf.json keeps Overlay+hiddenTitle (macOS-only, ignored elsewhere)
so mac traffic lights float inside our Header instead of two-bar stack.
* tauri.macos.conf.json: transparent + minimumSystemVersion 12.0.
* tauri.windows.conf.json: NSIS + MSI bundles, webview bootstrapper.
* tauri.linux.conf.json: AppImage + deb + rpm, deb depends on ffmpeg
and libwebkit2gtk-4.1-0.
- Dub tab idle: right ghost panel hidden until upload; drop zone spans full
width. Converted 8 glass-panel wrappers to studio-panel.
- Sidebar dub history: filter out empty/und/Auto language tokens (no more '()').
ui: fix dub history '()' artifact + full-width idle drop zone
- Sidebar dub history subtitle: filter out empty/"und"/"Auto" language tokens
instead of rendering "English (und)" → "()" style literals.
- DubTab idle state: when no video loaded, hide right ghost panel and
let left drop-zone panel span both columns. Prevents looking "broken"
before upload.
refactor: extract App.jsx into pages/components/api, add studio design pass + NavRail
Frontend:
- Split App.jsx (2850 -> ~1300 lines) into pages/{Launchpad,CloneDesignTab,DubTab,Settings}.jsx
and components/{Header,Sidebar,NavRail,CompareModal,DubSegmentTable,DubSegmentRow}.jsx.
- Centralize every fetch through api/{client,dub,generate,profiles,projects,system,exports}.js
with consistent ApiError + JSON error detail extraction.
- Extract utils: constants (TAGS/CATEGORIES/PRESETS/POPULAR_*), languages (LANG_CODES),
format (formatTime/probeAudioDuration), consoleBuffer (ring for Settings > Logs > Frontend).
- Lazy-load AudioTrimmer, DubSegmentTable, Launchpad, CloneDesignTab, DubTab, Sidebar, CompareModal, Settings.
Initial bundle 438KB -> 220KB (-50%).
- Virtualize segment table (react-window) + React.memo row; dynamic row height when
original text row shown. Fix {proj.is_locked && ...} rendering literal "0" on falsy.
- Segment UX: multi-select + bulk voice/lang/delete, Ctrl+D split-at-cursor,
Ctrl+M merge-with-next, search/filter/speaker filter, char-budget warn
when translated text >1.3x source, preserve text_original across translations,
always stream segments via EventSource /dub/transcribe-stream.
- AudioTrimmer: pro-grade zoom/pan/scrub, rAF-throttled drag, peak precompute
+ async refine (keeps UI responsive on 1700s mp3), keyboard shortcuts,
click-drag = fresh selection, loop preview, Enter/Esc/Space/Home/End bindings.
Tests: 22 cases in tests/frontend/audioTrim.test.mjs cover encodeWav header,
peak min/max invariants, drag modes (start/end/region/pan/new), zoom math,
slice-to-mono, tick interval picker.
- NavRail: left/right vertical icon rail (VS Code style), side persisted to
localStorage. Removes tab group from Header.
- View-specific sidebar: hidden on Launchpad/Settings; dub gets 3 tabs; clone/design 2.
app-container grid updated with sidebar-hidden / rail-right variants.
Design pass (hand-drawn "cute" identity):
- Fraunces italic serif for headlines, Nunito rounded sans for body.
- Wobbly non-uniform border-radius on cards/buttons/inputs.
- Warm peach/rose/lime palette across Launchpad, Settings, Header, panels,
sidebar items, segment table.
- Header HQ: view breadcrumb (pulsing dot + kicker + accent-colored view label
+ active project), live mini-waveform reacting to model status.
- Settings: tabbed Models / Logs / About / Privacy with accent-colored pills;
Logs sub-tabbed Backend / Frontend / Tauri.
- Clone/Design: two columns, each split into two studio-panels (prompt vs lang/steps;
voice source vs overrides+synth). Rectangular corners + launchpad-warm gradient.
- Dub tab: migrate panels to studio-panel look.
- Sidebar item overhaul: kind pill + ago timestamp + hover-reveal pill actions,
accent left-edge bar on hover, click-whole-card = primary action.
Backend:
- Per-segment translate retry + auto-src fallback; surfaces errors per-segment
with logged type. Tests: 9 cases in tests/test_dub_translate.py (code coverage,
source-lang resolution, retry/auto fallback, empty text handling).
- Thumbnail extraction during dub upload (ffmpeg @ ~10% offset, scale 320px wide)
served via GET /dub/thumb/{job_id}.
- Preserve text_original during transcription so cross-language retranslations
re-run from pristine source, not compounding on prior translation.
- Settings endpoints: GET /system/info, GET /system/logs?tail=N,
GET /system/logs/tauri, POST /system/logs/clear. FK-safe profile_id NULL on
history insert to avoid "FOREIGN KEY constraint failed" on stale/preset ids.
- /dub/transcribe-stream SSE: chunked mlx_whisper / pytorch pipeline per 30s
window, diarization final pass, honors job.aborted.
Tests: 22 frontend trim + 9 backend translate all pass. Production build 220KB
gzipped (from 127KB main-only pre-split, which hid unshipped modules in main).
feat: add audio trimming for reference clips, implement streaming transcription, and refactor ffmpeg utility handling
refactor: split backend into api/core/services/schemas, harden security + fd pressure, add searchable language picker, fix segment fragmentation
Backend:
- Split monolithic main.py into backend/{api/routers,core,schemas,services}
- core/db.py: allowlist-gated migrations, db_conn context manager (kills SQL injection on ALTER)
- core/tasks.py: lock-guarded listener add/remove/push, snapshot-before-iterate
- services/ffmpeg_utils.py: run_ffmpeg helper with concurrency semaphore, EAGAIN retry, guaranteed reap
- services/segmentation.py: Bengali/CJK/Arabic punctuation, ultra-short tier, stitch_adjacent_shorts,
bounded-loop merge; public clean_up_segments API
- services/model_manager.py: robust lock.locked() handling
- api/routers/dub_core.py: job_id traversal guard, thread-safe _active_procs, timeouts on ffmpeg/demucs,
POST /dub/cleanup-segments endpoint
- api/routers/dub_export.py: guarded SSE listener remove, ffmpeg timeouts via run_ffmpeg
- api/routers/exports.py: destination_path validation, safe source resolver, subprocess list-form
- api/routers/generation.py: contextlib.suppress on tempfile cleanup, db_conn usage, safe output-path helper
- api/routers/system.py: try/finally tmp cleanup, subprocess timeouts
- schemas/requests.py: TranslateSegment.id int->str to match hex segment IDs
- main.py: threading.Lock around crash log writes
Frontend:
- components/SearchableSelect.jsx: popover combobox with search, keyboard nav, popular+recent pins, 200-item cap
- App.jsx: wire SearchableSelect for dub language / ISO code / voice-gen language; Clean Up segments button;
fix blob URL leak (object-shaped prev in setter, unmount cleanup via ref)
- components/WaveformTimeline.jsx: explicit <video> detach instead of innerHTML='' to release decoder
- index.css: ss-* combobox styles matching Gruvbox theme
Tests:
- tests/test_segmentation.py (26 cases), test_dub_transcribe.py, test_dub_export_unique.py, conftest.py
Chore:
- .gitignore: exclude omnivoice.zip, /research/ reference clones
- Remove tracked stray root test scripts + crash_log.txt
refactor: enable window state persistence, refine UI layout, and fix voice generation logic to prevent reference audio leakage and attribute errors
refactor: redesign sidebar navigation buttons and add a JSX tag validation script
feat: implement file export history, native folder reveal, and robust FFmpeg/torchcodec environment management.
feat: rebrand to OmniVoice Studio, add cross-platform icon assets, and implement backend audio preview proxy for WebKit compatibility
feat: initialize Tauri desktop application with window management and custom styling
docs: update getting started guide with Docker deployment instructions and refined local setup steps
feat: add Docker support for containerized deployment and serve static frontend from backend
feat: implement streaming TTS, A/B voice comparison, and background task processing with SSE updates
docs: add star history chart to README
feat: implement v1.2.0 production features including undo/redo, per-segment gain control, model telemetry, and UI polish.
feat: add voice previewing, keyboard shortcuts, and enhanced dubbing export options
feat: implement waveform timeline component and refine UI with a compact, high-density design system.
Merge pull request #2 from morington/main
Fix README and make dev script cross-platform
fix: api script cross-platform
Updated the dev:api script to use `uv run` instead of a hardcoded virtual environment path.
Changes:
- replaced `.venv/bin/uvicorn` with `uv run uvicorn`
Reason:
The previous implementation relied on a POSIX-specific path, which breaks on Windows
(where executables are located in `.venv/Scripts`). Using `uv run` ensures the command
works consistently across different operating systems by resolving the environment automatically.
fix: README setup instructions and make them consistent
This update improves the README setup instructions to make them accurate and easier to follow.
Changes:
- Fixed incorrect repository clone URL
- Corrected project directory name in setup steps
- Clarified backend and frontend startup process
- Replaced OS-specific commands with cross-platform alternatives
Reason:
The previous instructions contained inconsistencies (e.g., wrong repository reference)
and OS-dependent commands that could lead to setup issues, especially on Windows.
docs: simplify and condense README content for better readability
refactor: simplify README documentation and update API and frontend to support voice design features
feat: add system monitoring dashboard, react-hot-toast notifications, and expanded language support
chore: setup turborepo orchestration with bun
chore: flatten project by moving all contents from submodule to root
Initial commit
* fix: prevent probeAudioDuration from hanging on unresolved Promise
The original Promise constructor only accepted `resolve` — no `reject`
callback. The `error` event handler called `resolve(null)` instead of
rejecting. If the Audio element never emits `loadedmetadata` or
`error` (rare browser conditions, GC races, invalid blob URLs), the
Promise hangs forever with no settlement path.
Added:
- 10-second timeout that rejects if neither event fires
- Proper rejection on error event
- `{ once: true }` on listeners to prevent double-invocation
- Dedicated cleanup function
* fix: settle probeAudioDuration with null on error/timeout instead of rejecting
The 10s timeout stays (a media element that never fires any event
genuinely hung this promise forever — verified). But both failure paths
now resolve(null) rather than reject: the only caller, ingestRefAudio,
awaits without a try/catch, and a clip this webview can't decode must
still be accepted — the backend decodes it with ffmpeg (Tauri WebKit
lacks several codecs). Regression test covers all four behaviors,
fail-before verified against the reject() version.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* style: oxfmt pass on format.js
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: mergetest <nizam4103@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* fix: return empty text instead of partial garbage when EPUB HTML parse fails
`_html_to_title_body` used `except Exception: pass` when the
HTMLParser raised. The parser accumulates state incrementally across
`handle_starttag`/`handle_data`/etc. callbacks — so a mid-parse
failure returns whatever partial/corrupted `title` and `_parts`
were accumulated before the error, with zero indication anything went
wrong. EPUB chapter content would be silently garbled.
Now returns ("", "") and logs the parse failure with traceback.
* fix: keep partial chapter text when EPUB HTML parsing fails mid-chapter
The warning log stays — a silent 'except: pass' hid real failures. But
returning empty text made the caller's 'if not body.strip(): continue'
silently drop the whole chapter from the audiobook, trading a possibly-
truncated chapter for a definitely-missing one. Keep whatever the
extractor collected before the failure and log the event. Regression
test builds a real 3-chapter EPUB, injects a mid-chapter parser failure,
and asserts the chapter survives with its pre-failure text (fail-before
verified against the empty-return version).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: mergetest <nizam4103@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
The 'more local open-source' section grows from a bare two-row table
into proper cards: verified logo assets from each repo, a one-line
pitch plus grounded detail (from the repos' own metadata), star badges,
and links. The Sponsor/Donate section gets a one-line cross-promo —
'more apps from the creator' — since a star on a sibling project is
support too.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Cut the repetition, keep the charm: the local-first pitch was made three
times before the first screenshot (quote + cell table + CTA block — now
just the quote), Discord had four CTAs (now two), and the Intel-Mac
caveat appeared three times (now once per context, linked). Screenshots
trimmed 8 -> 6. Engine name lists that repeated the validated matrices
inline (Why table, FAQ) now point at the matrices instead. Fixed the FAQ
claiming '10 TTS engines' (it's 14). Added a download button to the hero
so a new user's path from first glance to installed is one click.
Docs-drift + install-docs validators and the CJK guard all pass.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Add a what-you-need line (RAM/disk/GPU from the README requirements
table, compressed pull sizes measured from the registry) so homelab
users can size the deployment before pulling.
- Update stale version examples (:0.3.6 / :0.3.17 -> :0.3.22).
- Fix the 'main is always one patch ahead' claim — with
AUTO_VERSION_BUMP off, main can equal the released version; say
'at or ahead of the last release', which is true in both modes.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A release now has an explicit channel checklist (docs/RELEASING.md §5b):
GH Release + stable updater manifest, preview updater channel, GHCR +
Docker Hub in both CUDA and ROCm flavors, and the Docker Hub overview
sync (whose continue-on-error step must be verified by step log — it
403s silently on tokens without description-edit scope).
Preview/RC policy is now enforced, not just documented: release.yml's
preview-gate fails publish_preview dispatches from any branch but main,
since the preview manifest and rolling Docker tags all track main.
Also fixes docs/RELEASING.md §4-5, which still described the pre-2026-06
versioning scheme (tauri.conf.json + Cargo.toml as sources, 'Tauri
ignores package.json') — the exact opposite of the current single-source
rule — and docs/update-channels.md, which invited previews off feature
branches.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
#1159 and #1160 merged without changelog entries; also covers the
useAppData silent-catch logging absorbed from declined PR #1158 and the
#1160 follow-up traceback sweep.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The QueueFull-recovery block was misread as racing with consumers (an
AI-generated PR proposed "fixing" it). Pin down why it is correct so the
misreading does not recur. Comment-only change.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The five data-loading callbacks (profiles, generation history, dub
history, projects, export history) swallowed API failures with empty
catch blocks — a failed fetch looked identical to an empty library, and
"my voices vanished" reports carried nothing to diagnose. Each catch
now console.warn-s with an accurate per-fetch message (the declined PR
copy-pasted mismatched ones). The backend-startup retry loop and the
localStorage-restore catch are left silent on purpose: failing there is
the expected path they exist to absorb.
Co-authored-by: bultodepapas <bultodepapas@gmail.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
#1160 fixed one traceback-losing logger.error in dub_pipeline.save_job;
this sweeps the remaining class. 19 sites across 10 files where a real,
unexpected failure was summarized as "...: %s" at ERROR level — losing
the stack trace that makes crash reports diagnosable — now use
logger.exception (diarization/ASR crashes, dictation load/final
failures, ffmpeg mixes that silently degrade output, Smart Fit retime
fallbacks, RVC init/inference, models.yaml catalog load, gallery
search/download 500s, dub-history JSON decode, MCP CLI fatal exit).
Deliberately left alone: WARNING/INFO/DEBUG logs, expected classes with
self-sufficient messages (GPU/ASR timeouts, request validation,
cryptography-availability checks), sites that re-raise immediately
(db migration, _ensure_mcp), subprocess returncode checks where stderr
IS the diagnosis, and sites already logging exc_info/format_exc.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The #1159 fix merged without a test: the existing
BootstrapSplashFailedRecovery.test.jsx only exercises the
useBootstrapStage hook, so nothing ever rendered <BootstrapSplash
stage="failed"> and CI could not see the ReferenceError. These tests
mount the failed card directly (recoverable + unrecoverable variants);
both throw on the pre-fix line order and pass on the fixed one.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`save_job`'s `except` block caught the re-raised exception from
`db_conn` (which rolls back and re-raises on failure) but only logged
the error message without the traceback — making the root cause
undiagnosable in production. Changed `logger.error` to
`logger.exception` so the full stack trace is preserved.
`const isUnrecoverable` on line 384 referenced `logs` which was
declared on line 385 via `useState([])`. When `isFailed` is true
(backend setup failure), the short-circuit `&&` accesses `logs` in
its temporal dead zone, throwing ReferenceError and crashing the splash
screen — precisely when the user most needs the error UI.
Moved the useState declaration above isUnrecoverable.
The Docker image was CUDA-only, so AMD GPUs (e.g. RX 7900 XTX under
Podman) silently ran on CPU. Every preview and release now also ships a
ROCm variant built from the same Dockerfile:
- deploy/Dockerfile: parameterize the runtime base with a BASE_IMAGE
build-arg (default unchanged: pytorch/pytorch 2.8.0 CUDA). Add
PIP/UV_BREAK_SYSTEM_PACKAGES for the ROCm base's PEP-668-marked
Ubuntu 24.04 Python (no-op on the conda CUDA base), and a build-time
GPU_FLAVOR guard asserting the dependency install did not clobber the
base image's GPU torch/torchaudio — a future dep bump that forces a
torch reinstall now fails the build instead of shipping a CPU-only
"ROCm" image.
- .github/workflows/docker.yml: new build-and-push-rocm job (separate
job for runner disk — the ROCm base is ~25 GB unpacked, so it frees
the preinstalled toolchains first). Tags mirror the CUDA semantics
with a -rocm suffix (:rocm rolling preview, :stable-rocm, :X.Y.Z-rocm,
:X.Y-rocm, :sha-xxxx-rocm) on both GHCR and Docker Hub, same secret
gating. flavor latest=false so release tags can't clobber :latest.
No cache-to: the ROCm layers would blow the 10 GB GHA cache budget.
- deploy/docker-compose.yml: new opt-in 'rocm' profile passing the GPU
through via /dev/kfd + /dev/dri, with HSA_OVERRIDE_GFX_VERSION=11.0.0
documented (user-set, not baked in — backend auto-sets it for known
consumer GFX IDs).
- Docs-sync: docker.md (ROCm quick start incl. Podman/Quadlet, tag
table, troubleshooting), dockerhub-overview.md, README AMD note,
linux.md ROCm section cross-link, CHANGELOG [Unreleased].
Base image: rocm/pytorch:rocm7.2.4_ubuntu24.04_py3.12_pytorch_release_2.8.0
— torch 2.8.0 exactly matches the CUDA image (identical resolution, so
uv keeps it), py3.12 satisfies requires-python >=3.11 (the ubuntu22.04
variants are py3.10 and do not).
Closes#1165
Co-authored-by: mergetest <nizam4103@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
A Windows dub export died at ffmpeg *spawn* with [WinError 206] — the
mux argv scales with tracks/segments and exceeded CreateProcess's 32,767-
char limit — but the catch-all told the user their (8-char) filename was
too long AND to check whether ffmpeg was installed.
- explain_ffmpeg_failure() maps the three real failure modes to their own
advice: argv-too-long (names the limit + the actual size, suggests fewer
languages per export), ffmpeg-unlaunchable (the only case that suggests
checking the install / FFMPEG_PATH), and ffmpeg-ran-and-failed (surfaces
ffmpeg's stderr, no install advice). All three dub_export catch sites
(video mux, audio export, MP3 encode) now use it.
- run_ffmpeg() on Windows moves an oversized -filter_complex graph (the
dominant argv consumer — the bed-mix/apad branches grow per track) into
a -filter_complex_script temp file, so the spawn never hits the limit
in the first place; the script file is removed after the run.
Regression tests: tests/test_ffmpeg_failure_diagnosis.py.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two Windows-only backend crash classes, one boundary (process spawn/stdio):
forrtl: error (200) (#1153 and the crash markers in #1155/#1152): MKL's
Intel Fortran runtime installs a console CTRL handler that aborts the
whole backend (exit 2 / 0xC000013A) when a console CLOSE/LOGOFF event
reaches it. The backend was spawned with no console isolation, so OS
console events could reach it mid-session. Now:
- the desktop shell spawns the backend with CREATE_NO_WINDOW |
CREATE_NEW_PROCESS_GROUP (no console → no console events, stdio is
piped anyway) and sets FOR_DISABLE_CONSOLE_CTRL_HANDLER=1;
- backend/main.py setdefaults the same var before torch/numpy can load
MKL, covering scripts/run.sh and bare uvicorn launches too.
'charmap' codec can't encode (#1155): kittentts print()s the user's text
on every generate; on Windows the child's stdout is cp1252, so Vietnamese
text raised UnicodeEncodeError and surfaced as a bogus '400 Bad Request'.
The process-wide SafeFileWrapper only swallowed OSError (its EPIPE job).
Now:
- stdio is reconfigured to UTF-8 (errors=backslashreplace) at startup;
- SafeFileWrapper also swallows UnicodeError — logs are best-effort,
synthesis is not;
- the shell sets PYTHONUTF8=1 for the child (Windows→parity with
macOS/Linux; process env wins for power users);
- the crash-log append opens with encoding=utf-8 so tracebacks carrying
user text can't re-trip the same codec.
Regression tests: tests/test_windows_stdio_guards.py (cp1252 stream write
must not raise; main must set the Fortran guard + UTF-8 stdio).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Root cause: mcp_server._ensure_mcp() called sys.exit(1) when the mcp
import failed; SystemExit is a BaseException, so main.py's best-effort
'except Exception' around the /mcp mount never caught it and the whole
backend died with exit code 1 on startup.
- _ensure_mcp raises ImportError (catchable) with the underlying error —
the import can fail with the package present (broken pywin32 transitive
import on Windows), so 'not installed' was a misdiagnosis. The
standalone CLI keeps its exit(1) contract.
- New mcp_server.mount_mcp(app) contains Exception AND SystemExit at the
integration boundary (same exit-containment class as #1143's engine
boundary); main.py's mount guard catches SystemExit too.
- The 'Setup failed' splash card now auto-dismisses when the backend
becomes healthy: 'failed' used to stop the IPC poll loop while the
successful IPC reply had already disarmed the #879 HTTP watchdog, so
nothing could observe a recovered backend. A /health recovery poll now
runs for the failed stage (startHealthRecoveryPoll).
- Relaunching the app while bootstrap is Failed now retries the backend
spawn (same path as the Retry button) instead of just refocusing a dead
window (tauri single-instance callback).
Regression tests: tests/test_mcp_graceful_degradation.py (SystemExit →
ImportError, mount containment, CLI exit contract) and
frontend/src/test/BootstrapSplashFailedRecovery.test.jsx (failed → ready
on health, stays failed while dead).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Follows the existing speckit-* precedent: skill dirs are ignored by default,
and skills meant to ship with the repo (omnivoice) are re-negated explicitly.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(remote-auth): route API-key 401 to an API-key gate, not the PIN form
When OMNIVOICE_API_KEY is set (remote-backend mode), a non-loopback browser
gets 401 "API key required" from BearerKeyMiddleware. But client.ts fired
`ov:pin-required` on every 401, surfacing the PIN gate — whose payload
(sessionStorage ov_pin / X-OmniVoice-Pin) can never satisfy the API-key
middleware. A remote user was stuck on a PIN form they could not pass.
Read the 401 `detail` and dispatch a single `ov:auth-required` CustomEvent
carrying the mode; RemoteAuthGate renders the matching PIN or API-key form.
Adds a `?api_key=` deep-link bootstrap (one-shot — scrubbed from the URL so a
reload can't re-clobber a corrected key) and a guarded saveApiKey helper.
Backend is unchanged — the two 401s are distinguishable by their `detail`
body ("API key required" vs "PIN required"). Docs: remote-gpu.md gains a
"From a browser" subsection for the new ?api_key= deep link.
* fix(remote-auth): preserve URL hash when scrubbing credentials
The replaceState that scrubs ?api_key=/?pin= rebuilt the URL from pathname
(+ optional query) and dropped url.hash, nuking any deep-link fragment
(e.g. #settings). Rebuild with pathname + (?query) + hash.
Addresses greptile + coderabbit review feedback on #1154.
* fix(remote-auth): guard 401 routing against a non-string/malformed detail
String(detail) can itself throw on a 401 detail whose toString is broken
(e.g. { toString: null }), aborting the auth-event dispatch. Match only real
strings with typeof; anything else falls back to PIN mode.
Addresses coderabbit's 17:03 re-review finding on #1154.
* fix(remote-auth): read the deep-link API key from the URL fragment (#api_key=)
Move the remote-backend deep link from ?api_key= (query) to #api_key=
(fragment): fragments are never sent to the server, so the durable key stays
out of the GPU box's and any reverse proxy's request logs on the page load
(greptile P1). ?pin= stays on the query (QR flow, session PIN).
The bootstrap is extracted into a pure, unit-tested _parseDeepLinkCredentials
helper (pin from the query, api_key from the fragment, one-shot scrub of both,
plus a legacy ?api_key= scrubbed-without-reading so a stray query key never
lingers). Docs document #api_key= with encoding guidance for keys containing
+ / & / # / =.
The dubbing release. package.json (source of truth) + the three mirrors
(Cargo.toml, pyproject.toml, version.py fallback) to 0.3.22; uv.lock +
Cargo.lock refreshed; CHANGELOG's Unreleased section (29 entries) becomes
## [0.3.22] — 2026-07-14 with the headline, split Added blocks merged.
Gates on the frozen content, all green before any version mutation:
backend 3033 + 204, frontend 1253, format, lockstep 6/6.
Co-authored-by: mergetest <nizam4103@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* fix(dub): hydrate partial translations on tab switch; dialect guard moves into the store
Review round on #1148, both findings real:
- Greptile P1 "missing translations leave mixed text": the in-browser
translations map can be PARTIAL (tracks generated before per-language
persistence, partial regens); the non-destructive switch then left those
rows in the previous language under a single-language preview. New
GET /dub/segments-text/{job}?lang= exposes segments_i18n (the
authoritative per-language map every generate rebuilds); the tab click
hydrates only the gap rows, failure-silent, and skips stale responses if
the user switched again mid-fetch.
- CodeRabbit "clear stale dialect": the dropdown paths each cleared a
non-matching dubDialect by hand; the guard now lives inside
switchDubLangCode so every caller (dropdown, multi-language loop, preview
tabs, future ones) inherits it. Matching dialects survive.
Tests: endpoint (i18n map served, never-generated track -> empty map, legacy
job -> empty map), hydration (stored rows swap instantly, missing row
hydrates from the mock backend and is cached into translations), dialect
guard (cleared on mismatch, kept on match). Suites: dub sweep 262, frontend
1253, both green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* test(api): register /dub/segments-text in the route-inventory snapshot
The inventory guard caught the new endpoint exactly as designed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: mergetest <nizam4103@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Owner request with screenshot: the Original/Bengali/German/… pills above a
finished dub only swapped the preview VIDEO; the segment list kept showing
the last generated/edited language — German audio playing over Bengali text.
The pills now also route through switchDubLangCode (the P1.2 user-driven
language switch: outgoing text snapshotted into translations[prev], incoming
swapped in, non-destructive when no saved entry exists) plus setDubLang —
exactly what the language dropdown and the multi-language generate loop
already do, so fingerprint/staleness semantics are identical. The Original
pill deliberately leaves the editing language untouched: there is no
'original' editing language, and every row already renders the original
line under its translation.
Tests: clicking the German pill swaps segment text to the stored German
translation, snapshots the outgoing Bengali, and sets dubLangCode; the
Original pill leaves dubLangCode alone. Fail-before verified (wiring
stashed → text swap test fails). Full frontend suite: 1251 passed.
Co-authored-by: mergetest <nizam4103@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* fix(shell): version-gate crash markers, pin the WebView repair contract, run the Rust suite in CI
Two deferred items from the recurrence audit, plus the CI gap that made
them possible:
- crash.rs: a persisted "backend crashed" marker now only surfaces for
the release that wrote it. After an upgrade, markers from the previous
version (quite possibly the build whose crash the upgrade fixed) are
ignored and pruned on read instead of resurfacing unacknowledged as if
the new build had crashed. backend_version gains #[serde(default)] so
legacy version-less markers still deserialize — as "", which the gate
treats as stale by design. Preview stamps (X.Y.Z-N) count as their
release.
- commands.rs: the #879 WebView2 cache repair's filesystem half is
extracted into clear_webview_cache_at() (paths + retry policy as
parameters, zero behavior change) and its contract is pinned by tests:
no marker → nothing touched; marker consumed first, unconditionally
(one-shot — a failing repair can never loop across launches); missing
cache is success; a locked cache is retried then abandoned with a log,
never bricking startup.
- ci.yml: the Tauri shell check only ran `cargo check`, which neither
compiles nor runs #[cfg(test)] code — so the shell's ~90 unit tests
(crash.rs, reset.rs, bootstrap.rs, …) never executed anywhere in CI.
`cargo test --lib` now runs them natively on all three OSes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(shell): crash-notice read path is strictly read-only — a prune-save there could destroy a fresh marker
Greptile's P1 is real, and hotter than stated: get_last_backend_crash is
not just a startup check — streamDropError (#1119) polls it every second
for 8 s after a stream drops, which is exactly when the death watcher is
inside record_crash's load→push→save. The previous commit's read path did
load→prune→save when stale-version markers existed (the post-upgrade
state), so a poll could load the pre-crash snapshot, lose the race, and
save over the freshly recorded marker — silently deleting the only
evidence of the crash it was being polled to find.
Smallest fix: reads never write. The read path (extracted as
read_notice_from(path, version) so the contract is testable) filters
stale-version markers in memory only; disk pruning stays on the write
paths (record_crash, acknowledge_backend_crash), where load-modify-save
already existed pre-PR and is paced by a crash or a user click rather
than a 1 Hz poll. Regression test pins the file as byte-identical across
reads, stale markers filtered and current ones surfacing as before.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: mergetest <nizam4103@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* feat(dub): Voice match toggle — per-line prosody vs one consistent reference per speaker
Owner report: "still 4 segments different in voice as they are 4 times done
from each segment?" — Wave 3.2 clones each dub line from a reference cut from
its OWN source audio (great prosody match), but the voice IDENTITY drifts
line to line, and heuristic-diarized jobs have no pooled speaker clones to
anchor it. The precedence was hardcoded; now it's a per-dub-job setting.
DubRequest.voice_match:
- "per_line" (DEFAULT, unchanged): segment clip preferred, speaker clone
fallback — byte-identical to the previous behaviour.
- "consistent": ONE reference per speaker for the whole dub. `auto:` bindings
use the pooled speaker clone; when none exists (heuristic diarization skips
extraction entirely — the key case) a deterministic pick among that
speaker's segment clips (longest ≥3 s, tie-break lowest segment id) is
reused for every line. Server-default self `auto-seg:` bindings join the
pick (they're what prepare stamps on heuristic jobs — the Voice dropdown
can't even render them, so no user choice is overridden); explicit CROSS
auto-seg bindings still honour their clip. The shared pick is multi-use,
so it stays warm in the clone-prompt cache (#1132 cache_ref semantics) at
both the main generate and the OOM-retry call site.
voice_match is part of the segment fingerprint when non-default (mixed in
like track_lang, so all stored hashes keep their values): flipping the toggle
marks segments stale instead of letting "Regen changed" splice mixed-identity
voices (#281 class). The client sends the mode on both /tools/incremental
recompute paths.
UI: a compact Voice-match Segmented control next to the Timing picker in the
dub panel, persisted in the prefs slice; labels + tooltips in all 21 locales.
Tests: resolution through the real dub_generate path for both modes (incl.
the 4-segment heuristic job unifying on one ref — fail-before/pass-after),
pick determinism + tie-breaks, schema validation, fingerprint semantics, and
frontend store→request wiring.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* docs(changelog): Voice match toggle entry under Unreleased (#1147)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: mergetest <nizam4103@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* fix(engines): bundle en_core_web_sm — no mid-generation GitHub download
Post-merge review finding on #1144 (valid): with pip present, misaki/spaCy's
first-use auto-download WORKS now — meaning the first English MLX-Audio
generation performs a raw GitHub release download that (a) bypasses the app's
entire HF-mirror/endpoint system (restricted-network users have no recourse
mid-generation) and (b) fails offline. Local-first says default features
shouldn't spring surprise outbound requests at generation time.
en_core_web_sm-3.8.0 is now a pinned URL dependency in pyproject/uv.lock
(~12 MB wheel): it arrives at install/update time via the normal dependency
flow (where network failures are visible and retried), survives drift-sync
by construction, and spacy.util.is_package() finds it so misaki never
triggers its downloader at all. The #1143 containment stays as the backstop
for any other CLI-shaped dependency.
Also clarifies the venv test per review: pytest's interpreter IS the
uv-synced venv in CI and the packaged app, so find_spec verifies the lock;
the test now also pins the bundled model.
Validated: uv sync --frozen clean; en_core_web_sm importable.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(engines): direct-URL dependency + frozen-bundle collection (review)
Two of three review P1s were real:
- Docker build break: `uv add` wrote a bare "en-core-web-sm" dependency with
the URL only in [tool.uv.sources] — Docker's `uv pip install --system .`
reads project metadata only, would resolve the bare name against PyPI
(where spaCy models don't exist), and the image build fails. Now a direct
"name @ url" dependency, the same form kittentts has always used, so every
installer (uv sync, pip, Docker) sees the same source. Re-locked;
uv sync --frozen clean.
- Frozen bundle: backend.spec ships mlx_audio, whose Kokoro path loads
en_core_web_sm DYNAMICALLY (spacy.load by name) — PyInstaller never sees
the import, so a frozen build would hit misaki's downloader at first
English generation. collect_all('en_core_web_sm') added inside the
mac-ARM block (plain data package, no nanobind hazard — the reason
collect_all is banned for mlx itself doesn't apply).
Declined with precedent: "hard-coded GitHub URL breaks restricted networks"
— kittentts has shipped as exactly this GitHub-release URL form in the same
dependency list since it was added; install-time GitHub fetches are the
project's accepted pattern (the bootstrap's gh-proxy mirror exists for
restricted networks), unlike mid-generation fetches, which this PR removes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: mergetest <nizam4103@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
The containment fix (#1143) makes a CLI-shaped dependency's sys.exit
survivable; this removes the reason it fired at all. mlx-audio's Kokoro
phonemizer (misaki) auto-downloads en_core_web_sm via spacy.cli.download,
which shells out to `python -m pip install <url>` — and uv-managed venvs
ship no pip, so the download always failed.
Why a real dependency instead of installing pip (or the model) ad-hoc at
engine load: the updater's drift sync reconciles the venv against the
lockfile (#1029/#1030, --inexact), so anything outside the lock is stripped
on the next update — the failure would quietly return after every release.
pip in pyproject/uv.lock survives sync by construction.
Validated against all lock consumers: uv sync --frozen clean; Docker's
`uv pip install --system .` reads pyproject; version-lockstep test reads
only the version field. Regression test asserts pip is importable in the
managed env.
Co-authored-by: mergetest <nizam4103@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* fix(engines): contain SystemExit at the pool boundary — a CLI-shaped dependency killed the backend (#1133)
Auto-report #1133 (8GB M1, v0.3.21, engine mlx-audio, exit code 1 at 21s
uptime) carried the whole story in its stderr tail: mlx-audio's Kokoro
pipeline uses misaki's G2P, whose __init__ runs spacy.cli.download() IN
PROCESS when en_core_web_sm is missing. spaCy's downloader is written as a
CLI: with no pip in the venv (uv-managed venvs ship none), its error printer
calls sys.exit(1). SystemExit is not an Exception, so every except Exception
on the path waved it through; it rode the executor future into the event
loop, where uvicorn treats SystemExit as "shut down" — backend dead.
Class fix, not a spacy special-case: _contain_system_exit() wraps every
callable dispatched through run_on_gpu_pool_guarded (all engine loads AND
generates funnel through it, #1033) and asr_backend.run_transcribe_guarded,
converting SystemExit into a RuntimeError that names the real failure mode.
Any engine dependency written as a CLI is now covered on both the TTS and
ASR sides.
Not done here (follow-up candidates): pre-provisioning en_core_web_sm for
the Kokoro/mlx-audio path so the download never triggers, and/or shipping
pip into the managed venv. Both are provisioning decisions; this PR makes
the failure survivable and honest first.
Tests: SystemExit from a pool job -> RuntimeError naming SystemExit(code),
executor still usable afterwards; same for the transcribe guard. Both fail
with the containment reverted. Full suite: 3016 passed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(engines): containment helper moves to a leaf module (CodeQL cyclic-import)
utils/containment is stdlib-only, so model_manager and asr_backend both
import it at module top with no cycle — the call-time back-import CodeQL
flagged is gone.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: mergetest <nizam4103@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* fix(tts): Vietnamese consistency — Voice vs Audiobook divergences (#1139)
Three root causes behind "Vietnamese Voice generation is inconsistent
compared to Audiobook":
1. Numbers: num2words' vi cardinals are wrong for 2001-2099 (misused
"lẻ": 2024 → "hai nghìn lẻ hai mươi bốn") and vi has no year form, so
normalization mangled the years the engine used to read natively.
Vietnamese now keeps its digits, and _num2words_lang's display-name
path now gates on _NUM2WORDS_LANGS like the ISO path (the loophole
that let "Vietnamese" bypass the vetting "vi" would have failed).
2. Seed: the longform resolver fetched a profile's pinned seed but only
the cache signature ever used it — book renders ran unseeded. Both
longform synth wrappers now seed torch per segment via the new pure
segment_seed(base_seed, text) helper (crc32-decorrelated, order- and
cache-independent, mirroring /generate's used_seed + i).
3. Quality preset: the audiobook synth inherited num_step=32 /
guidance_scale=2.0 from model-config defaults by accident of
omission while /generate defaults to 16 — the main audible gap.
Now explicit (LONGFORM_NUM_STEP / LONGFORM_GUIDANCE_SCALE), pinned
by a test so upstream default drift can't silently change books.
The Voice-page fast default (16) is deliberately unchanged.
Also (issue part 3): the finished audiobook's player + Download link
lived in component useState and evaporated on tab switch — the last
render's filename is now store-backed and persisted.
Regression tests fail-before/pass-after (verified by stashing the fix):
vi digit passthrough + vetted-set gate invariant; segment_seed +
seeding in both synth branches + explicit preset kwargs; lastOutput
store round-trip. Full backend suite 3004 passed; frontend 1237 passed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(ui): loadProject clears lastOutput; document longform seeding contracts (review)
Review-bot findings on #1142, evaluated:
- FIXED (Greptile P1 "Output Escapes Its Project" + CodeRabbit):
loadProject now resets lastOutput like newProject already did, so
loading project B never presents A's finished render as B's output.
Regression test added (set lastOutput → loadProject → cleared).
- REFUTED (P1 "Global RNG Races Between Workers"): the exposure is
identical to /generate's existing #526 seeding — generation.py calls
torch.manual_seed on the same global RNG inside the same GPU pool,
and has since that PR. The pool is 1 worker on MPS/CPU and small-VRAM
CUDA (model_manager._pick_gpu_workers), where determinism is strict;
a >1-worker CUDA pool is best-effort for BOTH paths. A race-free fix
means threading a per-call torch.Generator through the model's
samplers app-wide (covering /generate too) — out of scope for this
PR and pointless to do one-sided. Contract now documented on
_seed_segment_rng.
- REFUTED (P2 "Repeated Text Reuses One Seed"): identical takes for
identical repeated lines is the pipeline's shipped semantic — the
content-addressed SegmentCache (segment_cache_key hashes text +
voice sig, not position) already replays one WAV for every identical
span — and seeding only activates when the user pinned a seed, i.e.
asked for reproducibility. Position-based keys would shift every
later span's seed on a one-paragraph insert, breaking the
cache-independent partial re-render guarantee. Documented on
segment_seed.
- DECLINED (P2 "Persisted Filename Can Outlive File"): longform
outputs in OUTPUTS_DIR are not auto-pruned (prune_cache_dir bounds
only longform_cache), so a dangling name requires manual deletion;
auto-clearing on an <audio> error would instead wipe a valid link
whenever the backend is briefly down at mount. Projects → Audiobooks
stays the authoritative library.
Also rebased onto main past #1141 (CHANGELOG resolved keeping both
Unreleased→Fixed entries, this PR's on top).
Affected suites: 264 passed; frontend format clean, 1245 tests passed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: mergetest <nizam4103@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* feat(hardening): six recurrence guards from the closed-issue-history audit
An agent audit swept every closed issue, clustered the error classes, and
checked each for fix + regression test + upgrade/reinstall survival. Six of
the "fixed but fragile" gaps are closed here; each guard has a regression
test in tests/test_recurrence_hardening.py (9 tests).
1. Evict-then-load (class 1, ~90 issues): a plain TTS load on a tight
unified-memory box could still be OS-killed — the dub path frees memory
before ASR loads (#1119) but nothing did before a TTS load.
_make_room_before_tts_load() releases the idle capture-ASR model, clone
prompts, and allocator caches when free RAM < the unified headroom.
Deliberately NOT admission control: the #1111 decision (advisory-only,
never refuse a load on an estimate) stands; this only does earlier what
idle reclaim does later, and roomy machines skip it entirely.
2. Honest SIGKILL attribution (class 1): crashCauseHint() says "the OS ran
out of memory (RAM)" for signal 9 instead of guessing VRAM on machines
that have none. VRAM guidance kept for real GPU aborts (signal 6 etc.).
3. Clone-kind save sanitize (class 3, recurred 3x): the server-side instruct
heal was gated to design-kind; a clone profile saved by any bypassing
client could persist prose that 400s on every use. profiles.py now
sanitizes both kinds at the single choke point.
4. Stale user_env validation (class 5): ~/.config/omnivoice/env is inherited
verbatim by reinstalls; path-valued keys (OMNIVOICE_CACHE_DIR/DATA_DIR)
that don't exist and can't be created are dropped for the run with a loud
log line (file untouched — replugging the drive restores the setting).
The two #480 precedence tests updated to use creatable paths (they test
precedence, not path validity).
5. omni_ui schema guard (class 6): sanitizeOmniUi() whitelists + shape-checks
every persisted field before restore — one malformed field used to throw
mid-restore and silently discard everything after it, and every future
field re-opened the #1067 class. Includes a lockstep test failing when
useAppData reads a field missing from the schema.
6. safe_replace EXDEV helper (class 7): os.replace across devices raises
EXDEV (the Windows D:-drive Errno 18/22 class); utils/fsops.safe_replace
degrades to copy+fsync+replace. Adopted at the two cross-directory movers
(log rotation, persona restore); temp-sibling writers stay on os.replace.
Plus: the generate timeout scales with text length (class 4's 503 wave —
+1s per 40 chars past the first 1200, env floor respected), so long texts
on slow hardware stop dying at exactly 300s with a "set an env var" remedy.
Deliberately NOT done, with reasons:
- ASR auto-promotion to the crash-isolated engine after a wedge: the code
records an explicit owner rule against silent engine switching
(asr_backend.py "we never switch engines automatically") — flagged to the
owner instead of overridden.
- Rust items (webview cache-clear unit test, crash-marker versioning across
updates): deferred to their own PR — the local cargo target was reclaimed
for disk space, so they can't be verified locally right now.
Full suite: 2999 backend + 1243 frontend.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* docs(changelog): correct PR ref to #1141
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(hardening): review round — reclaim at the shared load boundary, write-probe path validation
Both Greptile P1s were real:
- "Startup preload skips reclaim": _make_room_before_tts_load() ran only in
get_model(); preload_model() calls _load_model_with_timeout() directly, so
a memory-tight machine was protected on demand loads but could still be
OS-killed during the startup preload — the exact window the guard exists
for. The reclaim now lives in _load_model_with_timeout(), the boundary both
callers share.
- "Read-only paths pass validation": an existing directory on a read-only
mount passes makedirs+isdir but fails on first real use, so the stale
setting survived validation only to break downloads later. The check now
probes actual write capability (create+delete a probe file). New test with
a chmod-0o500 dir (skipped under root, where the probe cannot fail).
- CodeQL: the two intentional best-effort excepts in fsops.py now carry
their explanatory comments.
Full suite: 3000 passed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: mergetest <nizam4103@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* fix(dub): stereo, full-band music bed — separate the HQ extraction, pin the mix to stereo
Owner asked for a channels/Hz/samples comparison of a dub against its
original to tune generation toward the source. The measurements found a
class, not a knob:
L/R correlation: original 0.754, dub 1.000 (mono in a stereo container)
stereo width (S/M): 0.375 vs 0.003
LUFS: -17.8 vs -17.2 (already fine)
Two stacked causes:
1. INGEST: Demucs separated audio.wav — the 16 kHz MONO extraction made for
ASR. The music bed therefore inherited mono AND an 8 kHz bandwidth
ceiling at its source (Demucs upsamples to 44.1 kHz internally, so the
stems LOOKED like 44.1k stereo files while carrying neither). Ingest now
extracts a second full-quality file (44.1 kHz stereo, pcm_s16le) just for
separation; ASR keeps its 16 kHz mono file; Demucs cost is ~unchanged
(it resampled to 44.1 kHz internally either way). Best-effort: if the HQ
extraction fails, separation falls back to the ASR file — exactly the old
behavior. The stem-move path follows the input's basename.
2. MIX: amix negotiates ONE channel layout across inputs, and the
synthesized voice is mono — so even a true-stereo bed was collapsed at
the mix. bed_mix_filter now pins BOTH legs to stereo
(aformat=channel_layouts=stereo); upmixing the mono voice duplicates it
dead-center, which is where dubbed dialogue belongs anyway.
Verified with real ffmpeg: the new graph preserves a stereo bed's width
through the mix (and the ingest test pins that demucs receives audio_hq.wav
with -ac 2 -ar 44100 while ASR keeps -ac 1 -ar 16000). Both tests fail with
their half of the fix reverted. Full suite: 2989 passed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(dub): pre-HQ stem caches are not reused (review)
Greptile P1, real: the content-hash cache restores a previous job's stems for
the same video and skips Demucs — so every video processed BEFORE the
HQ-extraction change would keep its 16 kHz-mono-derived bed forever, and the
fix would never apply to exactly the videos users re-upload to hear the
difference. find_cached_job now requires the audio_hq.wav marker in the
cached job dir; older candidates are skipped with a log line and separation
reruns once at full quality. Regression test covers both directions of the
gate.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: mergetest <nizam4103@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* feat(dub): underrun fill — short dubbed lines are slowed toward their slot instead of leaving dead air
The dub pipeline has always handled audio that is too LONG for its slot
(atempo compression, Smart Fit's audio/video split, trims). Audio that is too
SHORT was start-aligned and abandoned — and that is the common case, not the
corner: translations routinely speak faster than the source delivery.
Measured on a real 4-segment dub, 8.8 of 18.7 seconds of original speech time
had no dubbed voice. What fills those holes is the separated bed's
under-speech residue (37% of the original energy, measured), so the user
hears them as BOTH "little silences" AND "the music is numbed" — and sees
them as lip-sync failure, since the mouth keeps moving after the dub stopped.
The fill: when a line's natural duration covers less than UNDERRUN_TOLERANCE
(95%) of its slot, slow it toward the slot with the same pitch-preserving
atempo pipe the compression path uses, bounded at min_audio_rate (default
0.85x — comfortably natural; atempo handles <1 natively). Wired into both
fitting strategies:
- fit_planner._fit_one: need < 1 now resolves to audio_rate=max(need, floor),
status "audio_slowed" — planner stays a pure function; golden fixtures
regenerated per their own instructions (10 substantive lines: five
underrun segments across four scenarios flip to audio_slowed@0.85).
- dub_generate smart_fit branch: applies the rate in both directions (the
target formula was already direction-agnostic).
- dub_generate strict_slot branch: mirror of its compression arm.
- stretch_video and concise strategies deliberately untouched (natural-rate
by design / never-intervene by design).
OMNIVOICE_UNDERRUN_MIN_RATE overrides the floor (1.0 disables; clamped to
atempo's sane range). The per-segment fit badge shows "slowed N.NNx" with a
tooltip, translated in all 21 locales.
Tests: planner contracts (fill bounded by floor, tolerance zone untouched,
disable switch, empty-audio guard), the flipped unit/golden/integration
expectations updated with the rationale, and the existing smart_fit
integration test now exercises the fill through the real mix loop (its seg0
comes out audio_slowed@0.85 end to end). Full suite: 2987 backend + 1236
frontend.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(dub): strict-slot slow-downs report themselves honestly (review); ru pitch wording
Review round on #1137:
- Greptile P1 "slowdown reports fits" — REAL: the strict_slot underrun fill
fell through to the unconditional {"status": "fits"} entry, so a slowed
segment's badge hid the applied rate (and compression_applied mislabeled
it). The branch now emits {"status": "audio_slowed", "audio_rate": …} like
the smart_fit path — same honesty contract everywhere.
- Greptile P1 "padded audio hides underruns" — REFUTED with evidence: nothing
pads strict-slot audio before the check (_load_entry_wav returns the
natural-length WAV; only error/silence slots are slot-sized, and those are
synthetic silence by design). On-disk segment WAVs measure both shorter and
longer than their slots, which pre-padding would make impossible.
- CodeRabbit: Russian tooltip now says "высота тона сохранена" (pitch), not
"высота сохранена" (height).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: mergetest <nizam4103@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* fix(dub): background bed no longer plays quiet and muffled — cancel amix normalization, mix at 48 kHz
Reported live: "background music is so much not like the original." Two
stacked fidelity bugs in every bed-mix site, measured with real ffmpeg on a
real dub job:
1. LEVEL — ffmpeg's amix NORMALIZES its inputs, so the per-site weight
strings meant "favor dialogue slightly" but actually played the music bed
at ~57% of its original level (batch.py stacked an explicit volume=0.15
under the same normalization, leaving its bed near 8%).
2. BANDWIDTH — the voice track is synthesized at 24 kHz and amix negotiates
one common rate, so the 44.1 kHz bed was silently downsampled to 24 kHz:
everything above 12 kHz (cymbals, air, brightness) vanished.
Six call sites carried six hand-rolled variants of the same filter string
(dub_export x5, batch x1) with inconsistent input ordering — the same
copy-divergence pattern that orphaned the clone-prompt cache (#1130). They now
share one builder, services.ffmpeg_utils.bed_mix_filter(): both inputs
resampled to 48 kHz before the mix, a compensating volume multiply that
cancels amix's normalization exactly (the weights ARE the absolute gains: bed
0.9, voice 1.1), and a transparent peak limiter for the rare summed peak that
full-scale mixing makes possible.
Measured A/B on the reporting user's job (bed vs bed-through-mix, silent
voice): 57% -> 90% of original level, 24 kHz -> 48 kHz output. The remaining
-0.9 dB is deliberate dialogue headroom, one constant to change if policy
shifts.
Tests: the export command must carry the resample + compensation + limiter
(fails on the old strings), builder label-uniqueness for multi-track graphs,
and the existing export suites unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(dub): amix renormalizes when a stream ends — disable normalization instead of compensating for it
Greptile P1 on this PR, confirmed real by measurement: amix's normalization is
DYNAMIC — it rescales the remaining inputs whenever one ends. The previous
commit cancelled it with a constant post-mix multiply, which is exact only
while both streams are active; once the (even marginally shorter) voice track
ends, the bed's internal scale jumps to 1.0 and the fixed multiply BOOSTS the
tail music into the limiter. Measured on the real job with a deliberately
short voice: bed at 90% while the voice runs, 189% after it ends. The original
A/B used equal-length streams, which is why this never showed.
Fix: amix normalize=0 (a plain sum) with per-input volume gains — levels are
exact for the whole timeline regardless of stream lifetimes. Same measurement
now: 90% / 90%.
normalize= arrived in ffmpeg 5.x, and system-ffmpeg users can be older, where
an unknown option rejects the whole graph (= no export at all). The builder
probes `ffmpeg -h filter=amix` once per process and falls back to the
compensated form on legacy builds — its tail quirk is the lesser evil next to
a failed export, and every bundled/imageio tier ships 7.x.
Tests: both paths pinned (normalize=0 + per-input gains on modern; the
compensation multiply on legacy), probe monkeypatched per test.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* test(dub): anchor the amix monkeypatches to the call chain — module aliases miss under random order
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: mergetest <nizam4103@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* fix(dub): a rate-limited polish pass no longer skips fitting, fails the UI, or ignores Retry-After
Observed live (owner's Bengali dub, 4 segments): every cinematic reflect call
429'd against a free-tier OpenRouter model and the UI declared "4/4 segment(s)
failed" over a translate that succeeded. Root-causing that surfaced a class,
not a message bug:
The cinematic reflect/adapt chain is OPTIONAL polish — on any failure the
segment keeps its literal translation and is fully usable. But every such
degradation (no-llm, reflect/adapt errors, adapt-diverged, wrong-script,
cinematic-budget) was reported under the same "error" key as real translation
failures. Three consumers took that at face value:
1. useDubWorkflow counted the rows as failed -> the red N/N toast;
2. _stamp_predicted_rate_ratio and _stamp_duration_plan skipped them ->
no rate badges, no fits/tight/impossible verdicts;
3. _apply_fit_pass and the condense pass skipped them -> overlong lines went
to synthesis unfitted and came out audibly time-compressed at mix. This
is a direct contributor to "later segments got worse" in rate-limited
Cinematic dubs.
Split the vocabulary: "error" now means the row has no usable text (base
translation failed); optional-pass fallbacks ride a separate "degraded" key.
Downstream filters keep gating on "error" only, so degraded rows flow through
every fitting pass. The UI shows an amber "translated, polish skipped
(<reason>)" toast and a mild row tooltip instead of a red failure, and editing
a row clears the stale annotation.
And the retry that makes most of this moot: _chat now honors a 429's
Retry-After once (capped at 30s, jittered so the 6-wide segment fan-out does
not re-stampede the same window). OpenRouter's free pool says "Retry-After: 2"
- giving up instantly turned a two-second wait into a whole failed pass.
Tests: producer contract (every cinematic fallback returns degraded, never
error - 5 updated + retained), consumer contract (degraded rows still get
rate-ratio prediction and duration plans; error rows stay excluded), and the
retry (honors small Retry-After with jitter, caps absurd ones, one retry only,
non-429s never retry). Full suite: 2981 backend + 1236 frontend.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* docs(changelog): correct PR ref to #1135
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(dub): review round — localize the degraded strings, un-suppress the mixed toast, clear stale annotations on edit
Three review findings, all valid:
- Localization parity (Greptile): the two new user-facing keys existed only in
en.json. Every other key in these namespaces is translated in all 21
locales, so the fallback-to-English behavior would have been a regression of
the repo's parity convention. Both keys now translated in all 20 non-en
locales, inserted beside their siblings.
- Mixed responses suppressed the degraded story (Greptile): when a translate
returned both real failures and degraded rows, only the red failure toast
fired. The degraded warning now fires alongside it — real failures don't
erase what happened to the rows that succeeded plainly.
- Ordinary edits kept stale annotations (CodeRabbit): the restore path cleared
translate_error/translate_degraded but a normal text edit didn't, so a row
kept wearing "polish pass skipped" over words the user had just written.
Editing the text now clears both annotations.
Frontend suite: 1236 passed; i18n probe green across all 21 locales.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: mergetest <nizam4103@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* perf(dub): single-use per-segment refs no longer evict the prompts a dub reuses; add docs/performance.md
The scan-resistance fix:
A dub cuts a distinct reference clip per segment (Wave 3.2 / #486 — each line
clones its own source delivery) and falls back to the per-speaker clone for
segments under 3 s. Both paths flow through the voice-clone prompt cache — an
LRU of 8. Streaming hundreds of one-shot per-segment clips through that LRU
evicts the per-speaker and locked-profile prompts that every fallback segment
reuses, so the speaker ref was re-encoded (~0.4 s each, measured with
scripts/bench_pipeline.py) again and again across the render.
Note what this deliberately does NOT do: the bench's "166 misses vs 2 speakers"
framing suggested keying refs per speaker — but per-segment refs are the
intentional prosody-matching feature, and the re-transcription behind them is
the #1004 correctness fix. Their encode cost is the price of the feature, not
waste. The waste was only the eviction side-effect, and that's what this
removes: _get_clone_prompt(store=False) still reads the cache (a hit is free)
but never inserts, and the dub loop marks exactly the segment-scoped refs
(auto-seg: bindings and auto: bindings resolved to a segment clip) as
single-use. Per-speaker, locked-profile, and preview refs cache as before.
cache_ref is popped in generate_with_cached_ref before the model call — the
model's generate() has an explicit signature and would TypeError — and unknown
engines ignore it (**kw adapters).
The doc:
docs/performance.md is the first performance documentation in the repo — none
of the ~15 perf env vars appeared anywhere in docs/, the Performance panel's
only control is Windows-only, and slowness reports (#1032) arrived as mysteries
instead of settings checks. Covers the three classic causes of "it got slow",
where generation/dub time goes, every knob with defaults and warnings (raising
OMNIVOICE_GPU_WORKERS on a small GPU is the #567 crash, not a speedup), platform
notes, and how to run the bench so reports carry numbers. Linked from README's
install section.
Tests: store=False semantics (encodes, never inserts, still reads), the flood
scenario end to end (a speaker prompt stays warm through 3x the cache cap of
one-shots), and the pop contract (cache_ref never reaches the model). Full
suite: 2974 passed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* docs,dub: review round — qualify the per-file cache claim; note the OOM-retry tradeoff
- CodeRabbit: docs/performance.md's "the reference encode is cached per file"
now carves out the dub's per-line clips (single-use by design — nothing for
a cache to save).
- Greptile P2 (OOM retry re-encodes a single-use ref): acknowledged in a code
comment as deliberate — caching the retry's ref would reintroduce the
eviction this flag prevents, to optimize a path that only runs after an OOM
already cost seconds.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* docs(performance): probe-based torch.compile wording; honest accelerator + cache claims (review)
Greptile's repeated OOM-retry finding is deliberately skipped: retaining the
prompt across the retry would require passing prompt objects through the
adapter protocol (backend.generate takes paths), to save 0.4s on a path that
only runs after an OOM already cost seconds — the tradeoff is documented at
the call site.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: mergetest <nizam4103@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* fix(engines): an Install click during the mount status probe was silently dropped
refreshInstall serializes status requests per engine so a slow backend can't
land responses out of order (an old 'running' overwriting a newer 'succeeded'
would restart the poller forever). But the guard dropped ALL overlapping
callers, including the one that must never be dropped: the Install click's
first status refresh. If the click landed while the mount-time re-attach probe
still held the slot, the refresh returned null, the state kept the pre-install
'idle' snapshot, the 1.5s poller (which only watches 'running' jobs) never
started, and the progress panel never appeared — no error, no retry. The
backend install DID start; the UI just never showed it.
Fast machines win the race almost every time, which is why this surfaced as a
rare CI-only failure of "a failed job renders the error with its remediation
and offers Retry" (observed on #1130's run, a PR with zero frontend changes).
The inflight guard now maps id -> the in-flight promise; advisory callers (the
poller, the probe) still drop on overlap, but the click passes force: true and
waits the in-flight request out before fetching its own fresh snapshot —
strictly ordered, never dropped.
The regression test holds the mount probe open with a gated promise, clicks
during the window, and only then releases the probe — deterministic where the
CI flake was scheduler-luck. Fails before the fix (panel never renders, 3s
timeout), passes after. Full frontend suite: 1234 passed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(engines): bound the forced wait, serialize rapid clicks, reject stale responses by epoch
Round-2 review findings on #1131, both real:
- Greptile P1 "forced waiters break serialization": two rapid Install clicks
waking from the SAME awaited probe both proceeded without re-checking the
slot — two concurrent requests, out-of-order responses possible again. The
forced wait is now a loop that re-checks the map after every await.
- Greptile P1 "install inherits probe stall": a wedged probe (no abort signal)
made the forced click wait forever — trading "silently dropped" for
"silently stuck". The wait is now bounded (FORCE_WAIT_TIMEOUT_MS, 5s), and a
per-engine request EPOCH makes proceeding safe: a response may only be
applied if no newer request started since it was issued, so the wedged
request's eventual stale response is discarded instead of clobbering the
fresh 'running' snapshot. The epoch is now the actual ordering guarantee;
the inflight slot is just throttling.
- CodeQL js/missing-await on `=== req`: intentional promise-identity compare,
restructured to compare a plain { promise } wrapper object so the alert
class can't fire.
Both new tests fail against the round-1 fix (maxActive=2; panel never appears
after a 5s fake-timer advance) and pass now. Frontend suite: 1236 passed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(engines): clear the losing race leg's 5s timer (review)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: mergetest <nizam4103@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* perf(tts,dub): the reference clip was re-encoded on every chunk; the dub loaded a 3 GB model to throw it away
Two independent pieces of pure waste on the generate path, both measured with
scripts/bench_pipeline.py on a 16 GB M2 (a reference encode costs 0.40s).
1. The voice-clone prompt cache was built, then orphaned.
#427/#473 added a bounded LRU that encodes a reference clip once and reuses it,
because "every cloned generation re-encodes the reference audio from scratch".
It was wired into OmniVoiceBackend — the *adapter* path. But /generate for the
default engine forks to the *native* model path (that fork predates the cache,
#324) and passed ref_audio=<path> straight through, so the codec encoder re-ran
the reference on every model.generate() call: once per text chunk, once per
pause-span, once per audiobook segment, and once per request.
That perf PR has therefore only ever sped up /v1/audio/speech. The Generate
button never touched it.
Every native call site now goes through one helper (generate_with_cached_ref) so
the rule lives in a single place: chunked /generate, its streaming twin (#1088),
the [pause] stitcher (#276), and the audiobook renderer. Saving is
0.40s x (calls - 1): ~3.6s on a 10-chunk text, ~66s on a 166-segment audiobook.
Same-class bug found in the same cache: /v1/audio/speech accepts
preprocess_prompt, but the adapter dropped it before it reached the model AND
the cache key omitted it — so the flag was silently ignored, and honoring it
without keying on it would have served (and poisoned) the wrong prompt. Both
fixed together.
2. A dub loaded the TTS core just to free it again.
The transcribe preflight called get_model() — pulling in the ~3 GB TTS model —
for one reason: to read a preloaded `_asr_pipe` off it. That attribute only
exists under OMNIVOICE_PRELOAD_TTS_ASR, which is off by default. So every dub
loaded the model, harvested None, had offload_tts_for_asr() free it 60 lines
later (on unified memory that is a full UNLOAD, #1119), and then cold-reloaded
the same model in dub_generate (~8s). Load -> unload -> reload, for an attribute
that was always None. It now loads only when there is something to harvest.
Also fixes a latent NameError: asr_on_vocals was assigned only inside the
model-loaded branch but read from _gen_body, so an early preflight bail raised
NameError instead of the real error.
Tests: the existing cache tests passed the whole time the cache was dead, because
they test the cache in isolation with a stub model. The new tests assert the
wiring instead — that a real render encodes the reference ONCE regardless of how
many generate calls it takes. All four encode-count tests fail before this change
and pass after; the dub tests likewise.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* docs(changelog): the reference re-encode and the dub's throwaway model load (#1130)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(memory): unloading the TTS model must drop its cached reference prompts too
Follow-on to the cache wiring in this PR, and a real gap it opened.
clear_clone_prompt_cache() was called from exactly one place:
OmniVoiceBackend.unload(). That was sufficient while the prompt cache was
adapter-only — but the native /generate path now populates it, and the native
path unloads through model_manager (idle_worker, _offload_unified_memory), not
through the adapter. So cached prompt tensors would have survived an unload.
That directly undercuts #1119: on unified memory offload_tts_for_asr() sets
model = None precisely to hand the RAM to the ASR model. Prompts left behind sit
in the memory the unload was trying to reclaim. The tensors are small (integer
codes, not waveforms), so this is hygiene rather than a leak — but "unload means
unload" is the whole point of that change, and the next thing cached here might
not be small.
model_manager.release_tts_side_caches() is now called wherever the global model
is dropped. Best-effort by construction: cache hygiene must never be able to
break an unload, because a failed unload is how the backend gets OOM-killed.
The test binds services.tts_backend at CALL time, not import time: several suites
purge sys.modules["services.*"] for DB isolation (test_model_load_timeout,
test_model_manager_preload), so a module-level alias goes stale mid-run and the
assertion would inspect a different module's cache than the code under test just
filled. Production already imports it at call time.
Full suite: 2968 passed, in both deterministic and random order.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(tts): keep the prompt cache best-effort, and stop the unload hook closing an import cycle
Three review findings, all real.
1. Greptile P1 — the shared helper dropped the inline fallback.
OmniVoiceBackend.generate() has always caught a failure from
generate(voice_clone_prompt=...) and retried with the inline ref, so the cache
stays a pure latency optimization. generate_with_cached_ref did not: a model that
rejected a precomputed prompt would have turned a working /generate, streaming
render, or audiobook job into a hard error. Moving the native path onto the cache
would then have made it LESS robust than before it was cached at all.
The helper now carries that fallback, and OmniVoiceBackend delegates to it
instead of keeping a second copy. Two subtly-diverging copies of this logic is
precisely how the cache ended up wired into the adapter and nowhere else; there
is now exactly one.
2. CodeQL — cyclic import.
release_tts_side_caches() imported services.tts_backend, which already imports
model_manager: a real cycle, not a false positive. A registration hook fixed the
cycle but replaced it with a worse problem — the hook runs at import time and
pulls model_manager (and core.config) in earlier than before, which perturbs
DATA_DIR binding and broke test_longform_jobs::test_route_handler_returns_jobs_envelope
in the full suite (passed in isolation, failed in order — caught locally, not in CI).
It now reaches the module through sys.modules instead: no import, no cycle, no
import-time side effect. And it is the more correct expression of the invariant
anyway — a module that was never imported has no cache to clear.
3. CodeRabbit — the audiobook and streaming call sites had no encode-count test.
Added one for the audiobook synth path (the worst case: hundreds of segments on
one voice).
New tests fail before their respective fixes: stripping the try/except from the
helper fails the prompt-rejection test.
Full suite: 2970 passed, deterministic and random order.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: mergetest <nizam4103@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Every performance question this week ("can we batch by cores?", "why is dubbing
slow?") was answerable only by measuring, and twice the intuitive answer was wrong:
* Concurrency on Apple Silicon buys NOTHING. Measured, 4 segments:
1 worker 19.3s | 2 workers 20.7s (0.93x) | 3 workers 19.2s (1.00x)
One GPU, already saturated — extra workers interleave. Scaling the GPU pool by
free RAM (the "intelligent batching" that sounds obviously right) would have
added OOM risk on a 16 GB box for zero throughput. _pick_gpu_workers()'s
hardcoded `MPS -> 1` is correct, and now provably so.
* The clone-prompt cache misses on every segment (a dub writes one reference per
segment: 166 distinct keys, cache can never hit). That looked like the dub's
hidden cost. It is 0.40s/segment — ~2% — and it is not even waste: each
reference is genuinely different audio, and encoding it is the *feature*
(per-line prosody). Dropping to per-speaker refs would save ~65s/dub and cost
quality. Not a free win; not taken.
What actually dominates is TTS itself, which scales with text length (3.2s for a
short line, 8.7s for a 2.5x longer one) and is GPU-bound on a GPU that one
inference already fills.
The profiler is deliberately gentle with memory, because a profiler that OOMs the
machine reproduces the very bug class it exists to fix (#1119): stages run one at a
time, models are unloaded between them, a stage is SKIPPED if free RAM is under the
floor rather than starting a load the OS would kill, and each measurement is a fixed
small number of passes — no looping to convergence.
Co-authored-by: mergetest <nizam4103@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(asr): Apple Silicon dubbing ran Whisper on the CPU — pick the engine by hardware
_auto_detect() probed WhisperX first, unconditionally, with no device check. WhisperX
is always installed, so it always won — and WhisperX (like faster-whisper) is CTranslate2,
which has NO Metal backend. On every Mac, dub transcription therefore ran whisper-large-v3
on the CPU while the GPU sat idle. The MPS branch below it was unreachable in practice.
Measured on an M2, one 30 s dub chunk of large-v3:
WhisperX (CPU) 90.4 s <- 3x SLOWER than realtime
MLX (GPU) 20.5 s
MLX (GPU) + forced align 20.3 s <- ~4.4x faster, identical word timings
That is a 16-minute video taking ~48 minutes and looking like a hang. It is also why the
slowest chunks exceeded OMNIVOICE_TRANSCRIBE_CHUNK_TIMEOUT_S (120 s) and were ABANDONED
after 2 attempts (chunks 33/34 in the reported run), losing transcript outright — while
#730's advice blamed a "VRAM-starved GPU" on a machine with no VRAM.
The engine pick is now device-aware: Apple Silicon gets MLX, everywhere else is unchanged
(on CUDA, WhisperX already uses the GPU and remains the default).
Crucially this does NOT buy speed with lip-sync accuracy. WhisperX's value is its wav2vec2
forced alignment (±10-30 ms word boundaries vs Whisper's ±100-300 ms), and dub lip-sync
depends on it. Alignment takes *plain segments*, so it is independent of whichever engine
produced them: it is extracted into forced_align() and layered on MLX's GPU transcript.
Verified — the boundaries agree with WhisperX's (multiple: 0.62 vs 0.71; different: 1.57 vs
1.55) and every word carries timing. Alignment prefers MPS (20.3 s vs 28.4 s, byte-identical
timings) and falls back to CPU rather than silently dropping to loose timestamps.
Also fixes the ETA, which was pure fiction: TranscribeOverlay estimated
`ceil(duration/60)*3 + 8` seconds — an assumption of ~20x-realtime transcription. For a
16-minute video it predicted 56 s against a real ~48 min, then clamped to "~0s remaining"
with the bar frozen at 95% for the rest of the job. The backend already streams a real
progress fraction (dub_core.py emits `progress` on every `segments` event) and the UI simply
ignored it. It now extrapolates from the observed rate, and shows nothing until it has a
rate to extrapolate from.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* style: oxfmt
Formatting only — no behaviour change. CI's format:check gate (not run locally
before the push) rejected the two new/edited dub files.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: mergetest <nizam4103@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
core/analytics.py reads POSTHOG_PROJECT_TOKEN from its own environment at RUNTIME,
but the backend runs on the *user's* machine, where nothing sets it. So in a shipped
build token_configured() was false forever: every backend event — including the
speech_generated capture — was silently dropped, no matter what secret CI held. Only
the frontend half ever worked, and nothing would have told us.
The token is really a build input. release.yml already passes the POSTHOG_PROJECT_TOKEN
secret to the tauri-action step as VITE_POSTHOG_KEY, and that step compiles the Rust
shell as well as the frontend bundle — so option_env! bakes it into the shell on exactly
the builds that ship it, and spawn_backend() hands it to the child process.
The guarantees are unchanged and now pinned by tests:
- no token baked in (every source build) => nothing passed => no destination => the
backend cannot transmit, and the toggle isn't offered;
- a real process env var still wins, so a dev can point a local run at their own project;
- consent remains a separate gate (prefs, default off) — a destination alone sends nothing.
Hardened against silent recurrence, since this failure mode is invisible: build.rs gets
rerun-if-env-changed (option_env! is compile-time, so a cached build would otherwise keep
the token it first saw), and tests assert the whole chain — release.yml still passes the
secret, backend.rs still bakes it, build.rs still busts the cache.
Also fixes a CHANGELOG contradiction that would have shipped in the release notes: the
Usage-panel entry still claimed PostHog "was proposed and rejected ... there is no
analytics service, no token", directly under two entries announcing it.
Co-authored-by: mergetest <nizam4103@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(analytics): wire posthog-js — consent-gated, autocapture OFF
The owner supplied the standard snippet:
posthog.init(TOKEN, { api_host, defaults: '2026-05-30' })
Shipping that verbatim would have broken the guarantee we just made, twice:
1. It initialises AT MODULE LOAD — it starts tracking every user before they
have consented to anything. The README now says "OmniVoice sends nothing out
of the box"; this would have made that false on the very next release.
Analytics is therefore started ONLY after the user opts in (Settings →
Privacy), and the stored consent is what restores it at launch.
2. posthog-js AUTOCAPTURES by default, and `defaults: '2026-05-30'` turns that
on. Autocapture sends the text content of the DOM elements a user interacts
with. In THIS app the DOM holds the script they are about to synthesise,
their voice names and their file names — exactly the content we promise never
leaves the machine. It is explicitly disabled, along with session recording
(which records the screen) and pageview capture.
utils/analytics.ts: hardenedConfig() — autocapture false, disable_session_recording
true, capture_pageview/pageleave false, mask_all_text + mask_all_element_attributes
as defence in depth, and opt_out_capturing_by_default so init alone can never
capture. Events pass sanitizeProps(), mirroring the backend allowlist: a key not
on it is DROPPED and long strings refused, so a future caller cannot leak content
by adding a field. Backend down / no consent / no destination → stays off.
The token is taken from VITE_POSTHOG_KEY at BUILD time and is never committed —
a token-shaped literal trips the secret scanner and is a bad habit regardless.
release.yml injects it from a repo secret; the backend already reads
POSTHOG_PROJECT_TOKEN the same way. No token => no destination => the Privacy
toggle isn't offered and nothing can be sent, which is the right default for a
source build. A test fails if a phc_ literal is ever committed to that file.
posthog-js added to frontend/package.json; root bun.lock regenerated and
`bun install --frozen-lockfile` verified (the Docker gate).
11 tests: autocapture/session-recording/pageview off, starts opted-out, allowlist
drops text+paths+names, long strings refused, consent honoured in all three
failure directions, and no token literal in source. Frontend suite 1229 passed.
* test(analytics): guard the committed-token rule in the suite, not just in the scanner
The frontend typecheck failed on the guard I added: it reached for `node:fs`,
which has no type definitions in the frontend tsconfig (and would have been
cwd-dependent at runtime anyway). Wrong layer.
Source-scanning guards in this repo are Python tests (test_no_hardcoded_cjk,
test_no_literal_borders), so this one moves there — and gets strictly stronger
in the process: it scans every tracked file rather than analytics.ts alone, and
matches a PostHog key by SHAPE (phc_[A-Za-z0-9]{20,}), so a *different* key
can't slip through where the old test only knew about the one gitleaks caught.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: mergetest <nizam4103@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
offload_tts_for_asr() exists to make room before WhisperX large-v3 (~3 GB) loads
for a dub. On CUDA it moves the TTS model to CPU. On Apple Silicon it did
NOTHING — an early return with the comment "MPS / CPU / DirectML don't benefit
from manual offloading".
That reasoning is right about the STRATEGY and wrong about the CONCLUSION. On
unified memory, moving a model "to CPU" frees nothing, because it is the same
physical RAM. But that means the fix is to RELEASE the model — not to skip
making room altogether.
Measured on a 16 GB M2, at the moment a dub begins:
TTS model resident 3,107 MB
backend footprint 4,170 MB
free RAM 4.17 GB
large-v3 then wants ~3 GB of that, alongside the app and macOS. The OS kills the
backend mid-transcription, and the stream "drops before emitting any segments".
On a unified-memory host the TTS model is now actually released when free RAM is
below a headroom threshold (default 6 GB, OMNIVOICE_UNIFIED_OFFLOAD_HEADROOM_GB),
and left warm when there's room — so a roomy machine pays no reload. get_model()
lazily reloads it on the next generation, so restore is correctly a no-op. The
CUDA path is untouched.
Verified end to end in a real process: model loaded → offload_tts_for_asr() →
`mm.model is None` and free RAM recovered. Previously it returned immediately and
freed nothing.
6 tests (releases when tight / stays warm when roomy / restore is a no-op /
no model is a no-op / a failing probe never aborts the dub / CUDA path unchanged).
This is a CAUSE, not another error-message fix.
Refs #1119#1113
Co-authored-by: mergetest <nizam4103@gmail.com>
Reported on v0.3.21, which ALREADY had the #1098 fix. The user still got
"Transcribe stream dropped… Likely ASR backend failed to load" — the guess that
fix was supposed to retire.
Why: streamDropError() consults the crash marker before falling back to the
guess, but it asked exactly ONCE, at the instant the stream dropped. The shell
learns of a dead backend from a ~2 s poll — it must notice the child exit and
write the marker. So the check raced that poll and lost: no marker yet ⇒ "no
crash" ⇒ fall back to the guess, even when the backend had just died.
That is precisely the race #1102 fixed for apiFetch. This path never got it — I
fixed the symptom in one place and left the identical bug in the other.
streamDropError now polls for the marker across a short window (8 s, 1 s apart)
before believing there was no crash, so a late-arriving marker is found and the
user gets the real cause — exit code + captured stderr, one click from the crash
notice — instead of a guess. Outside the Tauri shell there is no marker to wait
for, so it asks once and returns immediately (no 8 s stall for a browser/Docker
user). Injectable sleep/clock so the race is directly unit-testable.
3 new tests (a LATE marker is found, not missed / no marker ever still yields the
caller message / no shell asks exactly once). Frontend suite 1218 passed.
Fixes#1119
Co-authored-by: mergetest <nizam4103@gmail.com>
The rejected PR #1110 had a genuinely careful PII-free event design, but shipped
three things a local-first app can't: exception autocapture ON (raw tracebacks —
home paths, and in this codebase HF tokens out of exception messages — bypassing
core.failure.sanitize() entirely), no user consent or disclosure, and 3,069 lines
of PostHog wizard scaffolding. This is the same capability with those fixed.
core/analytics.py, three rules, each enforced and tested rather than promised:
1. OFF unless the user says yes. TWO gates must both be true: a build-provided
POSTHOG_PROJECT_TOKEN *and* the user's analytics_enabled pref, default False.
A default install transmits nothing, so "nothing leaves your machine" stays
literally true for everyone who doesn't opt in. A broken prefs file fails
CLOSED. OMNIVOICE_ANALYTICS_DISABLED=1 is a hard kill switch above both.
Withdrawing consent tears the client down immediately — no restart.
2. NO exception autocapture. Explicitly disabled; a test asserts the constructor
arg, because the SDK's default is the leak.
3. Metadata ONLY, by allowlist. Every property passes sanitize_properties(),
which DROPS any key not on _ALLOWED_PROPS and refuses long strings — so no
future caller can leak a take's text, a path, or a voice name by adding a
field. text_length is the LENGTH; the text itself has no way through.
The person id is a random per-install UUID — not hardware, hostname, or username.
UI: Settings → Privacy → "Help improve OmniVoice" states in the panel exactly
what is sent, exactly what never is, and that it can be turned off — rather than
burying it in a policy. No destination in the build (any source build) → the
toggle isn't shown, because an inert switch would be a lie.
Docs: README FAQ answers "does OmniVoice collect any data about me?" honestly.
Also fixed a bug I'd introduced in my own wiring: the generation event referenced
variables not in scope, and the call site's bare `except: pass` swallowed the
NameError — so the event would have silently never fired. The call site now logs.
12 tests (default-off / opt-in without token still can't transmit / both gates /
kill switch / consent withdrawal / prefs failure fails closed / allowlist drops
text+paths+names / long strings refused / autocapture OFF / never raises /
random install id). Backend 2936 passed; frontend 1211 passed.
Refs #1110
Co-authored-by: mergetest <nizam4103@gmail.com>
A v0.3.21 user hit "Can't reach the local OmniVoice backend — it may still be
starting up, or it stopped" — on the release that was supposed to end that class.
Reading the report tells us what happened WITHOUT reproducing it: they got the
generic message, not the crash story. On 0.3.21 apiFetch consults the crash
marker, and a real process death always writes one. No marker ⇒ the backend did
not die. And the shell was still reporting `ready` ⇒ the process was alive.
So both halves of that sentence were false: it had not stopped, and it was not
starting. It was ALIVE and not answering — a job wedged holding the engine
(troubleshooting §14: a generate/transcribe too heavy for the available memory
starves the worker). Telling that user to "restart the app" is the wrong advice
for a stuck job, and it buries the real cause.
When the reconcile window expires and the shell STILL says `ready`, we now know
the process is running, so say that: name the wedged-job cause, point at
Settings → Logs → Backend for what it was last doing, and at a smaller
model/engine as the usual fix. The genuine "stopped or starting" message stays
for the case where the shell has no idea (no shell — browser/Docker), and the
crash story still wins whenever a marker exists.
This does not claim to stop the wedge — it stops the app from lying about it,
and gives the next reporter the right words.
2 new tests; frontend suite 1213 passed.
Refs #1113
Co-authored-by: mergetest <nizam4103@gmail.com>
An Intel-Mac user got "Backend process exited (never started) — no error output
captured", and reported that Retry and Clean & Retry did nothing at all. Both
symptoms have one cause, and it destroys EVERY precise bootstrap diagnosis — not
just the Intel one.
ensure_venv_ready() diagnoses the real reason a start failed (Intel Macs can't
run the backend — PyTorch ships no macOS x86_64 wheels, #889; a failed uv sync;
a blocked GitHub) and records it via fail() as Failed{that reason}. It then
returns None, spawn_backend returns None, and spawn_backend_and_wait — seeing no
child — OVERWROTE the stage with the generic "Backend process exited (never
started) — no error output captured". The honest cause was written and
immediately bulldozed.
Which also explains the dead buttons: the UI's hint matcher keys off the
specific message text, so with it gone the Intel hint ("retrying can never
help") never fired. The user was offered a Retry that re-failed identically
every time, looking like the button did nothing.
- bootstrap.rs: already_diagnosed() — a caller that knows the CAUSE outranks one
that only knows the SYMPTOM. When the stage is already Failed, the spawn
watcher keeps it. A real exec failure still forms the generic message (it
writes its diagnostic to backend_err.log and leaves the stage un-Failed), and
a genuine post-start crash is untouched.
- BootstrapSplash: isUnrecoverableFailure() — an Intel Mac can never be retried
into working, so don't offer the dead end; say so instead. Keyed off the same
hint the matcher produces, so the two can't drift.
3 Rust tests + 2 frontend tests. Rust 81 passed; frontend 1213 passed.
Fixes#1112
Co-authored-by: mergetest <nizam4103@gmail.com>
The version was already in the Models & Engines masthead, but on Setup and
Installing it was buried in a footer as "OVS · v0.3.x" — the two screens a user
is most likely to screenshot when something goes wrong during install. Move it up
beside the app name on both, so all three acts of the first run (setup →
installing → models & engines) carry the same masthead and any screenshot
identifies the build at a glance. The footers keep their real content (the
download total on Setup); the duplicate version line is gone.
Frontend suite 1211 passed (incl. the css-token guard, which is what catches a
token that doesn't exist and silently renders nothing).
Co-authored-by: mergetest <nizam4103@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* feat(privacy): Settings → Usage — local-only insights, the answer to cloud analytics
A PostHog integration was proposed and rejected (PR #1110, closed): sending
usage events to a third-party endpoint would break the one promise this product
is built on — nothing leaves your machine — and local-first is the reason people
choose it over ElevenLabs. But the question analytics was meant to answer ("how
am I using this?") is a fair one, so answer it locally.
services/local_stats.py aggregates the history the app has ALREADY written to
the user's own SQLite DB: takes, audio produced, compute time, starred, active
days, voices/dubs/projects/exports, and distributions by mode and language.
GET /stats/usage serves it over loopback; Settings → Usage renders it.
The three properties that stop this becoming telemetry by accident:
- READ-ONLY. No new table, column, or event stream. Delete the feature and not
one byte of stored data changes.
- NO CONTENT. Counts and totals only — the `text` column of a take is never
read and never returned; no paths, no ids, no person. Pinned by a test that
asserts the payload contains no take text, no /Users/ path, no row id.
- NO NETWORK. There is no client, no endpoint, no token. It has no way to send
anything anywhere.
The panel states the guarantee in the UI, because a privacy promise the user
can't see isn't worth much.
Route added to the API-surface snapshot (the inventory guard caught it, as
designed — one line: GET /stats/usage).
4 backend tests (aggregation / never-leaks-content / empty install / missing
table degrades to 0) + 4 frontend tests. Backend suite 2924 passed; lint,
format, typecheck clean.
Closes the analytics question opened by #1110.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(settings): use the real --chrome-fg-dim token in UsageTab (css-token guard)
cssTokens.test.js is a frontend guard that every var(--…) a component
references actually exists — an undefined custom property with no fallback is an
invalid declaration, so the style silently does nothing. UsageTab referenced
--chrome-fg-subtle, which doesn't exist; the dim sub-label token is
--chrome-fg-dim (what the other settings panels use).
My miss: I ran the full BACKEND suite but only the two new frontend test files,
so this guard never ran locally. Full frontend suite now green (1211 passed).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: mergetest <nizam4103@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Two gaps the model-management investigation surfaced, now closed.
1. /model/loaded reported only the OmniVoice core, so a resident second engine
(mlx-audio, cosyvoice, …) and the warm dictation ASR were INVISIBLE — the
memory picture looked ~2 GB lighter than reality on exactly the boxes that
OOM. list_loaded() now enumerates the in-process engine instances (from the
generate path's cache) and the capture ASR singleton too, and adds a
`system` block: free/total RAM (and free VRAM on a dedicated GPU) plus a
low-memory advisory. Verified live: after an mlx-audio generate the panel
shows `engine:mlx-audio` and `system: {ram_available_gb, ram_total_gb}`,
where before it showed nothing.
2. services/memory_budget.py: available_memory() reads FREE memory now (device
caps only reports total, once per process) — free system RAM via psutil,
free VRAM via torch.cuda.mem_get_info on a dedicated GPU; on MPS the RAM
figure is what matters (unified memory). low_memory_warning() returns an
advisory below a headroom threshold (OMNIVOICE_LOW_MEMORY_HEADROOM_GB,
default 2). The generate path calls log_if_low() before a load, so a later
OOM kill leaves a breadcrumb pointing at the load that tipped it instead of
a silent death.
Advisory only — nothing is blocked: the OS reclaims cache, and refusing a load
on an estimate would brick machines that would cope. The single-active-engine
eviction (#1105) is what actually reclaims room; this makes the picture honest
and leaves forensics.
6 new unit tests (threshold logic / VRAM-precedence / never-raises); frontend
LoadedModelsResponse typed for the new `system` field + id shapes. Backend
suite 2918 passed; typecheck clean.
Co-authored-by: mergetest <nizam4103@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Two recon findings from the reset work, fixed properly (whole class + tests +
docs), plus the destructive reset path is now exercised end-to-end.
1. ~/.config/omnivoice/env survived every uninstall. The app persists the
model-cache location (and a possible HF_TOKEN) there via
backend/core/user_env.py, but the in-app "Remove all data" (uninstall.rs),
uninstall.sh, and uninstall.ps1 all walked past it — so a reinstall silently
inherited the old file and redirected downloads to a maybe-deleted location.
All three now remove it. It's the same expanduser("~/.config/omnivoice/env")
path on every OS, so the Windows script uses %USERPROFILE%\.config\omnivoice.
is_recognizably_ours accepts it (contains "omnivoice"); docs tables updated.
2. Disk usage measured the wrong engines dir. storage_report.default_engines_dir()
returned backend/engines (built-in engine *modules*, no venvs), while sidecar
installs live in DATA_DIR/engines/<id>. So a multi-GB IndexTTS-2 install was
invisible in the engine-venv category and rolled into data/"other". Now points
at DATA_DIR/engines and sizes the WHOLE install (venv + checkout + weights),
with the data category claiming that subtree so it isn't double-counted.
Reset hardening: extracted purge_scopes() as a pure fs function (no AppHandle),
so the actual delete loop runs in tests against a real on-disk install tree —
"everything" wipes the install but spares the venv/foreign temp/sibling folders,
a settings reset keeps content+config+models, and a poisoned data_dir="$HOME"
deletes NOTHING. This is the live drive-through of the destructive path, minus
the GUI.
Also: gitignore the node_modules symlink form (the directory rule node_modules/
never matched a worktree symlink, so it kept slipping into commits).
Tests: Rust 78 (6 new), storage_report 20 (2 new incl. once-not-twice count +
default-dir guard), frontend 1207, i18n probe green, format+lint clean.
Co-authored-by: mergetest <nizam4103@gmail.com>
2026-07-12 15:39:07 +05:30
462 changed files with 44824 additions and 3115 deletions
description:Reviews proposed changes to OmniVoice Studio 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 **OmniVoice Studio** 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
# The review voice: a panel of senior domain experts, not a linter.
# Brevity is a hard requirement (owner directive 2026-07-20): comment ONLY
# when a finding would change what gets merged.
tone_instructions:>-
Review as a panel of principal engineers: ML inference, audio DSP, desktop
systems, product polish. Cite exact lines, name the failure mode, give the
concrete fix. No filler praise; raise nits only when they change a decision.
Comment only on findings that change what gets merged: a bug, a violated house rule, a real risk. Max three sentences each: failure mode, line, fix. No praise, no diff restating, no style nits, no emojis.
reviews:
# "chill" keeps the bot from blocking merges — it comments, it does not gate.
# Hard gating lives in CI (security.yml) and the constitution's human bar.
profile:chill
request_changes_workflow:false
# Keep the walkthrough minimal: a short summary, no diagrams, no per-push
# status chatter, collapsed by default (owner: no fluff on PRs).
high_level_summary:true
# Every walkthrough gets a visual: mermaid sequence diagrams for the
# mechanics, plus (via the summary instructions) an ASCII before/after
# sketch when the PR touches UI — so each PR is reviewable at a glance.
sequence_diagrams:true
high_level_summary_instructions:>-
If the PR changes UI (JSX/TSX/CSS/Tauri windows), include a compact ASCII
before/after sketch of the affected layout or component. If it changes
behavior, include a short mermaid flowchart of the new mechanism.
review_status:true
Three sentences maximum: what changed, why, and any risk worth a human
look. No diagrams, no sketches, no file-by-file narration.
sequence_diagrams:false
collapse_walkthrough:true
review_status:false
poem:false
auto_review:
@@ -113,6 +112,24 @@ reviews:
instructions:>-
Pin actions to a major version tag at minimum. Flag any workflow that
grants write permissions it does not need.
- path:"CHANGELOG.md"
instructions:>-
Hard rule (owner-restyled 2026-07-17): the Unreleased section is a
short **Highlights** bullet list followed by ### Changed/Added/Docs/
Fixed sections whose entries are each a SINGLE one-liner ending with
the (#NNN) ref and, for community contributions, a "— thanks @user!"
credit. Flag multi-line or bold-lead paragraph entries, missing refs,
and missing credits.
- path:"frontend/package.json"
instructions:>-
This is a bun workspace monorepo: any dependency change here requires
regenerating the repo-root bun.lock in the same PR —
deploy/Dockerfile runs `bun install --frozen-lockfile`, so CI-green
does not imply Docker-green. Flag package.json dependency changes
without a matching root bun.lock diff. Also: this file is the single
source of truth for the app version — flag any version change not
mirrored in pyproject.toml, frontend/src-tauri/Cargo.toml and
backend/core/version.py in lockstep.
# Non-gating pre-merge audits of the project's hard rules (warning mode —
# the human owner is the gate, these make the checklist visible per-PR).
@@ -158,9 +175,9 @@ reviews:
finishing_touches:
docstrings:
enabled:true
enabled:false
unit_tests:
enabled:true
enabled:false
# Feed the bot the project constitution and docs, and let it accumulate
# learnings from review conversations ("@coderabbitai always/never …").
description:"Publish a rolling 'preview' prerelease (updater Preview channel) from the selected branch"
description:"Publish a rolling 'preview' prerelease (updater Preview channel). Previews ALWAYS build from main — dispatching from any other branch fails the preview-gate."
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.
## Token economy (owner directive, 2026-07-20)
- 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`).
- Mechanical rules live in deterministic tests, never in agent effort: changelog style (`tests/test_changelog_style.py`), locale parity (`tests/test_locale_parity.py`), version lockstep (`tests/test_app_version.py`), CJK (`tests/test_no_hardcoded_cjk.py`).
- 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.
## 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.
3. Merge current `main` into stale branches before judging their CI — PR-green under an old workflow ≠ main-green.
4. Gate: "Tests (backend + frontend)" green + MERGEABLE.
5. After EVERY merge: watch `main`'s own post-merge runs to green (`gh run list --branch main`). Red main = drop everything and fix.
## Change rules (see CLAUDE.md for full text)
- Root-cause the class, not the instance; fail-before/pass-after regression test; smallest correct change.
- Default behavior identical on macOS/Windows/Linux; platform-only features go behind explicit opt-in. Divergent default = P0.
- Local-first: no new required network calls; any HF download gated on installed-ness or explicit user action; all synthetic audio through the `mark_synthetic` chokepoint.
- Every user-facing string via i18n, present in ALL 21 `frontend/src/i18n/locales/*.json` with real translations.
- Docs-sync in the same PR. CHANGELOG Unreleased: quiet one-liners ending `(#N)` + `— thanks @user!` for community work, under a short `**Highlights**` list.
- 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.
@@ -6,6 +6,249 @@ 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.
## [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 OmniVoice 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
- 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 OmniVoice 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)
## [0.4.0] — 2026-07-21
**Highlights**
- Audiobooks, end to end — a real **Stop** with live per-chapter progress, a **multi-voice cast**, expressive controls, a markup toolbar, live stats, and a one-click sample
- Pick a designed voice from the **Gallery** anywhere you choose a voice — audiobook, Stories, and Dubbing
- Dub **Paste Translation** — drop in a translation or `.srt` and it maps straight onto your segments, timings intact
- Downloading a finished audiobook no longer hijacks the app — it just saves
- First run is ~2.4 GB, not ~5 GB — only the TTS model is required; ASR picks are curated per platform
- Guided mic + Accessibility permissions with Open Settings deep-links; **Parakeet TDT v3** on Apple Silicon
- Opens in your system language, with a one-tap switch back to English
- Security: server-mode admin routes can't be reached by a trusted-network client without the API key
- A render error shows a recoverable card instead of a blank window; queued and long generations stop failing with a bogus "too heavy for your hardware"
### Changed
- Settings → Models: grouped catalog (TTS / ASR / Dictation / Diarisation), "recommended for this machine" chips, incompatible models collapsed behind a toggle
- Only the TTS model (~2.4 GB) is required on first run; ASR picks are curated per platform via `curated_on` in `models.yaml` (MLX on Apple Silicon, CT2+Turbo on CUDA, PyTorch on ROCm, int8 on CPU)
- Audiobook tab tidied up: the settings column is now grouped into compact collapsible sections (Output / Book details / Pronunciation / Markup), so script + voice + Create sit up top instead of a long scroll — same controls, denser layout (#1214)
### Removed
- The Dubbing per-segment picker's hardcoded design-presets group — superseded by the richer designed-voice Gallery; already-saved `preset:` picks still generate identically (#1220)
### Added
- Voice picker: the designed-voice **Gallery** is now selectable anywhere a voice is chosen — the audiobook default voice and each Cast row can pick a gallery archetype (searchable, favourites first), and it's materialised into a real profile on pick so it just works everywhere (#1219)
- The Stories editor and the Dubbing per-segment voice pickers now use the same gallery-enabled picker, so designed-voice archetypes are selectable there too; the dub picker drops its redundant hardcoded presets group in favour of the richer Gallery (existing picks unchanged) (#1220)
- Audiobook tab: a Cast panel maps each `[voice:NAME]` in the script to a profile so multi-voice renders correctly (it previously fell back to a single voice), plus a markup insert toolbar, live stats (chapters · words · est. runtime), and pre-flight validation for unknown voices and empty chapters (#1217)
- Audiobook tab: a **Stop** button that truly cancels a running generation (not just the UI) and live per-chapter progress — a bar, elapsed + ETA, and each chapter's status (rendering / done / cached / failed); finished chapters stay cached so Create again resumes. `Cmd/Ctrl+Enter` starts a render (#1216)
- Settings → Permissions + wizard System Check: live mic/Accessibility grant state, per-OS guidance, Open Settings deep-links; dictation pre-flights the mic grant (#1175)
-`parakeet-mlx` engine: Parakeet TDT v3 on Apple Silicon — 25 EU languages, word timestamps, ~2 GB, opt-in from Settings → Models, never auto-downloads (#1175)
- First-run downloads race the direct GitHub path against the mirror and use whichever answers fastest (#1179)
- 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
-`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)
-`clone_voice` MCP tool — AI agents can clone a new voice from a base64 reference audio sample; returns a `profile_id` immediately usable with `generate_speech` — thanks @paoloantinori! (#1194)
- Dub tab: **Paste Translation** — paste a translation made elsewhere (ChatGPT, DeepL, a human) as subtitles, numbered lines, or plain lines; it maps onto the existing segments with a before→after preview, keeping timings and the source transcript intact (#1203)
- Audiobook tab: **Production Overrides** (position/class temperature, steps, guidance, postprocess, seed) for expressive narration, plus IndexTTS2 emotion controls and a "vary repeated lines" toggle — defaults reproduce today's renders exactly (#1208)
- Audiobook tab: a **Load sample** button that fills the editor with a demo story — chapters, per-character `[voice:]`, `[pause]`, `[slow]`/`[fast]`/`[emphasis]`/`[spell]`, and reaction tags — so first-timers can hit Create and hear every capability before their real work (#1214)
### CI
- The quiet changelog style and 21-locale key/placeholder parity are now enforced by plain pytest checks; CodeRabbit/Greptile carry the house rules via `.coderabbit.yaml`/`greptile.json` (#1198)
- Flush caches / Unload documented in the performance guide, incl. `POST /system/flush-memory` for scripts
- README FAQ: why a longer reference clip doesn't clone better (zero-shot 15 s cap; fine-tuning is the audiobook-grade path)
-`docs/expressive-speech.md` corrected so every recipe it names (breaths, temperature) is reachable in the surface it points to, including the Audiobook tab (#1208)
- New `docs/api-auth.md` — one place for authenticating the local API: share PIN, API key, dictation WebSocket, and trusted networks, with curl/SDK examples and what `401`/`403`/`429` mean (#1212)
### Fixed
- Downloading a finished audiobook (or story mix) no longer hijacks the app: in the desktop WebView a plain download link to the media file made WebKit navigate the whole window to it and play it fullscreen (then the blank-window guard misfired) — downloads now go through the native Save dialog + a server-side copy instead (#1218)
- The blank-window guard's fallback page is shown by injection rather than a `data:` URL the desktop WebViews refuse to navigate to, and its Reload button now returns to the app even if the window had navigated away (#1218)
- Security (server mode): the admin routes (`/system/*`, `/api/settings/*` — RCE-class) now require the API key or genuine loopback — with an API key set and `OMNIVOICE_TRUSTED_NETWORKS` configured, a trusted-network client could previously reach them with no credential; the short share PIN no longer gates admin either (#1213)
- A render error no longer blanks the whole window — a recoverable error card (Reload / Report) appears instead, and CI now builds the real production bundle so a pre-mount crash can't ship (#1209)
- 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)
- 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)
- Timeout messages stopped claiming capacity was restored automatically — the abandoned job keeps the device until it finishes, and the guidance now says to let it drain (#1190)
- The length-scaled generate budget now covers every path — streaming previews, batch dubbing, `/v1/audio/speech`, dub and archetype previews — instead of only the two classic call sites, so long inputs stop failing at a flat 300s (#1190)
- Provenance watermarking moved off the GPU worker pool: on 1-worker machines each embed was serializing ahead of the next generation (#1190)
- A batch segment that times out fails the job with a reason instead of shipping a finished-looking dub with silent gaps (#1190)
-`/v1/audio/speech` refuses work up front with 429 + `Retry-After` when the pool is saturated, and returns a retryable 503 rather than a 500 on timeout (#1190)
- Subtitle parsing no longer stalls on a blank-line-heavy `.srt`: the timing-line regex backtracked across newlines, so a mis-saved export could pin an import for hours (#1203)
- A broken ASR engine's fallback could silently auto-download multi-GB weights — every fallback now passes the same no-download preflight and shows the download CTA instead (#1189)
- Dub transcription releases the ASR model from VRAM on every exit — crashes, early errors, and client disconnects included (#1175)
- An invalid dictation model override could bypass the missing-model check for the Whisper fallback (#1175)
- The OpenAI-compatible transcription route's 409 now carries the same typed download-CTA payload as every other route (#1175)
- Cross-drive installs with a user-pinned `UV_CACHE_DIR` still keep uv's managed Python off the system drive (#1189)
- MCP `clone_voice` accepts data-URI base64, stores the clip under its real container extension, and returns backend validation errors as structured JSON (#1195)
- Sidecar launch errors no longer embed the user's home directory in logs or error messages (#1189)
- KittenTTS degrades gracefully if a future upstream update moves its text chunker (#1189)
- Restored 151 broken locale strings: mangled `{{placeholders}}` (vi/ar showed literal `_V_0__`) and gallery errors that dropped their detail in all 20 translations (#1198)
- Voice cloning no longer fails on quiet recordings — silence removal retries with gentler thresholds (then skips), and a truly silent clip gets a localized, actionable message instead of a dead-end 400 (#1188)
- Custom install folders on another drive are honored end-to-end — uv's wheel cache and Python download now follow the chosen environment folder instead of filling C: (#1186)
- Dubbing no longer fails outright when an ASR engine's dependencies are broken ("No module named 'lightning_fabric'") — the engine is marked unavailable with a repair hint and the next one is used automatically (#1185)
-`/v1/audio/speech` no longer 500s with a raw "Exec format error" when `bin/` holds a zero-byte GGUF placeholder — managed binaries are validated before exec and broken ones return an actionable 400/503 naming the repair (#1172)
- KittenTTS no longer aborts with "invalid expand shape" on digit-heavy input and no longer 500s on empty input — chunks are split to the ONNX 512-token cap and unspeakable text returns a clear 400 (#1173)
- Quitting the app while a model is still loading now shuts down clean — no "cannot schedule new futures" traceback, no crash-shaped exit or phantom crash record, and first-run/upgrade boots keep their backend log (#1174)
- Dictation falls back to the main ASR engine when a model transcribes real speech to nothing (sherpa NeMo-TDT decoder defect, upstream k2-fsa/sherpa-onnx#3767), remembers the demotion, and names the model that failed (#1175)
- A failed dictation session with no transcript clears the floating pill instead of parking it on screen forever (#1175)
- A TTS-only first run gets a one-click ASR download prompt instead of a silent 1.6–3 GB Whisper pull (dub, batch, dictation, clone-ref, `/v1` STT, boot warm-up) (#1175)
- App boot makes no Hugging Face calls and never silently downloads the TTS model — warm-up is local-cache-only (#1175)
- Quitting with a batch dub in flight no longer hangs shutdown (#1175)
- Dubbing's vocal-separation step no longer crashes on Windows dev runs (`SelectorEventLoop` sync-pipe fallback) (#1184)
- Closing the app while the model is loading is logged as a shutdown, not a phantom "Model loading failed" crash (#1183)
- The backend-crash banner sits below the navbar and stays clickable (#1182)
- Windows source builds: `bun desktop` no longer dies on a `UnicodeEncodeError` from piped status glyphs (#1181)
- The Windows app no longer boots to a black screen (production-minifier variable reorder in the splash; now gated by a real minified-bundle e2e) (#1178)
- No more storms of black console windows on Windows — every subprocess, incl. third-party spawns, runs windowless (#1178)
-`bun desktop` self-heals a stale terminal `PATH` without `~/.cargo/bin` and hints when Rust is genuinely missing (#1180)
- FunASR/SenseVoice: no more crash or speaker-identity swaps across 30 s chunks with inline diarization (#182)
- Provenance watermark now applied on every synthetic-audio route via one chokepoint (#1169)
- "Can't reach the local backend" reports unclean backend deaths with evidence in dev/Docker/LAN too (#1164)
- Failed sidebar fetches are logged instead of silently showing stale/empty lists — thanks @bultodepapas! (#1158)
- A missing/broken `mcp` package degrades to "/mcp disabled" instead of killing the backend at startup (#1156)
- "Setup failed" auto-dismisses when the backend recovers; relaunching retries instead of refocusing a dead window (#1156)
- Ended the `forrtl: error (200)` mid-session Windows crashes (math-runtime console handler) (#1153)
- Non-Latin text can't crash synthesis on Windows (backend forced to UTF-8) (#1155)
- Video-export errors diagnose the real cause (Windows 32k command-line limit; big filter graphs go via a script file) with per-mode advice (#1152)
- Remote backends with an API key get an API-key prompt (durable per-browser, one-shot `#api_key=` link) instead of an unpassable PIN form — thanks @paoloantinori! (#1154)
## [0.3.22] — 2026-07-14
The dubbing release. Dubbed videos stop sounding like a compromise: the music keeps its stereo width and full frequency range, short lines no longer leave dead air while the mouth keeps moving, one speaker stays one voice, and the language tabs finally switch the transcript with the audio. Underneath it, the memory fixes that ended the "can't reach the local backend" era on 16 GB machines ship at last — plus a sweep of never-again hardening drawn from an audit of every bug this project has ever closed.
### Added
- **A "Voice match" toggle for dubbing — keep one steady voice per speaker.** Each dubbed line clones from a snippet of its own original audio, which matches the delivery beautifully but can make the *voice itself* drift from line to line — most audibly on videos where speaker detection ran in fallback mode ("still 4 segments different in voice", as one report put it). A new control next to the Timing picker chooses: **Per line** (the default, unchanged) for the best per-line delivery match, or **Consistent** to clone every line of a speaker from one shared reference — the speaker's pooled sample, or the best single clip when none exists — for a steady identity across the whole dub. Flipping it honestly marks segments as needing regeneration, and the shared reference is encoded once and reused, not re-studied per line. (#1147)
- **A performance guide, at last.** [docs/performance.md](docs/performance.md) explains where generation and dubbing time actually goes, the three classic causes of "it got slow" (an empty Transcript field on a voice profile chief among them), every tuning knob the backend reads — none of which were documented anywhere — and which settings to leave alone (raising `OMNIVOICE_GPU_WORKERS` on a small GPU is how you get the crash the default exists to prevent). Includes how to run the built-in profiler so a slowness report can carry numbers instead of vibes.
- **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.
- **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).
### Fixed
- **Switching preview languages can't leave a mixed-language transcript.** Follow-up to the tab/transcript sync: if a track's translations were only partially stored in the browser (older projects, partial regenerations), switching tabs could show German audio with a few rows still in the previous language. Missing rows now hydrate from the app's own per-language store on the backend — and a picked regional dialect is automatically cleared when you switch to a language it doesn't belong to, wherever the switch comes from. (#1149)
- **The Export step's language tabs now switch the transcript too.** Clicking Bengali/German/Hindi… above the finished dub swapped the *video* but left the segment list showing whichever language you generated last — German audio over Bengali text. The tabs now also swap every segment's text to that language (through the same per-language store the language picker uses, so nothing is lost when you switch back); the Original tab keeps your editing language as-is, since each row already shows the original line beneath its translation. (#1148)
- **A "backend crashed" notice can no longer outlive the update that fixed the crash — and the desktop shell's self-repair paths are now pinned by tests that CI actually runs.** Crash notices now record which app version wrote them, and a notice left behind by an older version is ignored and cleaned up after you upgrade instead of resurfacing as if the new build had crashed. The Windows blank-window repair (the one-click WebView cache fix after a BSOD) also gets regression tests pinning its safety contract — one attempt per request, never touches anything unasked, never blocks startup on a locked cache — and CI now runs the desktop shell's entire Rust unit-test suite on macOS, Windows, and Linux, which it previously never executed at all. (#1145)
- **The MLX-Audio phonemizer's language model now ships with the app environment instead of being fetched mid-generation.** Follow-up to the pip fix: with the installer present, the first English MLX-Audio generation would auto-download a small model straight from GitHub — an outbound request that bypasses the app's mirror system (a problem on restricted networks) and fails offline. The model is now a pinned dependency of the managed environment: it arrives at install/update time through the normal dependency flow, and first generation works fully offline. (#1146)
- **The MLX-Audio engine's first English generation no longer trips over a missing installer.** Its phonemizer auto-downloads a small language model on first use by shelling out to `pip` — which the app's managed Python environment didn't include, so the download always failed (and before the recent containment fix, took the whole backend down with it, #1133). `pip` now ships as a real dependency of the managed environment, so it survives app updates too — anything installed ad-hoc would have been stripped by the updater's environment sync, quietly re-breaking this after every release. (#1144)
- **A voice engine's helper library can no longer shut down the whole backend.** One user's backend died 21 seconds after starting (#1133): the MLX-Audio engine's phonemizer tries to auto-download a language model on first use, the downloader is written as a command-line tool, and on failure it calls "exit the program" — which, running inside the backend, exited *the backend*. Any engine dependency written that way could do this. Exits are now contained at the engine-dispatch boundary and turned into a normal, explained error ("an engine dependency failed to auto-install something — see the log"), for TTS and transcription alike. The app keeps running; the failed request tells you what actually happened. (#1143)
- **Vietnamese years read like Vietnamese again.** A recent release started spelling out numbers before synthesis, and its Vietnamese number library turns out to be wrong for exactly the numbers people say most — years ("2024" became *"hai nghìn lẻ hai mươi bốn"*, which no Vietnamese speaker says). The voice model has always pronounced Vietnamese digits correctly on its own, so Vietnamese text now keeps its digits — the same conservative rule that already protected Vietnamese decimals. Also closes the loophole that made this depend on spelling: picking "Vietnamese" from the language list behaved differently from the code "vi". (#1139)
- **A voice profile's pinned seed now pins Audiobook renders too.** Locking a take (or a designed voice) stores a seed so the voice performs reproducibly — and the Voice page honors it, but Audiobook/Stories renders quietly ignored it and rolled fresh randomness for every segment. Book renders with a pinned-seed profile are now deterministic end to end, matching the Voice page. And the audiobook renderer's higher generation quality (32 decoding steps — the model's own quality preset, vs. the Voice page's fast default of 16) is now pinned explicitly in code rather than inherited by accident, so it can't silently change; that steps gap is also *why* Audiobook sounds steadier than Voice at default settings — move the Voice page's Steps slider to 32 for the same quality. (#1139)
- **A finished audiobook's Download button stops vanishing.** The player and Download link for a completed book lived only in the page's temporary state — switch tabs once and they were gone, which read as "no way to export at all" (the file was still on disk, and in Projects → Audiobooks). The last finished render now survives tab switches and reloads, right where the book was made. (#1139)
- **Six recurrence guards from a full audit of the project's issue history — aimed at "this bug can never come back, even after an update or reinstall."** (1) Before loading the voice model on a memory-tight machine, the app now *first releases* things it already reclaims on idle (the warm dictation model, allocator caches) — the missing half of the 16 GB OOM-kill fix; roomy machines pay nothing. (2) When the operating system force-kills the backend for running out of RAM, the crash notice now says exactly that instead of blaming "VRAM" on machines that have none. (3) Saving a *cloned* voice with free-form text in its delivery field can no longer persist a profile that errors on every future generation — the server now sanitizes all profile kinds, closing a hole that had been re-exploited three times through different clients. (4) A reinstall that inherits an old settings file pointing at an unplugged drive or deleted folder no longer sends downloads into the void — dead paths are ignored for the run with a clear log line. (5) Locally-saved UI state is now schema-checked as a whole on restore, so one corrupted field can't silently discard everything after it (the general form of the "app got empty" fix). (6) File moves across drives (Windows D:-drive installs) get a dedicated safe-move helper, so the next code path that renames across devices degrades gracefully instead of failing with `[Errno 18]`. Long texts also get a generation time budget that scales with their length instead of a fixed five minutes. (#1141)
- **Dubbed videos get their stereo back — and the music's full frequency range.** A/B-measuring a dub against its original showed the dubbed audio was **mono in a stereo container** (channel correlation 1.000 vs the original's 0.754) — the entire stereo image of the music, gone. Two causes, both fixed: the separation step was being fed the **16 kHz mono** file extracted for transcription — so the music bed inherited mono *and* an 8 kHz ceiling at the source — and the mixer then let the mono voice drag the whole mix down to mono. Ingest now makes a second, full-quality stereo extraction (44.1 kHz) just for separation, transcription keeps its mono file, and the mixer pins both sides to stereo with the voice dead-center where dubbed dialogue belongs. Loudness already matched the original (−17.2 vs −17.8 LUFS, measured); now the width and brightness do too. (#1138)
- **Dubbed lines that finish early no longer leave dead air — they now speak at the pace of the scene.** Translations routinely come out shorter than the original delivery, and the dub used to just stop early: measured on a real dub, **8.8 of 18.7 seconds of speech time had no voice at all** — the mouth kept moving on screen over the thin residue the vocal separation leaves behind, which reads as silence and as "the music got quiet". Short lines are now gently slowed toward their time slot (pitch preserved, never below 0.85× — comfortably natural), so speech covers the speaking time the way the original did. This also does most of the work people expect from "lip sync": the voice now starts *and ends* with the mouth. Near-full lines are left untouched, the per-segment badge shows the applied rate, and `OMNIVOICE_UNDERRUN_MIN_RATE=1.0` turns the fill off. (#1137)
- **The dub's background music no longer comes out quiet and muffled.** Every dub export mixes your synthesized voice over the video's separated music/ambience bed — and that mix had two fidelity bugs stacked on top of each other. The mixer *normalizes* its inputs, so the weights meant to gently favor dialogue actually played the music at **~57% of its original level** (measured); and because the voice track is synthesized at 24 kHz, the mixer silently pulled the 44.1 kHz music down to 24 kHz — deleting everything above 12 kHz: cymbals, brightness, air. The batch pipeline was harsher still, pinning the bed near 8%. All six mix sites now share one filter that resamples both sides up to 48 kHz, cancels the normalization so the music plays at **90% of its true level** (a hair of headroom keeps dialogue legible), and adds a transparent peak limiter. Measured on a real dub: bed level 57% → 90%, bandwidth 12 kHz → 24 kHz. (#1136)
- **A rate-limited translation polish pass no longer sabotages the dub — or lies about it.** The Cinematic quality mode runs an optional critique-and-rewrite pass after translating. When that pass hit a rate limit (free-tier LLM endpoints throttle hard), three bad things happened at once: the app reported **"N/N segment(s) failed"** in red over a translate that had actually succeeded; the affected segments were **silently skipped by the speech-rate fit pass and duration planner** — so overlong lines went to synthesis unfitted and came out audibly time-compressed; and the two-second "retry shortly" hint the provider sent was ignored. All three are fixed: a rate-limited call now waits out the provider's own `Retry-After` (bounded, once) and usually just succeeds; a segment that still misses the polish keeps its plain translation, **stays in every downstream fitting pass**, and is reported honestly — "translated, polish skipped" as a warning with the reason, not a failure. Rows that really failed still say so. (#1135)
- **Dubbing kept re-studying the same speaker's voice, hundreds of times per video.** Each dubbed line clones from a clip of its own source audio (that's what makes deliveries match), and lines too short to clone from fall back to a per-speaker sample. But the app's memory for already-studied voices only holds 8 — and a long dub streams *hundreds* of one-shot per-line clips through it, each pushing out the per-speaker samples that every other line needs. Result: the speaker sample was re-studied (~0.4 s, measured) over and over. One-shot clips are now studied without displacing anything, so the per-speaker samples stay warm for the whole dub. Nothing about the audio changes — same clips, same voices, less repeated work. (#1132)
- **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)
- **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)
- **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)
- **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)
- **Analytics you switched on would have stayed half-dead.** The backend half of the new opt-in analytics read its destination from an environment variable that nothing on your machine ever set — so in a shipped build it could never send anything, silently, no matter what you chose. Only the frontend half worked. The destination is now baked into the desktop shell at build time and handed to the backend when it starts, so "on" means on. Nothing else changes: it stays off until you opt in, builds from source still have no destination at all, and the property allowlist still decides what may leave. (#1123)
- **A dub that dies mid-transcription still guessed at the cause.** v0.3.20 taught it to check the crash report before blaming the ASR model — but it checked *instantly*, the moment the stream dropped, and the desktop shell needs about two seconds to notice the backend died and write that report. So it kept looking too early, finding nothing, and falling back to the same old guess ("Likely ASR backend failed to load") even when the backend had in fact just crashed. It now waits for the shell to catch up, so you get the real cause — exit code and error output — instead of a guess. (#1119)
## [0.3.21] — 2026-07-12
The memory release. The reason the app kept saying "Can't reach the local backend" on 16 GB machines was never really the network — the backend was quietly running out of memory and getting killed. This release fixes that at the source: the models it holds now get out of each other's way. Plus the uninstaller and factory reset grew into a proper Settings → Storage pair.
@@ -26,6 +269,10 @@ The memory release. The reason the app kept saying "Can't reach the local backen
- **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.
- **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.
@@ -138,7 +385,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.
@@ -487,6 +733,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
@@ -15,7 +15,7 @@ Everything else (new engines, fancy features) is downstream of "the thing instal
- **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.
- **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**: Auto bug reporting (new addition) must be **opt-in**, must submit only to GitHub Issues (no third-party telemetry endpoint), and the app must remain fully functional with reporting disabled. No required cloud calls, accounts, or API keys.
- **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.
<!-- GSD:project-end -->
@@ -24,7 +24,7 @@ Everything else (new engines, fancy features) is downstream of "the thing instal
The May-2026 stack research that used to live here served five capabilities that have all since shipped (HF-token Settings panel, prefilled-URL bug reporting, uv mirror fallback for restricted networks, the Supertonic-3 engine, in-repo Markdown docs). Follow the patterns in the code itself; the durable *don'ts* that research established:
- **No third-party telemetry endpoints, ever** (`sentry-tauri` was evaluated and rejected) — bug reporting stays opt-in via prefilled GitHub-issue URLs.
- **No third-party endpoints for bug reporting or crash dumps** (`sentry-tauri` was evaluated and rejected) — bug reporting stays opt-in via prefilled GitHub-issue URLs, submitted from the user's own browser. The one sanctioned third-party endpoint is the opt-in PostHog EU product analytics (owner-set 2026-07-16), which is consent-gated behind the first-run prompt, ships allowlisted content-free metadata only, and must never grow exception/DOM autocapture. Its publishable write-only project token is committed in-repo (owner reversal 2026-07-20, #1193 — source builds get the same consent-gated analytics as installers; env/baked token overrides), allowed by `tests/test_no_committed_analytics_token.py` in exactly `backend/core/analytics.py` + `frontend/src/utils/analytics.ts`.
- **No PAT/token-based GitHub posting from the app** — the user submits from their own browser.
- **Don't recommend `setx` for env vars on Windows** (silent truncation, no current-shell propagation) — use the in-app Settings panel or PowerShell `[Environment]::SetEnvironmentVariable`.
- **Don't adopt Material for MkDocs** for any future docs site (maintenance mode since Nov 2025) — Astro Starlight is the precedent if docs ever outgrow the repo.
@@ -42,12 +42,14 @@ 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 = the existing house style: a one-paragraph headline, then`### Added` / `### Fixed` / `### Changed` / `### License` / `### CI` subsections; each entry is a **bold one-line lead**(what the user gets), 1–3 lines of plain-English why, and the `(#NNN)` issue/PR ref — grouped by theme, written for users, **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.
**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.
**Localization (hard rule):** No hardcoded non-English (CJK) **user-facing text** anywhere in the codebase except the translation layer (`frontend/src/i18n/`). All UI strings go through i18n (`t('...')` keys in `locales/*.json`); native language names live in `i18n/index.ts` (`LANGUAGES`). Functional CJK is allowed and tracked via the allowlist in `tests/test_no_hardcoded_cjk.py` — text-processing regexes, model/engine vocabulary & identifiers (e.g. CosyVoice speaker IDs), localized error matching, demo/eval data, and test fixtures. CI fails on any hardcoded CJK outside the allowlist; to add legitimate functional CJK, extend `_ALLOWED_FILES` there with a justification.
**Release deployment channels (hard rule, owner-set 2026-07-16):** a version bump is not "released" until **every** deployment channel ships it — the full checklist (sources, producing workflows, per-channel verification) lives in `docs/RELEASING.md` §5b. The channels: GitHub Release with 4-platform installers + signed `latest.json` (Stable updater channel, body from CHANGELOG); the Preview updater channel; GHCR **and** Docker Hub images in **both** flavors (CUDA `:X.Y.Z`/`:X.Y`/`:stable`, ROCm `-rocm` suffixes); the Docker Hub overview page synced from `deploy/dockerhub-overview.md` (its sync step is `continue-on-error` and 403s silently if `DOCKERHUB_TOKEN` lacks description-edit scope — verify the **step log**, never just job-green). Verify all channels after tagging; a missing channel is a release bug to fix immediately, not backlog. **Preview/RC always sources from `main`:** there are no RC tags — the rolling preview channel (preview `latest.json`, Docker `:latest`/`:main`/`:rocm`) *is* the RC, it always builds from `main` (release.yml's preview-gate refuses other branches), and previewing a fix means merging it to `main` first. Never cut a side-branch build.
**Fix quality (hard rule, owner-set 2026-06-16):** Fix issues *properly* and future-maintenance-proof — don't stop at the symptom. Root-cause fully, fix the whole **class** of the bug (not just the one reported instance), add a fail-before/pass-after regression test, and harden against recurrence (e.g. if a lockfile drift only fails in Docker, also make CI catch it). Go the extra mile where it durably pays off. Be token-efficient about it — extra **effort**, not extra **verbosity**: no padding, no redundant re-checks, the smallest correct change that is also recurrence-proof. Don't be shy to spend the effort a proper fix needs; do be shy about wasting tokens.
**Keep main green (hard rule, owner-set 2026-06-16):** A merge must **never break `main`'s CI**. Before a change lands, verify the *full* CI matrix would pass — every workflow in `.github/workflows/`**and**`deploy/Dockerfile`, not only the checks you happened to run. Dependency / lockfile / config changes must be validated against **all** consumers. Specifically: `frontend/` is a bun **workspace monorepo** — the lockfile is the repo-root `bun.lock`, and `deploy/Dockerfile` runs `bun install --frozen-lockfile`, so any `frontend/package.json` change requires regenerating root `bun.lock` and confirming `bun install --frozen-lockfile` passes (plain `bun install` in `ci.yml` silently tolerates drift, so CI-green ≠ Docker-green). Likewise re-check CodeQL/Security on code changes and the Tauri `cargo` build on Rust/dep changes.
@@ -71,6 +73,12 @@ No project skills found. Add skills to any of: `.claude/skills/`, `.agents/skill
## Workflow
Direct repo edits are authorized (owner decision, 2026-07-08). The GSD command gate that used to live here referenced `/gsd-quick` / `/gsd-debug` / `/gsd-execute-phase` skills that are not installed in this environment; the owner chose to keep working directly rather than restore them. The working conventions that matter are in **Conventions** above — versioning, docs-sync, changelog, localization, fix quality, keep-main-green — plus: gate every merge on the "Tests (backend + frontend)" check passing and the PR being MERGEABLE, and check the open-PR queue before implementing any community-reported fix (contributors may have already submitted one).
**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.
**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).
> **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.
<div align="center">
| 🔑 No API keys | 🙅 No accounts | ☁️ No cloud | 💳 No subscription |
|:---:|:---:|:---:|:---:|
| nothing to paste in | nothing to sign up for | your audio stays home | it's your computer |
</div>
> [!WARNING]
> **OmniVoice Studio is in active beta.** Things may break between releases — for the latest features and fixes, clone the repo and run from source rather than the pre-built installers. Bug reports and PRs are very welcome: [open an issue](https://github.com/debpalash/OmniVoice-Studio/issues) or [join Discord](https://discord.gg/bzQavDfVV9).
<sub>Get setup help · Share your dubs · Vote on the roadmap · Early access to new engines</sub>
<br/>
</div>
<br/>
> **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).
<sub>Browse ready-made archetype voices with language filters — or build your own library.</sub>
<sub>Browse ready-made archetype voices with language filters, or build your own — then pick any of them in Studio, Audiobook, Stories, and Dubbing.</sub>
<td align="center" width="20%">⌨️<br/><b>Dictation Widget</b><br/><sub><kbd>⌘⇧Space</kbd> in any app</sub></td>
<td align="center" width="20%">🔐<br/><b>100% Local</b><br/><sub>No keys, no cloud, no accounts</sub></td>
<td align="center" width="20%">🤖<br/><b>MCP Server</b><br/><sub>Use from Claude, Cursor, …</sub></td>
</tr>
</table>
@@ -191,39 +152,21 @@ The eight headliners — and twelve more waiting under the fold.
<a href="https://github.com/debpalash/OmniVoice-Studio/releases/latest"><img src="https://img.shields.io/badge/Windows-MSI_(x64)-0078D4?style=for-the-badge&logo=windows&logoColor=white" alt="Download Windows MSI" /></a>
<a href="https://github.com/debpalash/OmniVoice-Studio/releases/latest"><img src="https://img.shields.io/badge/Linux-AppImage_(x64)-FCC624?style=for-the-badge&logo=linux&logoColor=black" alt="Download Linux AppImage" /></a>
<br/>
<sub><b>macOS:</b> first launch needs a one-time approval — right-click → <b>Open</b> (or System Settings → Privacy & Security → <b>"Open Anyway"</b> on macOS 15). No Terminal needed. <a href="docs/install/macos.md#gatekeeper-quarantine">Why?</a></sub>
<br/>
<sub><b>Intel Macs are not supported for the local backend:</b> the app UI installs, but the Python backend cannot run because PyTorch no longer ships Intel-Mac (x86_64) wheels (<a href="https://github.com/debpalash/OmniVoice-Studio/issues/889">#889</a>) — see <a href="docs/install/macos.md">docs/install/macos.md</a>.</sub>
<sub><b>macOS:</b> first launch needs a one-time approval — right-click → <b>Open</b> (or System Settings → Privacy & Security → <b>"Open Anyway"</b> on macOS 15). No Terminal needed. <a href="docs/install/macos.md#gatekeeper-quarantine">Why?</a> · <b>Intel Macs:</b> local backend unsupported (<a href="https://github.com/debpalash/OmniVoice-Studio/issues/889">#889</a>) — <a href="docs/install/macos.md">details</a>.</sub>
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).
- **Something broke?** Run the self-check — **Settings → About → "Run self-check"** (or `uv run python backend/main.py --diagnose --deep`) — then the [top 10 install errors](docs/install/troubleshooting.md). **"Save diagnostic bundle"** packages scrubbed logs for a bug report.
- **Feels slow?** [docs/performance.md](docs/performance.md) — where the time goes and how to tune it.
- **Want breaths, laughter, emotion?** [docs/expressive-speech.md](docs/expressive-speech.md) — what each engine can do today.
@@ -275,20 +218,14 @@ Professional-grade voice AI, minus the subscription and the cloud.
| **Python** | 3.10+ (managed by `uv`) | 3.11–3.12 |
| **GPU** | Optional — CPU works | NVIDIA CUDA · Apple Silicon MPS · AMD ROCm (Linux only) |
> [!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).
> [!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)). **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)).
> [!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).
> **A GPU is optional** — the whole pipeline runs on CPU (just slower), and on ≤8 GB VRAM, TTS auto-offloads to CPU. Caveats: **AMD ROCm** is Linux-only + opt-in ([Linux](docs/install/linux.md#amd-gpu-rocm)) — Windows AMD/Ryzen AI is CPU-only ([Windows](docs/install/windows.md#gpu-support)); **macOS Intel** can't run the local backend, so point it at a remote one ([#889](https://github.com/debpalash/OmniVoice-Studio/issues/889) · [macOS](docs/install/macos.md)).
<a id="tts-engines"></a>
### 🗣️ TTS Engines
**14 engines, one picker.** OmniVoice (default, 600+ languages) is always available; CosyVoice 3, GPT-SoVITS, VoxCPM2, MOSS-TTS-Nano, KittenTTS, MLX-Audio, and Sherpa-ONNX are opt-in and auto-detected — plus six lazy-installed heavyweights (IndexTTS 2, OmniVoice GGUF, Supertonic 3, MOSS-TTS-v1.5, dots.tts, Confucius4-TTS). Switch in **Settings → TTS Engine** or via the `OMNIVOICE_TTS_BACKEND` env var — the selection applies everywhere synthesis happens: single-clip generation, Voice Cloning, Video Dubbing, and Batch TTS.
**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>
@@ -316,7 +253,7 @@ Professional-grade voice AI, minus the subscription and the cloud.
>
> **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 weights) and**dots.tts** (2B, ~9 GB weights) are heavyweight opt-in engines that run in their own isolated venv from a local clone — see [MOSS-TTS-v1.5](docs/engines/moss-tts-v15.md) and [dots.tts](docs/engines/dots-tts.md). Neither claims Apple-Silicon **MPS** (upstream is CUDA/CPU only; on a Mac they run on CPU). dots.tts upstream is Linux/macOS only — no Windows path. **Confucius4-TTS** (14-language cross-lingual zero-shot cloning) is similar — its own Python 3.10 venv from a clone; CUDA recommended, CPU validated end-to-end (slow, ~17× realtime; no MPS — tested slower than CPU); see [Confucius4-TTS](docs/engines/confucius4-tts.md).
> **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).
</details>
@@ -324,10 +261,10 @@ Professional-grade voice AI, minus the subscription and the cloud.
### 🎧 ASR Engines
**10 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** (the ASR Engines table — same picker TTS has), or pin one with the `OMNIVOICE_ASR_BACKEND` env var (the env var wins over the Settings pick). Nine run fully on-device; one (OpenAI-compatible) is an optional remote client for pointing at Qwen3-ASR or another compatible server — see below.
**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.
<details>
<summary><b>📊 The full lineup</b> — 10 engines, what each is best at, and compute-type notes</summary>
<summary><b>📊 The full lineup</b> — 11 engines, what each is best at, and compute-type notes</summary>
<br/>
@@ -339,6 +276,7 @@ Professional-grade voice AI, minus the subscription and the cloud.
| **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**. |
@@ -354,64 +292,109 @@ Professional-grade voice AI, minus the subscription and the cloud.
## 🏗️ Architecture
A **Tauri v2** desktop shell (Rust) wraps a **React** UI and a bundled **Python/FastAPI** backend that runs as a local sidecar on `localhost:3900`. Nothing external — every layer is on your machine.
- **Shell (Rust)** — native OS integration: the system-wide dictation hotkey, tray, signed auto-updater (stable + preview channels), single-instance lock, and the first-run bootstrap that installs `uv` and a Python 3.11 venv.
- **Frontend (React)** — every workspace tab over a Zustand store, with a WebSocket event bus that live-refreshes the UI when backend data changes.
- **Backend (FastAPI)** — the bundled Python sidecar: 100+ endpoints, SSE/WSS streaming, a SQLite DB migrated by Alembic, and the OpenAI-compatible API surface.
- **Engines** — 14 TTS + 11 ASR, plus Demucs (isolation), Pyannote (diarization), and AudioSeal (watermark), all behind routing that GPU-preflights each engine and refuses to silently fall back to CPU.
<a id="openai-api"></a>
## 🔌 OpenAI-compatible API
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).
<div align="center">
**Drop-in replacement for OpenAI / ElevenLabs audio.** One line — no key, no code changes:
```diff
- base_url="https://api.openai.com/v1"
+ base_url="http://localhost:3900/v1"
```
</div>
Your existing scripts, agents, and OpenAI/ElevenLabs SDK calls now run **locally** on whatever engine you have active. What the cloud can't do: `voice` takes **your own cloned-voice profile IDs**, and `model` can pin a **specific engine** per request.
| Endpoint | What it does |
|---|---|
| `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. |
| `POST /v1/audio/speech` | TTS — text in; `mp3` / `opus` / `aac` / `flac` / `wav` / `pcm` out. `model`: `tts-1`/`tts-1-hd`(active engine) or a specific one (`voxcpm2`, `cosyvoice`, `kittentts`, …). `voice`: a cloned profile ID, `default`, or an OpenAI name (`alloy`, …). `speed` supported. |
| `POST /v1/audio/transcriptions` | STT — audio file in; `json` /`text` /`verbose_json` /`srt` /`vtt` out (`verbose_json` adds word-level timings). `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. |
**Speak with your own cloned voice** — list the IDs, then pass one as `voice`:
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.
### 📓 Run on Google Colab (community)
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.
No local GPU? A community member ([@shakib30](https://github.com/shakib30)) maintains a working Colab notebook: [shakib30/OmniVoice-Studio-google-colab](https://github.com/shakib30/OmniVoice-Studio-google-colab). Community-maintained — issues with the notebook go there; issues with OmniVoice itself come here.
### 📓 Run on Google Colab
[](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) boots the full app — web UI included — on a free Colab T4, then walks the whole feature surface (TTS, cloning, design, transcription, dubbing, audiobook, watermarking, the OpenAI-compatible API) as a guided tour with inline playback. No tunnels, no API keys.
### 🤝 Agent Skills
Teach your AI agent (Claude Code, Cursor, Codex, …) to use OmniVoice with one command:
Teach your coding agent to speak and listen through your local OmniVoice — one command, works with **Claude Code, Codex, Cursor, Grok, Kimi, opencode**, and any [skills.sh](https://skills.sh)-compatible agent:
```sh
npx skills add debpalash/omnivoice-studio
```
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.
Ships two [skills](https://skills.sh):
- **`omnivoice`** — generate speech (including your cloned voices) and transcribe audio from any agent, free and fully offline via your local install.
- **`oss-maintainer`** — the maintainer methodology this project is run with, for anyone running their own OSS project with an agent.
---
@@ -431,13 +414,13 @@ Ships two [skills](https://skills.sh): **`omnivoice`** — speak and transcribe
| **Infra** | Docker deployment, CUDA/MPS/ROCm auto-detect, cuDNN 8 compat, VRAM-aware model offloading, engine routing (no silent CPU fallback), diagnostics suite & error journal, restricted-network mirror support |
| **AI Provenance** | AudioSeal invisible watermarking (SynthID-like), video logo overlay, watermark detection API |
@@ -459,13 +442,11 @@ Ships two [skills](https://skills.sh): **`omnivoice`** — speak and transcribe
## 💜 Sponsor / Donate
OmniVoice Studio is built by one developer using Claude Code and AI agents — and the agent bills are real. Over the last three months I've spent thousands of dollars on Claude subscriptions to keep the features shipping, the bugs fixed, and your issues answered. If OmniVoice has created value for you, helping cover those bills means I can keep developing full-time.
One developer, real AI-agent bills. If OmniVoice is useful to you, chipping in keeps development full-time — every dollar goes straight to the bills.
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 (10 TTS engines, 10 ASR engines, your choice of translation).
<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 — and the output is only as good as its weakest link on <i>your</i> source material. Noisy or accented source audio degrades transcription, which degrades everything downstream; some language pairs translate better than others. If parts of a dub come out incoherent, check the segment table's <i>original</i> text first: if the transcription is already wrong there, switch the ASR engine (Settings → Engines) or use cleaner source audio — that's usually the fix, not the voice.
<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 find it replaces ElevenLabs outright; some keep both for different jobs. Both outcomes are fine with us.
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>
@@ -567,7 +559,7 @@ Yes. MPS acceleration is auto-detected. MLX-optimized Whisper models are availab
<details>
<summary><b>Can I use this commercially?</b></summary>
<br/>
<b>Yes — commercial use is free.</b> OmniVoice Studio is free and open-source under the <a href="https://www.gnu.org/licenses/agpl-3.0.html">GNU AGPL-3.0</a>. So personal, educational, research, <b>and commercial / business use are all free</b>: run it, sell the audio you make with it, dub your own or a client's videos, deploy it across your team. Because AGPL is a <b>network copyleft</b> license, if you <b>modify</b> OmniVoice Studio and make that modified version available to others over a network, you must offer those users the source of your modified version under the same AGPL terms. Want to embed OmniVoice in a <b>closed-source or proprietary</b> product without those obligations? A <b>commercial license</b> is available — see <a href="#license">License</a>.
<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>
@@ -579,7 +571,15 @@ Yes. MPS acceleration is auto-detected. MLX-optimized Whisper models are availab
<details>
<summary><b>Can I add my own TTS engine?</b></summary>
<br/>
Yes. OmniVoice uses a <b>built-in backend registry</b>. To add an engine in ~50 lines, subclass <code>TTSBackend</code> in <code>backend/services/tts_backend.py</code> and add it to the <code>_REGISTRY</code> dictionary. Fourteen engines are builtin: OmniVoice, CosyVoice 3, GPT-SoVITS, MLX-Audio (14+ sub-engines), VoxCPM2, MOSS-TTS-Nano, KittenTTS, Sherpa-ONNX, plus lazy-registered IndexTTS 2, OmniVoice GGUF, Supertonic 3, MOSS-TTS-v1.5, dots.tts, and Confucius4-TTS. See the <a href="#tts-engines">TTS Engines</a> section for details.
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>
@@ -600,7 +600,7 @@ OmniVoice Studio is free and open-source software under the [**GNU Affero Genera
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.
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.
---
@@ -623,14 +623,38 @@ OmniVoice Studio is built on the shoulders of exceptional open-source work:
---
<a id="more-from-the-maker"></a>
## 🧰 More local open-source from the maker
Like the local-first philosophy? It runs in the family:
Like the local-first philosophy? It runs in the family — same maker, same rule: **your data stays on your machine.**
| Project | What it is |
|---------|------------|
| [**Opal**](https://github.com/debpalash/Opal) 💠 | **Play everything.** The evolved media player for the next decades of entertainment — video, anime, comics, torrents, Jellyfin/Plex, with local AI built in. |
| [**memxt**](https://github.com/debpalash/memxt) 🧠 | **The fastest benchmarked open-source AI memory system.** 100% local memory for AI agents, with MCP support. |
<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)
没有本地 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 有声书,以及一个附带人声分离音轨的迷你视频配音。
### 🤝 智能体技能(Agent Skills)
用一条命令教会你的 AI 智能体(Claude Code、Cursor、Codex 等)使用 OmniVoice:
detail=f"ffmpeg failed to combine video + dubbed audio: {e}. Verify ffmpeg is installed (`ffmpeg -version`), and check that every dubbed track file exists in the job folder.",
detail=explain_ffmpeg_failure(e,"combine video + dubbed audio",cmd=cmd),
)
finally:
# The batched retime intermediate is a full re-encoded video — never
detail=f"ffmpeg couldn't encode MP3: {e}. Check that libmp3lame is compiled into your ffmpeg build (`ffmpeg -codecs | grep mp3`) — reinstall via homebrew if it's missing.",
label:"Whisper large-v3 (PyTorch — GPU path for AMD/ROCm)"
role:ASR
size_gb:3.1
platforms:[cuda]
platforms:[cuda, rocm]
curated_on:[rocm]
note:"CTranslate2 has no ROCm backend, so on AMD GPUs this PyTorch build is the accelerated route."
- repo_id:"mlx-community/whisper-tiny-mlx"
label:"Whisper tiny (MLX ASR — fast fallback)"
@@ -66,6 +86,7 @@ models:
label:"Whisper large-v3 Turbo (5× faster, 0.8B)"
role:ASR
size_gb:1.6
curated_on:[cuda, cpu]
note:"Best speed/quality tradeoff. 5× faster than large-v3 with minimal WER loss. Community CTranslate2 conversion (no official Systran/OpenAI turbo repo) — re-verify availability on catalog audits."
note:"English-optimized with punctuation/capitalization. Requires nemo_toolkit[asr]."
- repo_id:"mlx-community/parakeet-tdt-0.6b-v3"
label:"Parakeet TDT 0.6B v3 (MLX — Apple Silicon, 25 EU langs)"
role:ASR
size_gb:1.2
platforms:[darwin-arm64]
curated_on:[darwin-arm64]
note:"The Parakeet tier for Apple Silicon: 25 European languages, TDT word timestamps, ~2 GB unified memory, dictation-grade speed on the GPU via parakeet-mlx. Installing it makes dictation/capture prefer it automatically when your system language is one of the 25 covered (European) languages — other languages (CJK, Arabic, …) keep the multilingual Whisper engine so dictation coverage never regresses."
- repo_id:"UsefulSensors/moonshine-base"
label:"Moonshine base (edge-optimized, 61M, ONNX)"
@@ -18,6 +18,7 @@ from __future__ import annotations
importos
importplatform
importre
importshutil
importsys
frompathlibimportPath
fromtypingimportAny,Optional
@@ -39,11 +40,17 @@ _HINTS: dict[str, str] = {
"HF_AUTH_FAILED":"Set a valid HF_TOKEN in Settings → Hugging Face and retry.",
"PYANNOTE_LICENSE_REQUIRED":"Accept the pyannote model licenses on Hugging Face, then retry.",
"COMPUTE_TYPE_UNSUPPORTED":"Your GPU doesn't support float16 — OmniVoice retried on int8. If transcription still fails, set OMNIVOICE/ASR_COMPUTE_TYPE=int8 or use CPU.",
"TRANSFORMERS_IMPORT":"Your transformers install is incomplete. Reinstall it (`uv pip install --reinstall transformers`) or switch ASR to faster-whisper (Settings → Models).",
"TRANSFORMERS_IMPORT":"Your transformers install is incomplete, or a package it loads models through (torchaudio) is missing or mismatched with your torch. Reinstall them together (`uv pip install --reinstall torch torchaudio transformers`), then restart the backend. If only transcription is affected, switching ASR to faster-whisper (Settings → Models) also works around it.",
"WINDOWS_APP_CONTROL_BLOCKED":"Windows refused to load a file OmniVoice needs — an Application Control policy (Smart App Control, WDAC, or AppLocker) blocked it. On a personal PC: Windows Security → App & browser control → Smart App Control → Off (Windows only lets you turn it off once — re-enabling requires a Windows reset), then restart OmniVoice. On a managed/work PC, ask IT to allow the OmniVoice install folder.",
"WINDOWS_PAGING_FILE_TOO_SMALL":"Windows ran out of virtual memory while mapping the model into memory — its paging file is smaller than the model needs. This is not the same as your RAM being full, and closing other apps usually won't fix it: Windows has to be allowed to back the mapping. Set a bigger paging file — Settings → System → About → Advanced system settings → Performance → Settings → Advanced → Virtual memory → Change: untick \"Automatically manage\", pick your system drive, choose \"Custom size\" and set both Initial and Maximum to at least 32768 MB (more than the model's size), then OK and restart Windows. A smaller/quantized engine (OmniVoice GGUF, Supertonic-3) also avoids the large mapping entirely.",
"MEDIA_TOOL_MISSING":"OmniVoice's media engine (ffmpeg/ffprobe) wasn't on the system path when a component went looking for it. Open Settings → Audio tools and use Download/Repair to fetch the bundled copy, then retry — a restart picks it up for everything. If you'd rather use a system install, install ffmpeg (macOS: `brew install ffmpeg`; Windows: `winget install Gyan.FFmpeg`; Linux: your package manager) and restart OmniVoice, or point FFMPEG_PATH / OMNIVOICE_FFPROBE_PATH at the binaries in Settings.",
"AUDIO_IO_FAILED":"An audio file couldn't be read or written at the OS level. Check the drive isn't full, that the output and temp folders exist and are writable, and that antivirus or OneDrive isn't locking them (add an OmniVoice exclusion if you use one).",
"VIDEO_DOWNLOAD_OS_ERROR":"The OS refused a file operation while saving the downloaded video — this is a disk/folder problem, not a network one, so retrying the same link won't help. The download is written to a job folder under your OmniVoice data directory (Settings → Storage shows the path): check that drive isn't full, that the folder exists and is writable, and that antivirus or a cloud-sync client (OneDrive, Dropbox) isn't locking it — add an OmniVoice exclusion if you use one. If your data directory sits on a synced or network drive, move it to a local one.",
"OS_INVALID_ARGUMENT":"The OS rejected a file operation (Errno 22 / invalid argument) — in the transcribe path this is the temporary WAV write before ASR. It's almost always the temp directory: missing, read-only, on a full or removed drive, or blocked by antivirus. Check that your system TEMP/TMP folder exists and is writable and the drive has free space (add an OmniVoice antivirus exclusion if you use one), then retry.",
"SOCKS_PROXY_SUPPORT_MISSING":"A SOCKS proxy is configured in your environment (ALL_PROXY/HTTPS_PROXY=socks5://…) and the backend's HTTP client is missing SOCKS support. Newer OmniVoice builds ship SOCKS support (the socksio package) — update the app. If you still see this, unset ALL_PROXY/HTTPS_PROXY for OmniVoice, or run `uv pip install 'httpx[socks]'` in the backend venv, then restart.",
"SSL_HANDSHAKE_FAILURE":"A corporate or antivirus proxy is intercepting HTTPS traffic and re-signing certificates with its own CA — your OS trusts that CA, but Python's bundled certifi CA list doesn't, so the TLS handshake fails even though the connection reached the server. Newer OmniVoice builds trust the OS certificate store at startup (the truststore package), which should already fix this — update the app and retry. If you still see this, add an HTTPS-scanning exclusion for OmniVoice/Python in your antivirus, or ask IT for the proxy's CA bundle and set SSL_CERT_FILE to it, then restart.",
"UNSUPPORTED_VIDEO_URL":"This link isn't a directly downloadable video. Paste a direct video page (e.g. a youtube.com/watch?v=… or douyin.com/video/<id> link), not a share/profile/feed link — or download the file and drop it in directly.",
"VIDEO_DRM_PROTECTED":"The video host only offered OmniVoice a DRM-protected copy, which can't be downloaded. This is often not a property of the video itself — the host serves a different format set to different clients, and OmniVoice already retried through every client it has. Try the link again in a minute, or download the video with a browser extension / the host's own download button and drop the file into Dubbing directly.",
"VIDEO_DOWNLOAD_NETWORK":"The connection to the video server dropped mid-download (often a transient CDN/network blip or a regional rate-limit). Just retry — OmniVoice already cleaned up the partial download. If it keeps failing, check your network/VPN.",
"BROKEN_VENV":"The Python backend environment was moved or damaged. OmniVoice rebuilds it automatically on the next launch; if it keeps failing, use Clean & Retry on the setup screen.",
"MODEL_CACHE_CORRUPT":"The model cache had broken file links — snapshot entries that no longer point at their downloaded data (interrupted renames or antivirus interference can cause this). OmniVoice repairs this automatically and retries the load once. If the error persists, quit OmniVoice, delete the model's models--<org>--<name> folder inside the Hugging Face cache, and restart — the model re-downloads automatically.",
assertsecond.json()["profile_id"]==pid# same row reused
assertrender_calls["n"]==1# NOT re-rendered
# Exactly one row exists for this archetype (no duplicate materialization).
fromcore.dbimportdb_conn
withdb_conn()asconn:
rows=conn.execute(
"SELECT id FROM voice_profiles WHERE personality = ?",(sample["id"],)
).fetchall()
assertlen(rows)==1
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.