Compare commits

...
57 Commits
Author SHA1 Message Date
Palash DebnathandClaude Opus 5 b01c635bfe release: v0.4.2 (#1279)
Version files (package.json + the three mirrors) and both lockfiles bumped
0.4.1 → 0.4.2; [Unreleased] renamed to [0.4.2] — 2026-07-28.

Ships five user-facing fixes: the update toast, the restart guard that used
to discard in-flight work, the shutdown-is-not-a-crash 503, the self-healing
half-downloaded model, and the dub-history purge ordering — plus the
"Dismiss" mistranslation in five locales.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 15:56:37 -07:00
Palash DebnathandClaude Opus 5 e9307f2dc0 fix(errors): key the no-report rule on the shutdown marker, not on 503 (#1278)
* fix(errors): key the no-report rule on the shutdown marker, not on 503

Self-caught regression from #1272. Suppressing the "Report" action for every
503 was too broad: 503 is also how a real engine-load timeout and an
unavailable engine are reported (#1246, #1260, and #1277, filed hours ago
with a 503 in its very title). That would have removed the report button from
exactly the class of failure users need to be able to file — silencing real
bugs to hide a benign one.

The backend now tags only the shutdown case with a `[shutting_down]` marker,
following the existing `[clone_ref_unusable]` convention, and the UI matches
the marker instead of the status. A 503 that is a genuine failure keeps its
report action.

Localized message added to all 21 locales; the backend test pins the marker
so the cross-layer contract can't be renamed away.

Fail-before verified: the two "still offers Report for a 503" tests fail
against the shipped blanket-503 version.

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

* i18n(de): formal register for the two new German strings

Review: the surrounding errors.* strings and update.retry use "Sie"; both new
strings used the informal form. Turkish is consistently informal (install,
retry) so it stays as-is.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 15:38:36 -07:00
Palash DebnathandClaude Opus 5 c3d0d6b123 feat(update): announce updates as a toast with actions, not a wall of text (#1272)
* fix(dub): purge order was hash-dependent, and the cap could evict a live marker

main went red on my own test. Two distinct defects, both mine.

1. `targets = set(job_ids)` made iteration order depend on PYTHONHASHSEED, so
   which markers a cap-forced trim discarded was luck. That is why the test
   passed locally and failed in CI — verified: the old code passes at seeds
   0/7/42 and fails at 12345. Now a de-duplicated list in caller order.

2. The size cap could evict markers the CURRENT purge had just recorded. Those
   are the newest and the likeliest to still be held by a running job, so
   dropping one is precisely the resurrection this mechanism exists to prevent.
   A 'clear history' larger than the cap forced exactly that. The cap now never
   touches the current purge, making the real bound cap + one purge — stated
   plainly rather than implied.

Tests now pin both: identical survivors across runs, and an oversized purge
keeping all of its own markers. Verified across six hash seeds; full suite green
under 12345, the seed that reddened main.

* feat(update): announce updates as a toast with actions, not a wall of text

A version's release notes ARE the whole changelog section — v0.4.1's was 42
bullets. Any surface that renders them inline becomes unusable: older builds put
them in a blocking OS dialog that filled the screen and had to be dismissed
before the app could be touched.

Removing that dialog left the opposite failure. The only remaining signal was a
6-pixel dot beside the version number in the footer, which is easy to never
notice — so users either got shouted at or told nothing.

A toast is the middle: it names the version, offers Install and restart /
What's new / Later, and leaves. The notes stay one click away in Settings →
Updates, where there is room. Keyed by version so the 6-hourly re-check
replaces rather than stacks, and it never auto-dismisses — an update the user
hasn't answered is still true. Install declines while a generation is running,
since the relaunch would lose it.

Strings added to all 21 locales, translated rather than English-filled.

Tests pin the shape that matters: the toast takes no notes prop at all, renders
under 200 characters, and cannot stack duplicates.

* fix(update): don't relaunch while work is in flight; share the busy check

Review of #1272 found the restart guard was `dubStep === 'generating'` and
nothing else. Installing an update relaunches the process, so that permitted
throwing away a dub upload, a transcription, a translation, an export or a
standalone TTS synth. The same narrow check was written twice — in the new
toast and in UpdatesPanel — so the two could also drift apart.

Replaced with a single `isAppBusy(state)` in utils/appBusy, unioning the
signals the store actually has: the dub state machine, the floating status
pill (which every long background operation already pushes to), and
`ttsGenerating` — a new transient store field mirroring useTTS's local
`isGenerating`, which lived in a hook where no global check could see it.
`dubStep === 'editing'` is deliberately not busy: it waits on the user, and
counting it would block updates for as long as a transcript stays open.

Also from review:

- A failed lazy import of the toast fell into the outer catch, whose
  setUpdateIdle() erased the update that had just been found. The
  announcement is optional; the available state is not.
- "Dismiss" was machine-translated into the employment sense — terminate an
  employee — in de/ja/ru/zh-CN/zh-TW, on close buttons and one aria-label.
  Swept every locale rather than the four lines that were flagged.
- Hoisted the test's mock state with vi.hoisted.

CodeRabbit's changelog finding is declined: Highlights bullets carry no issue
refs by design, which tests/test_changelog_style.py enforces.

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

* fix(shutdown): a quit mid-generate is not a 500, and not a bug report (#1276)

#1174 made a model load interrupted by shutdown benign for the background
preload, but a *request* that triggered a load took the generic unhandled-
exception path: crash log, ERROR traceback, and an error-journal entry that
feeds the bug-report pipeline. Quitting the app with a generate queued
surfaced "500 Internal Server Error: model load skipped: backend shutting
down" and offered to file a GitHub issue for a normal teardown.

Nothing failed — the process is exiting. The handler now answers 503 with
Retry-After and an actionable detail, ahead of the crash-log/journal writes.

Frontend half of the same bug: toastErrorWithReport offered "Report" for any
error. A 503 means "not now, try again" by definition, so it now shows the
backend's message without the report action — covering a still-warming
backend too, not just this shutdown path.

Fail-before/pass-after tests on both sides, including that the shutdown case
leaves no crash-log entry and no journal record.

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

* fix(models): repair a half-downloaded model whichever way it reports (#1273)

transformers has two unrelated wordings for "this snapshot has no weight
shard", sharing no words:

  hub load   "<repo> does not appear to have a file named …"
  local dir  "Error no file named model.safetensors, … found in directory …"

The self-heal and the failure classifier both matched only the first. The
second is what a load of a *subfolder* inside a cached snapshot raises —
exactly where an interrupted download leaves a half-written repo — so the
reporter got neither the automatic repair (delete broken entries →
re-download → retry) nor an actionable hint, just a raw 500. Their disk had
10.6 GB free, i.e. a download that ran out of room.

The phrase list now lives once in core.failure, so the healer and the error
text cannot disagree about what an interrupted download looks like. Both
fragments of the second wording must match — "no file named" alone is
ordinary English and must not claim the class.

Also hardens the #1276 handler found by running both suites in one session:
services.model_manager can be imported under two module names, making two
distinct ModelLoadInterruptedByShutdown classes and breaking a bare
isinstance — which silently restored the 500 this fix exists to remove. Now
matched by isinstance OR class name, with a test that raises a same-named
class from a different module.

Verified against the full tests/ + backend/tests/ session: the only remaining
failures are the four pre-existing #1269 isolation leaks, unchanged.

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

* fix(update): count synths in flight at the chokepoint, not in one caller

Review round 2. Both Greptile P1s were the same weakness in my first pass:
tracking the synth in useTTS meant only the Generate tab counted (voice
previews, the compare modal, the stories editor and profile previews call
generateSpeech directly and were invisible), and a boolean meant two
overlapping syntheses cleared each other — whichever settled first reported
"idle" while the other was still running, so Install and restart discarded it.

Moved to `api/generate.ts`, around the one `/generate` call all seven paths
share, as a count with a `finally` release (so an abort or a network error
frees it too). A future synth caller is covered without opting in.
Fail-before verified: the overlap test fails against the boolean version.

Also from review:

- UpdatesPanel re-reads the busy state at click time; the render-time snapshot
  only exists to disable the button, and work can start after the last render.
- The 503 now carries the allowed-origin CORS headers. Without them the
  browser reports a bare CORS failure and the actionable detail — the whole
  point of the fix — never reaches the user. Both error responses build them
  through one helper now.
- Log `request.url.path`, not the full URL: a query string can carry tokens
  and newlines.
- `update.busy` said "finish your dub first" in all 21 locales, but the guard
  now covers uploads, transcription, translation, export and synth. Rewritten
  as work-in-progress wording, translated per locale.
- ru `common.dismissStatus` was "reject status"; missed in the earlier sweep.

Declined: CodeRabbit's request for a `(#NNN)` ref on the Highlights bullet —
Highlights carry no refs by design (CLAUDE.md), and tests/test_changelog_style.py
enforces it. Also declined gating the cache re-download behind a confirmation:
the self-heal already existed and already ran for the sibling wording, this
only stops it missing half the class, and it stays behind the existing
_hf_offline() check.

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

* fix(update): grey out Install while work is running

CI lint caught `busy` as unused after the click-time check moved into the
handler — and that exposed a real gap: `busy` had never been wired to
anything. The comment claimed it disabled the button; it didn't. Clicking
Install during a synth just bounced a toast back.

Now it does what it said: the Install and Restart buttons are disabled while
work is in flight, with `update.busy` as the tooltip. The click-time read
stays the authority, since work can start between the last render and the
click — the disabled state is the explanation, not the safety.

Adds the first UpdatesPanel test. The case it pins hardest is the inverse of
the bug: a busy predicate stuck at true would make the app permanently
un-updatable, which is worse than what this fixes. So it asserts enabled when
idle and during dub 'editing' (which waits on the user), disabled during an
upload, a translation, and one or more synths.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 14:32:32 -07:00
Palash Debnath f99832de90 fix(dub): purge order was hash-dependent, and the cap could evict a live marker (#1271)
main went red on my own test. Two distinct defects, both mine.

1. `targets = set(job_ids)` made iteration order depend on PYTHONHASHSEED, so
   which markers a cap-forced trim discarded was luck. That is why the test
   passed locally and failed in CI — verified: the old code passes at seeds
   0/7/42 and fails at 12345. Now a de-duplicated list in caller order.

2. The size cap could evict markers the CURRENT purge had just recorded. Those
   are the newest and the likeliest to still be held by a running job, so
   dropping one is precisely the resurrection this mechanism exists to prevent.
   A 'clear history' larger than the cap forced exactly that. The cap now never
   touches the current purge, making the real bound cap + one purge — stated
   plainly rather than implied.

Tests now pin both: identical survivors across runs, and an oversized purge
keeping all of its own markers. Verified across six hash seeds; full suite green
under 12345, the seed that reddened main.
2026-07-27 12:22:18 -07:00
Palash Debnath 9736fd4859 release: v0.4.1 (#1239)
* 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.
2026-07-26 14:48:05 -07:00
Palash Debnath b66b09ceaa fix(dub): deleting a dub no longer resurrects it (#1252, #1253) (#1270)
* 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)
2026-07-26 14:25:32 -07:00
Palash Debnath 73ebd6518d fix(errors): six failures that reached users as raw text (#1262, #1256, #1251, #1247, #1257, #1254) (#1264)
* 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.
2026-07-26 12:39:29 -07:00
Palash Debnath e3fa40a952 fix(macos): the app was dead on arrival on macOS 12 (Monterey) (#1245) (#1263)
* 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.
2026-07-26 11:01:06 -07:00
Palash Debnath 1bfcedab43 chore(agents): a standing reviewer carrying the project's own standards (#1267)
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.
2026-07-26 11:00:58 -07:00
Paolo Antinori 67d1f6a43d feat(mcp): OMNIVOICE_MCP_ALLOWED_HOSTS — configurable host allowlist for MCP transport security (#1249) (#1250)
* 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)
2026-07-26 02:02:23 -07:00
Palash DebnathandClaude Opus 4.8 e6ca31d7a6 fix(first-run): keep Continue + the HF token box on screen; no status bar yet (#1241)
* 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>
2026-07-26 01:59:37 -07:00
Palash Debnath b722ca7ad1 Merge pull request #1236 from debpalash/fix/download-resume-oom-1224
fix(download): retry a truncated model download instead of aborting (#1224)
2026-07-22 17:47:38 -07:00
debpalash 854306fef8 docs(changelog): rebuild [Unreleased] after the main merge 2026-07-23 06:02:58 +05:30
Palash Debnath fe3efe3633 Merge pull request #1235 from debpalash/fix/port-in-use-1223
fix(startup): report a port conflict as a port conflict (#1223)
2026-07-22 17:32:51 -07:00
debpalash 3e2b089698 docs(changelog): rebuild [Unreleased] after the main merge 2026-07-23 05:43:24 +05:30
debpalash 8c77a82727 docs(changelog): rebuild [Unreleased] after the main merge 2026-07-23 05:43:22 +05:30
Palash Debnath dbc87dbfe2 Merge pull request #1238 from debpalash/fix/changelog-structure
docs(changelog): repair the [Unreleased] structure
2026-07-22 17:12:59 -07:00
debpalash f67fde919c docs(changelog): repair the [Unreleased] structure mangled by a merge resolution
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.
2026-07-23 05:28:32 +05:30
Palash Debnath 00da2d3c94 Merge pull request #1237 from debpalash/fix/low-vram-preflight-1226
fix(routing): warn about an under-provisioned GPU before the job, not after (#1226, #1222)
2026-07-22 16:56:32 -07:00
debpalash 6d42db5052 Merge main into fix/low-vram-preflight-1226
# Conflicts:
#	CHANGELOG.md
2026-07-23 05:25:25 +05:30
debpalash 95fae6df0d Merge main into fix/download-resume-oom-1224
# Conflicts:
#	CHANGELOG.md
2026-07-23 05:25:19 +05:30
debpalash 59c7e5a3bd Merge main into fix/port-in-use-1223
# Conflicts:
#	CHANGELOG.md
2026-07-23 05:25:12 +05:30
Palash Debnath 916e075eb3 Merge pull request #1232 from debpalash/fix/lazy-model-import-1229
fix(startup): don't let one optional transformers symbol kill the backend (#1229)
2026-07-22 16:54:49 -07:00
debpalash 09b60901e4 Merge main into fix/lazy-model-import-1229
# Conflicts:
#	CHANGELOG.md
#	backend/core/failure.py
2026-07-23 05:24:33 +05:30
Palash Debnath 2df159e3f7 Merge pull request #1234 from debpalash/fix/dub-download-errno22-1225
fix(dub): name the folder when a URL ingest fails on a disk error (#1225)
2026-07-22 16:53:06 -07:00
debpalash 2009ef329c Merge main into fix/dub-download-errno22-1225
# Conflicts:
#	CHANGELOG.md
#	backend/core/failure.py
2026-07-23 05:22:47 +05:30
Palash Debnath a93f8f0c6c Merge pull request #1233 from debpalash/fix/synth-error-classes-1227-1221
fix(synth): name the App Control block and the libsndfile failure (#1227, #1221)
2026-07-22 16:52:17 -07:00
debpalash ca5ca99247 Merge main into fix/synth-error-classes-1227-1221
# Conflicts:
#	CHANGELOG.md
2026-07-23 05:22:12 +05:30
debpalash 9b2659733a Merge main into fix/lazy-model-import-1229
# Conflicts:
#	CHANGELOG.md
2026-07-23 05:22:05 +05:30
Palash Debnath e02f5b231b Merge pull request #1230 from debpalash/fix/rocm-arch-gate-1228
fix(rocm): stop force-routing every AMD GPU to the CPU (#1228)
2026-07-22 16:51:07 -07:00
debpalashandClaude Opus 4.8 90bc6f95db fix(download): #1224 review — a settled Hub verdict must not retry
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>
2026-07-23 04:29:17 +05:30
debpalashandClaude Opus 4.8 b0d5abe0fd fix(routing): #1226 review — chain the timeout cause (Ruff B904)
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>
2026-07-23 04:25:41 +05:30
debpalashandClaude Opus 4.8 1d3d611b2c fix(synth): #1227/#1221 review — assert the marker through classify()
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>
2026-07-23 04:24:54 +05:30
debpalashandClaude Opus 4.8 aa041be850 fix(rocm): #1228 review — a pre-set HSA override must also name a real target
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>
2026-07-23 04:24:20 +05:30
debpalashandClaude Opus 4.8 57a8571769 fix(startup): #1229 review — print versions before the model-stack import
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>
2026-07-23 04:03:31 +05:30
debpalashandClaude Opus 4.8 4fc494383b fix(download): #1224 review — replace the source-grep tests with real ones
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>
2026-07-23 03:59:31 +05:30
debpalashandClaude Opus 4.8 027c08f1ff fix(dub): #1225 review — the preflight error had no class, and ENOENT no facts
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>
2026-07-23 03:53:30 +05:30
debpalashandClaude Opus 4.8 63d40c33ce fix(synth): #1227/#1221 review — classify on a marker, not on shared wording
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>
2026-07-23 03:45:19 +05:30
debpalashandClaude Opus 4.8 d800f77e26 fix(rocm): #1228 review — a remap is only a fix if the build ships the target
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>
2026-07-23 03:33:26 +05:30
debpalashandClaude Opus 4.8 6715551c2a fix(routing): #1226 review — scope the VRAM floor to where it was measured
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>
2026-07-23 03:28:22 +05:30
debpalashandClaude Opus 4.8 6e565c57d2 fix(startup): #1223 review — the OSError handler was inert; probe the port instead
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>
2026-07-23 03:22:13 +05:30
debpalashandClaude Opus 4.8 6cfef5c0cf fix(routing): warn about an under-provisioned GPU before the job, not after (#1226, #1222)
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>
2026-07-23 03:03:32 +05:30
debpalashandClaude Opus 4.8 ed321c56e6 fix(download): retry a truncated model download instead of aborting (#1224)
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>
2026-07-23 02:47:22 +05:30
debpalashandClaude Opus 4.8 8695b478cd fix(startup): report a port conflict as a port conflict (#1223)
`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>
2026-07-23 02:36:06 +05:30
debpalashandClaude Opus 4.8 8f4914272e fix(dub): name the folder when a URL ingest fails on a disk error (#1225)
`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>
2026-07-23 02:25:12 +05:30
debpalashandClaude Opus 4.8 8cef017ee5 fix(synth): name the App Control block and the libsndfile failure (#1227, #1221)
`_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>
2026-07-23 01:55:08 +05:30
debpalashandClaude Opus 4.8 b6d92637e7 fix(startup): don't let one optional transformers symbol kill the backend (#1229)
`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>
2026-07-23 00:28:42 +05:30
debpalashandClaude Opus 4.8 791dae69db fix(rocm): stop force-routing every AMD GPU to the CPU (#1228)
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>
2026-07-22 23:46:47 +05:30
debpalashandClaude Opus 4.8 349eac9f77 docs(readme): restore 'Dictation Widget' feature name (docs-drift exact match)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 14:57:44 +05:30
debpalashandClaude Opus 4.8 cc138a4666 docs(readme): make Features image-forward — cropped app tiles
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>
2026-07-21 14:57:10 +05:30
debpalashandClaude Opus 4.8 728ddcb829 docs(readme): section-by-section polish pass
- 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>
2026-07-21 14:48:11 +05:30
debpalashandClaude Opus 4.8 3972a72bb9 docs(readme): sharpen the OpenAI-compatible API section
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>
2026-07-21 14:30:14 +05:30
debpalashandClaude Opus 4.8 07bbdb778e fix(release): drop the "Ranked by merged pull requests" line from the strip
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>
2026-07-21 14:22:28 +05:30
debpalashandClaude Opus 4.8 e38e27ad10 fix(release): credit contributors on stable releases — ranked, single section
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>
2026-07-21 14:20:30 +05:30
debpalashandClaude Opus 4.8 e79ad0e479 docs(readme): reflect v0.4.0 capabilities + fix ASR count drift
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>
2026-07-21 13:49:53 +05:30
debpalashandClaude Opus 4.8 ab48594582 chore(repo): declutter root — move community docs into .github/
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>
2026-07-21 13:32:13 +05:30
debpalashandClaude Opus 4.8 f92e4223db docs(docker): refresh registry tag examples 0.3.22 → 0.4.0
Update the exact-version / minor / ROCm pin examples in the Docker Hub
overview (deploy/dockerhub-overview.md — source of the hub.docker.com page,
re-synced on this main push) and docs/install/docker.md to the v0.4.0
release. GHCR's package page inherits the repo README and the current
org.opencontainers.image.description label, both already accurate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 12:29:20 +05:30
117 changed files with 6796 additions and 443 deletions
+133
View File
@@ -0,0 +1,133 @@
---
name: owner-judge
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
words, then `### Changed` / `### Added` / `### Docs` / `### Fixed` / `### CI`
subsections where each entry is a one-liner ending in its `(#NNN)` ref with
contributor credit where due. Highlights bullets do **not** carry refs — the
`###` entries do. Never edit an already-published version's section.
**Localisation.** No hardcoded non-English user-facing text outside
`frontend/src/i18n/`. Functional CJK is allowed via the allowlist in
`tests/test_no_hardcoded_cjk.py`, with a justification.
**Mechanical rules belong in tests, not in review.** Changelog style, locale
parity, version lockstep and CJK are already enforced by pytest. Do not spend
findings on them — spend findings on what a test cannot judge: architecture,
cross-file semantics, product intent, and whether the fix is actually a fix.
## How to review
1. **Read the actual change.** `git diff origin/main...HEAD`, or the PR diff.
Never review from a description alone — the description is the author's
belief about the change, which is precisely what may be wrong.
2. **Reproduce the reasoning.** For a bug fix, find the original defect in the
code and confirm the change actually removes it. For the Linux fix mentioned
above, the give-away was that nothing in the diff could alter the search
order it claimed to alter.
3. **Run what you can.** Targeted tests, the linter, a syntax check. Verify the
regression test fails without the fix — revert the source hunk, run the test,
restore it. A test that passes both ways is not a regression test.
4. **Hunt the rest of the class.** Grep for the same idiom elsewhere. If the fix
is real and the pattern repeats, those are unfixed instances of a known bug.
5. **Check the platforms the author could not.** Most work here is done on
macOS. Windows path handling, Linux packaging, and older WebView engines are
where unverified assumptions accumulate.
## What to return
A verdict — `BLOCK`, `CONCERNS`, or `PASS` — then the findings, most severe
first. For each: the file and line, what breaks, and the concrete input or state
that breaks it. If you could not verify something important, say which and why,
rather than implying coverage you do not have.
`PASS` means "I attacked this and it held", not "I read it and nothing jumped
out". If you did not try to break it, do not return `PASS`.
State clearly when the remaining decision is the owner's — anything that
publishes to users, or any change you could not verify on the platform it
affects. Naming that boundary *is* part of the review.
View File
View File
+69
View File
@@ -761,6 +761,75 @@ jobs:
scripts/uninstall.sh scripts/uninstall.ps1 \
--clobber --repo "${{ github.repository }}"
# ── Contributors avatar strip on STABLE releases ──────────────────────────
# Stable v* releases keep their curated CHANGELOG body + the per-platform
# checksums; this appends ONE "## Contributors" avatar strip crediting every
# PR author for the tag — including the owner — the courtesy the preview
# channel already gets (preview-notes job). It closes the gap where stable
# releases credited nobody.
#
# Two things the naive version got wrong (fixed here):
# 1. RANK by contribution. Authors are ordered by merged-PR count for the
# tag (descending, ties broken by handle), not alphabetically — the
# owner with 30+ PRs should not sort under a one-PR contributor.
# 2. Exactly ONE section. GitHub auto-renders its OWN "Contributors" widget
# from any plain `@handle` TEXT mention in the body (the CHANGELOG's
# "— thanks @user!" credits → `mentions_count`), which duplicates ours
# and can't be ranked or include the owner. We neutralise those inline
# text mentions in the RELEASE body only (`thanks @u` → `thanks u`; the
# repo CHANGELOG keeps the @handles) so GitHub renders no native widget —
# our ranked strip's @handles live in HTML attributes, which GitHub does
# not count as mentions, so the linked avatars stay clickable.
#
# MUST append via `gh release edit` on the EXISTING release (never a second
# softprops publish — that races tauri-action's per-matrix draft and splits
# installers across two releases; see uninstall-scripts). `needs: [build]`
# guarantees the release + all checksum appends already landed, and this job
# is single (no matrix) so there is no write race. Idempotent: it strips any
# prior "## Contributors" block before re-appending, so re-runs don't stack.
contributors-strip:
needs: [build]
if: >-
github.event_name == 'push'
&& startsWith(github.ref, 'refs/tags/v')
&& !contains(github.ref, '-')
runs-on: ubuntu-22.04
permissions:
contents: write
steps:
- name: Append ranked Contributors avatar strip to the stable release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }}
TAG: ${{ github.ref_name }}
run: |
set -euo pipefail
NOTES=$(gh api --method POST "repos/$REPO/releases/generate-notes" -f tag_name="$TAG" --jq .body)
# Rank PR authors by merged-PR count (desc), ties broken by handle.
RANKED=$(printf '%s\n' "$NOTES" | grep -oE 'by @[A-Za-z0-9-]+' | sed 's/^by @//' \
| sort | uniq -c | sort -k1,1nr -k2,2 | awk '{print $2}' || true)
if [ -z "$RANKED" ]; then
echo "No PR-author handles in the generated notes for $TAG — nothing to append."
exit 0
fi
# Current body: drop any prior Contributors block (idempotent re-runs;
# the strip is always the tail, and checksum sections use '### '
# headers so they never match), then neutralise inline @thanks so
# GitHub renders no duplicate native contributors widget.
BODY=$(gh release view "$TAG" --repo "$REPO" --json body --jq .body)
BODY=$(printf '%s\n' "$BODY" | sed '/^## Contributors$/,$d' | sed 's/thanks @/thanks /g')
{
printf '%s' "$BODY"
printf '\n## Contributors\n\nThank you all 💜\n\n'
while IFS= read -r h; do
[ -z "$h" ] && continue
printf '<a href="https://github.com/%s" title="@%s"><img src="https://github.com/%s.png?size=64" width="48" alt="@%s"/></a> ' "$h" "$h" "$h" "$h"
done <<< "$RANKED"
printf '\n'
} > /tmp/stable-notes.md
gh release edit "$TAG" --repo "$REPO" --notes-file /tmp/stable-notes.md
echo "Appended ranked Contributors strip ($(printf '%s' "$RANKED" | tr '\n' ' ')) to $TAG."
# ── Auto-generated preview release notes ──────────────────────────────────
# tauri-action publishes the rolling `preview` release with the plain
# changelog-fallback body ("Auto-generated release for main…"). Replace it
+10 -1
View File
@@ -36,14 +36,23 @@ frontend/src-tauri/target/
.DS_Store
Thumbs.db
# Local agent-memory DB (memxt) — per-machine state, never committed
memxt.db
memxt.db-shm
memxt.db-wal
# ─────────────────────────────────────────────────────────────────────────
# Editor / tool caches
# ─────────────────────────────────────────────────────────────────────────
# Ignore ad-hoc Claude Code state, but allow project-bundled skills
# (CLAUDE.md invites `.claude/skills/<name>/SKILL.md`).
# (CLAUDE.md invites `.claude/skills/<name>/SKILL.md`) and project-bundled
# review agents — the owner's review standards belong with the code they
# govern, not in one machine's local state.
.claude/*
!.claude/skills/
!.claude/skills/**
!.claude/agents/
!.claude/agents/**
/.cache*
/.tmp/
+83 -1
View File
@@ -6,6 +6,88 @@ 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.2] — 2026-07-28
**Highlights**
- The update prompt is a small toast with buttons, not a screenful of release notes
- Installing an update no longer throws away work that is still running
- Quitting the app mid-generate stops reporting itself as a crash
- A half-downloaded model repairs itself instead of dead-ending
- "Dismiss" no longer reads as "terminate an employee" in five languages
### Changed
- An available update now announces itself as a toast with **Install and restart**, **What's new** and **Later**, instead of only a dot beside the version number. The release notes stay in Settings → Updates, where there is room for them — a version's notes are the whole changelog section, and rendering them inline is what made the old prompt fill the screen (#1272)
### Fixed
- Installing an update no longer relaunches the app while work is running. The check only knew about dub synthesis, so a restart could silently discard an upload, a transcription, a translation, an export or a standalone synth — and two overlapping synths used to cancel each other's protection. Install is now greyed out while anything is in flight (#1272)
- A half-downloaded model now repairs itself instead of failing with a raw 500. The automatic repair recognised only one of the two ways the loader reports missing weights, so an interrupted download whose subfolder failed to load got neither the repair nor a hint about what to do (#1273)
- Quitting the app with a generate queued reported "500 Internal Server Error: model load skipped: backend shutting down" and offered to file a bug for it. A shutdown is not a fault: the backend now answers 503 with what to do, and no bug report is offered for it (#1276)
- Dub history: clearing a large history while a render was running could still resurrect the deleted job — which markers survived depended on the process hash seed, and an oversized purge could discard a live one (#1252)
- German, Japanese, Russian and both Chinese locales rendered "Dismiss" as the employment sense — "terminate an employee" — on close buttons (#1272)
- The "wait for the current job to finish" message named dubbing specifically, though it now covers uploads, transcription, translation, exports and synthesis; reworded across all 21 languages (#1272)
## [0.4.1] — 2026-07-27
**Highlights**
- AMD GPUs are used again — every ROCm host was silently running on the CPU
- Two synth failures that used to say "an error 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**
@@ -326,7 +408,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.
@@ -675,6 +756,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
+1 -1
View File
@@ -42,7 +42,7 @@ For anything new: prefer what's already pinned in `pyproject.toml` / `frontend/p
- Docker: `ghcr.io/debpalash/omnivoice-studio:latest` = **main** (rolling preview); `:X.Y.Z` + `:X.Y` + `:stable` = tagged releases. `:latest` is the preview channel by design — stable users pin `:stable` or a version tag.
- Do not bump minor/major or invent RCs/codenames without the owner asking. No "defer to next version" labels — scope is absorbed or declined, never re-versioned.
**Docs-sync (hard rule, owner-set 2026-06-11):** any change that alters something these docs describe — README.md, CONTRIBUTING.md, SECURITY.md, SUPPORT.md, LICENSE, or `docs/**` (install flows, Docker tag semantics, platform support, versioning/release behavior, review process, supported versions) — must update those docs **in the same PR** as the change. If a doc impact is discovered after merge, the docs fix is the immediate next commit, not backlog. Stale docs are treated as bugs.
**Docs-sync (hard rule, owner-set 2026-06-11):** any change that alters something these docs describe — README.md, `.github/CONTRIBUTING.md`, `.github/SECURITY.md`, `.github/SUPPORT.md`, LICENSE, or `docs/**` (install flows, Docker tag semantics, platform support, versioning/release behavior, review process, supported versions) — must update those docs **in the same PR** as the change. If a doc impact is discovered after merge, the docs fix is the immediate next commit, not backlog. Stale docs are treated as bugs.
**Release notes / changelog (hard rule, owner-set 2026-06-16):** every tagged release gets a **high-quality, user-facing `## [X.Y.Z] — DATE` section in `CHANGELOG.md`** before (or in the same hour as) the tag — never the "Auto-generated release for vX.Y.Z…" fallback. `release.yml` extracts that section verbatim as the GitHub Release body (the `Extract CHANGELOG section for tag` step), so a missing/empty section ships a bare release. Quality bar (owner-restyled 2026-07-17, replaces the old bold-lead paragraphs): **quiet and scannable** — a short `**Highlights**` bullet list first (plain words, one line each), then `### Changed` / `### Added` / `### Docs` / `### Fixed` / `### License` / `### CI` subsections where each entry is a **single one-liner** with the `(#NNN)` issue/PR ref and contributor credit (`— thanks @user!`) where applicable. Written for users, grouped by theme, no multi-line paragraphs, **not** raw commit dumps. This applies to **preview builds too**: preview release notes summarize what's new on `main` since the last stable, in the same style. Workflow: as features merge, keep `## [Unreleased]` current; at release time rename it to the version + date. If a release was already cut with the fallback body, the next action is to backfill `CHANGELOG.md` **and** `gh release edit <tag>` the live body — not backlog.
+100 -109
View File
@@ -7,7 +7,7 @@
<p>
<a href="#quickstart">Quickstart</a> ·
<a href="#features">Features</a> ·
<a href="#why-ovs">Why OVS</a> ·
<a href="#why-ovs">vs Others</a> ·
<a href="#tts-engines">Engines</a> ·
<a href="#openai-api">API</a> ·
<a href="#sponsor--donate">Donate</a> ·
@@ -68,7 +68,7 @@
<td align="center">
<img src="docs/screenshot-gallery.png" alt="Voice Gallery" width="100%"/>
<br/><b>Voice Gallery</b><br/>
<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>
<td align="center">
<img src="docs/screenshot-dub.png" alt="Video Dubbing" width="100%"/>
@@ -96,44 +96,28 @@
## ✨ Features
The eight headliners and twelve more waiting under the fold.
Three flagships, five more headliners, and a dozen under the fold.
<table>
<tr>
<td align="center" width="25%">
<h3>🎙️ Voice Cloning</h3>
<p>3-second clip → mirror any voice.<br/><b>646 languages</b>, zero-shot.</p>
</td>
<td align="center" width="25%">
<h3>🎨 Voice Design</h3>
<p>Gender, age, accent, pitch, speed,<br/>emotion, dialect — <b>dial it in</b>.</p>
</td>
<td align="center" width="25%">
<h3>🎬 Video Dubbing</h3>
<p>YouTube URL or file → transcribe →<br/>translate → re-voice → <b>MP4</b>.</p>
</td>
<td align="center" width="25%">
<h3>📖 Audiobook Editor</h3>
<p>Import text, EPUB, or PDF. Auto-chapter,<br/>loudnorm, metadata. Export <b>.m4b</b>.</p>
</td>
<td width="33%"><img src="docs/features/clone.png" alt="Voice Cloning" width="100%"/></td>
<td width="33%"><img src="docs/features/design.png" alt="Voice Design" width="100%"/></td>
<td width="33%"><img src="docs/features/dub.png" alt="Video Dubbing" width="100%"/></td>
</tr>
<tr>
<td align="center" valign="top">
<h3>🎭 Stories</h3>
<p>Multi-voice editor. Assign voices<br/>per-line, preview, <b>export full cast</b>.</p>
</td>
<td align="center" valign="top">
<h3>⌨️ Dictation Widget</h3>
<p><kbd>⌘</kbd>+<kbd>⇧</kbd>+<kbd>Space</kbd> from <b>any app</b>.<br/>Transcribes, auto-pastes, disappears.</p>
</td>
<td align="center" valign="top">
<h3>🔐 100% Local</h3>
<p>No keys, no cloud, no accounts.<br/><b>Your machine only</b>.</p>
</td>
<td align="center" valign="top">
<h3>🤖 MCP Server</h3>
<p>Use OmniVoice from <b>Claude</b>,<br/>Cursor, or any MCP client.</p>
</td>
<td align="center">🎙️ <b>Voice Cloning</b><br/><sub>3-sec clip → any voice · 646 languages · zero-shot</sub></td>
<td align="center">🎨 <b>Voice Design</b><br/><sub>Describe it — gender, age, accent, emotion</sub></td>
<td align="center">🎬 <b>Video Dubbing</b><br/><sub>Transcribe → translate → re-voice → MP4</sub></td>
</tr>
</table>
<table>
<tr>
<td align="center" width="20%">📖<br/><b>Audiobook</b><br/><sub>EPUB/PDF → .m4b, multi-voice cast</sub></td>
<td align="center" width="20%">🎭<br/><b>Stories</b><br/><sub>Multi-voice script editor</sub></td>
<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>
@@ -171,38 +155,18 @@ The eight headliners — and twelve more waiting under the fold.
<sub><b>macOS:</b> first launch needs a one-time approval — right-click → <b>Open</b> (or System Settings → Privacy &amp; 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>
</div>
Pick your OS and follow the guide end-to-end:
- 🍎 **macOS** — [docs/install/macos.md](docs/install/macos.md)
- 🪟 **Windows** — [docs/install/windows.md](docs/install/windows.md)
- 🐧 **Linux** — [docs/install/linux.md](docs/install/linux.md)
- 🐳 **Docker** — [docs/install/docker.md](docs/install/docker.md) · [Docker Hub: `palashdeb/omnivoice-studio`](https://hub.docker.com/r/palashdeb/omnivoice-studio)
Feels slow? [docs/performance.md](docs/performance.md) covers where generation time actually goes, the tuning knobs, and the three classic causes of "it got slow".
Want breaths, laughter, pauses, whispering, or emotion in the output? [docs/expressive-speech.md](docs/expressive-speech.md) covers exactly what each engine can do today — and what's spec'd but not shipped yet.
> Coming from **[CorentinJ/Real-Time-Voice-Cloning](https://github.com/CorentinJ/Real-Time-Voice-Cloning)** (now archived)? There's a dedicated migration guide: [docs/migration/real-time-voice-cloning.md](docs/migration/real-time-voice-cloning.md).
**Install guide:** [🍎 macOS](docs/install/macos.md) · [🪟 Windows](docs/install/windows.md) · [🐧 Linux](docs/install/linux.md) · [🐳 Docker](docs/install/docker.md)
<details>
<summary><b>🧰 Stuck? Self-checks, tokens &amp; restricted networks</b></summary>
<summary><b>🧰 Troubleshooting · slow generation · HF tokens · restricted networks</b></summary>
<br/>
Run the built-in self-check first — **Settings → About → "Run
self-check"** in the app, or `uv run python backend/main.py --diagnose` from
a checkout (`--deep` also test-loads the active engine). Then see
[docs/install/troubleshooting.md](docs/install/troubleshooting.md) for the
top 10 install errors. The in-app error UI deeplinks to those entries when
something breaks at runtime, and **Settings → About → "Save diagnostic
bundle"** packages scrubbed logs + the self-check report for bug reports.
For Hugging Face token setup, see
[docs/setup/huggingface-token.md](docs/setup/huggingface-token.md). For
diarization-specific gating, see
[docs/features/diarization.md](docs/features/diarization.md). For download
speed, the ⚡ fast-download (Xet) status, and restricted-network / mirror
options, see [docs/downloading-models.md](docs/downloading-models.md).
- **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.
- **HF tokens · diarization · download speed / mirrors:** [tokens](docs/setup/huggingface-token.md) · [diarization](docs/features/diarization.md) · [downloads](docs/downloading-models.md).
- **Coming from [Real-Time-Voice-Cloning](https://github.com/CorentinJ/Real-Time-Voice-Cloning)?** [Migration guide](docs/migration/real-time-voice-cloning.md).
</details>
@@ -210,7 +174,7 @@ options, see [docs/downloading-models.md](docs/downloading-models.md).
<a id="why-ovs"></a>
## 💡 Why OmniVoice?
## ⚖️ vs Others
ElevenLabs charges **$5$330/mo** and processes your audio on their servers. OmniVoice Studio runs **on your hardware, with no usage limits.**
@@ -227,7 +191,7 @@ ElevenLabs charges **$5$330/mo** and processes your audio on their servers. O
| **GPU Support** | N/A (cloud) | CUDA · Apple Silicon · ROCm (Linux) · CPU |
| **Desktop App** | ❌ | ✅ macOS · Windows · Linux |
| **TTS Engines** | 1 | **14** — [full matrix](#tts-engines) |
| **ASR Engines** | 1 | **10** — [full lineup](#asr-engines) |
| **ASR Engines** | 1 | **11** — [full lineup](#asr-engines) |
| **MCP Server** | ❌ | ✅ Use from Claude, Cursor, any MCP client |
| **Self-check** | ❌ | ✅ Diagnostics suite, error journal, scrubbed debug bundles |
| **Customizable** | ❌ Closed | ✅ Fork it, extend it, ship it |
@@ -254,14 +218,8 @@ Professional-grade voice AI, minus the subscription and the cloud.
| **Python** | 3.10+ (managed by `uv`) | 3.113.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)). In **Docker/Podman**, pull the dedicated ROCm image instead: `ghcr.io/debpalash/omnivoice-studio:rocm` ([docs/install/docker.md](docs/install/docker.md#pull-and-run-amd-gpu--rocm)). **On Windows, AMD GPUs (incl. Ryzen AI iGPUs) run CPU-only**: PyTorch has no Windows ROCm wheels, so Windows GPU acceleration is NVIDIA/CUDA-only ([docs/install/windows.md](docs/install/windows.md#gpu-support)).
> [!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>
@@ -334,47 +292,85 @@ 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.
```
┌─────────────────────────────────────────────────────────────┐
Frontend (React)
DubTab · VoiceConsole · Stories · Audiobook · Gallery
Dictation · BatchQueue · Diagnostics · MCP Client
├─────────────────────────────────────────────────────────────┤
│ Backend (FastAPI) │
100+ API endpoints · SSE+WSS streaming · SQLite
├──────────┬──────────┬──────────┬──────────┬────────────────┤
WhisperX │ Demucs │OmniVoice │ Pyannote │ Engine Routing
(+7 ASR │ Source (+10 │ Diariz- │ ↳ GPU preflight
│ engines) │ Sep. │ TTS) │ ation │ ↳ No silent CPU │
└──────────┴──────────┴──────────┴──────────┴────────────────┘
CUDA / MPS / ROCm / CPU (auto-detected + routed)
┌────────────────────────────────────────────────────────────────────
Tauri v2 shell — Rust
window state · global dictation hotkey · system tray ·
signed auto-updater (stable/preview) · single-instance ·
│ first-run bootstrap (installs uv + Python venv) · blank guard │
├────────────────────────────────────────────────────────────────────┤
Frontend — React + Vite
│ Studio · Dub · Stories · Audiobook · Gallery · Dictation · │
Batch · Diagnostics · MCP client — Zustand store · WS bus
▲ IPC / HTTP + WS
├──────────────────────────┼─────────────────────────────────────────┤
│ Backend — FastAPI sidecar @ localhost:3900 │
│ 100+ REST endpoints · SSE + WebSocket streaming · │
│ SQLite + Alembic (omnivoice_data/) · OpenAI-compatible API │
├───────────┬───────────┬───────────┬───────────┬────────────────────┤
│ TTS ×14 │ ASR ×11 │ Demucs │ Pyannote │ AudioSeal │
│ clone / │ WhisperX │ vocal │ speaker │ watermark │
│ design │ +10 more │ isolation│ diariz. │ embed / detect │
├───────────┴───────────┴───────────┴───────────┴────────────────────┤
│ Engine routing — per-engine GPU preflight, no silent CPU fallback │
│ Hardware: CUDA · MPS · ROCm (Linux) · CPU (auto-detected) │
└────────────────────────────────────────────────────────────────────┘
```
- **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`:
```sh
# 1 — find a cloned voice's profile ID
curl -s http://localhost:3900/v1/audio/voices | jq '.voices[] | select(.type=="profile") | {voice_id, name}'
# 2 — synthesize with it
curl http://localhost:3900/v1/audio/speech \
-H "Content-Type: application/json" \
-d '{"model": "tts-1", "voice": "alloy", "input": "Generated on my own hardware.", "response_format": "wav"}' \
-d '{"model":"tts-1","voice":"<profile-id>","input":"Made on my own hardware.","response_format":"wav"}' \
--output speech.wav
```
```python
from openai import OpenAI
client = OpenAI(base_url="http://localhost:3900/v1", api_key="none") # any string works — nothing checks it
client = OpenAI(base_url="http://localhost:3900/v1", api_key="none") # any string — nothing checks it
result = client.audio.transcriptions.create(model="whisper-1", file=open("clip.wav", "rb"))
print(result.text)
# TTS with your cloned voice (or "alloy" / "default"; model= can pin a specific engine)
with client.audio.speech.with_streaming_response.create(
model="tts-1", voice="<profile-id>", input="Made on my own hardware.") as r:
r.stream_to_file("speech.wav")
# STT
print(client.audio.transcriptions.create(model="whisper-1", file=open("clip.wav", "rb")).text)
```
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.
@@ -385,17 +381,20 @@ Calling the backend from **another machine** (LAN, Tailscale, behind a proxy)? I
[![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/debpalash/OmniVoice-Studio/blob/main/notebooks/OmniVoice_Studio_Colab.ipynb)
No local GPU? The official notebook ([notebooks/OmniVoice_Studio_Colab.ipynb](notebooks/OmniVoice_Studio_Colab.ipynb)) boots the full app — web UI included — on a free Colab T4: it builds the frontend in-notebook, installs the backend with uv (reusing Colab's preinstalled CUDA PyTorch), and opens the UI through Colab's built-in port proxy. No third-party tunnels, no API keys. It then walks the whole feature surface as a guided API tour with inline playback: multilingual TTS, voice cloning and design, saved voice profiles, transcription, AI-watermark detection, the OpenAI-compatible API, a multi-voice story, a chaptered m4b audiobook, and a miniature video dub with vocal-isolation stems.
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.
---
@@ -415,13 +414,13 @@ Ships two [skills](https://skills.sh): **`omnivoice`** — speak and transcribe
| Category | Features |
|----------|----------|
| **Longform** | Audiobook editor (text/EPUB/PDF → chaptered .m4b), Stories multi-voice editor, two-pass loudnorm mastering, crash-resume for interrupted renders, pronunciation control + SSML-lite prosody |
| **Longform** | Audiobook editor (text/EPUB/PDF → chaptered .m4b) with multi-voice cast, expressive controls, live per-chapter progress + Stop, and a one-click sample; Stories multi-voice editor, two-pass loudnorm mastering, crash-resume for interrupted renders, pronunciation control + SSML-lite prosody |
| **Dubbing** | Full pipeline (transcribe→translate→synthesize→mux), scene-aware splitting, lip-sync scoring, streaming TTS, per-speaker voice assignment, Smart Fit timing + second-pass QC, paste-in translations from any external tool, dedicated Dub home |
| **Voice** | Zero-shot cloning, voice design, A/B comparison, voice preview widget, gallery with favorites/tags, portable persona bundles (`.ovsvoice`), voice console workspace |
| **Voice** | Zero-shot cloning, voice design, A/B comparison, voice preview widget, gallery with favorites/tags (its voices selectable in every picker — Studio, Audiobook, Stories, Dubbing), portable persona bundles (`.ovsvoice`), voice console workspace |
| **Audio** | Demucs vocal isolation, per-segment gain, selective track export, stem/SRT/VTT/MP3 export, unlimited-length TTS via sentence-chunked generation |
| **Multi-Lang** | Multi-language batch picker, batch dubbing queue with sequential GPU execution |
| **Diarization** | Pyannote ML diarization, auto speaker clone extraction, per-speaker voice assignment |
| **ASR** | 10 engines (WhisperX, Faster-Whisper, isolated Faster-Whisper, MLX Whisper, PyTorch Whisper, Parakeet TDT, Parakeet TDT v3 MLX, Moonshine, FunASR/SenseVoice, sherpa-onnx live dictation), crash-isolated subprocess backend |
| **ASR** | 11 engines (WhisperX, Faster-Whisper, isolated Faster-Whisper, MLX Whisper, PyTorch Whisper, Parakeet TDT, Parakeet TDT v3 MLX, Moonshine, FunASR/SenseVoice, sherpa-onnx live dictation, OpenAI-compatible remote), crash-isolated subprocess backend |
| **TTS** | 14 engines (OmniVoice, CosyVoice 3, GPT-SoVITS, VoxCPM2, MOSS-TTS-Nano, KittenTTS, MLX-Audio, Sherpa-ONNX, + lazy: IndexTTS 2, OmniVoice GGUF, Supertonic 3, MOSS-TTS-v1.5, dots.tts, Confucius4-TTS), engine routing with GPU preflight |
| **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 |
@@ -443,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 (thousands of dollars over the last three months). If OmniVoice has created value for you, covering a slice of those bills keeps development full-time.
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.
<div align="center">
**This month's agent bill fund**
<img src="https://img.shields.io/badge/raised_%2410_of_%24200-5%25-EAB308?style=for-the-badge" alt="$10 / $200 raised" />
<img src="https://img.shields.io/badge/raised_%2410_of_%24200-5%25-EAB308?style=for-the-badge" alt="This month's agent-bill fund: $10 / $200" />
<br/><br/>
@@ -457,15 +454,9 @@ OmniVoice Studio is built by one developer using Claude Code and AI agents — a
&nbsp;&nbsp;
<a href="https://paypal.me/palashCoder"><img src="https://img.shields.io/badge/PayPal-Donate-00457C?style=for-the-badge&logo=paypal&logoColor=white" alt="PayPal" /></a>
<br/>
<sub>Every dollar goes directly to agent bills — keeping OmniVoice development continuous.</sub>
<br/><br/>
<sub><b>More apps from the creator of OmniVoice Studio</b> — same local-first philosophy:
<a href="https://github.com/debpalash/Opal"><b>Opal</b> 💠</a> (play everything — the media player for the AI era) ·
<a href="https://github.com/debpalash/memxt"><b>memxt</b> 🧠</a> (local memory for Claude Code & coding agents).
A ⭐ on those helps too → <a href="#more-from-the-maker">details below</a>.</sub>
<sub>Also from the maker: <a href="https://github.com/debpalash/Opal"><b>Opal</b> 💠</a> · <a href="https://github.com/debpalash/memxt"><b>memxt</b> 🧠</a> — a ⭐ helps too.</sub>
</div>
@@ -521,7 +512,7 @@ OmniVoice is **free** and **AGPL-3.0** — no paid tier, no SaaS revenue. Sponso
Yes please — bug fixes, new TTS engine adapters, UI improvements, docs, translations. All of it.
- 📖 Read the **[Contributing Guide](CONTRIBUTING.md)** for setup, code style, and PR workflow
- 📖 Read the **[Contributing Guide](.github/CONTRIBUTING.md)** for setup, code style, and PR workflow
- 🐛 Browse [good first issues](https://github.com/debpalash/OmniVoice-Studio/labels/good%20first%20issue)
- 💬 Join our [Discord](https://discord.gg/bzQavDfVV9) to discuss ideas or ask for help
+1 -1
View File
@@ -509,7 +509,7 @@ OmniVoice **免费**且采用 **AGPL-3.0** 许可——没有付费版,没有
非常欢迎——Bug 修复、新的 TTS 引擎适配器、UI 改进、文档、翻译。统统欢迎。
- 📖 阅读 **[贡献指南](CONTRIBUTING.md)** 了解环境搭建、代码风格和 PR 工作流
- 📖 阅读 **[贡献指南](.github/CONTRIBUTING.md)** 了解环境搭建、代码风格和 PR 工作流
- 🐛 浏览 [good first issues](https://github.com/debpalash/OmniVoice-Studio/labels/good%20first%20issue)
- 💬 加入我们的 [Discord](https://discord.gg/bzQavDfVV9) 讨论想法或寻求帮助
+16 -4
View File
@@ -229,7 +229,16 @@ def clear_dub_history():
"""Delete persisted dub rows and their on-disk dirs (scoped to known IDs)."""
with db_conn() as conn:
ids = [r["id"] for r in conn.execute("SELECT id FROM dub_history").fetchall()]
conn.execute("DELETE FROM dub_history")
def _delete_rows():
with db_conn() as conn:
conn.execute("DELETE FROM dub_history")
# Row-delete + in-memory evict together, so an ingest finishing right now
# can't re-save a job the user just cleared (#1252 review). This path
# never evicted from memory at all before, so an in-flight job survived
# "clear history" outright.
dub_pipeline.purge_jobs(ids, delete_rows=_delete_rows, include_inflight=True)
for jid in ids:
safe = _safe_job_dir(jid)
if safe and os.path.isdir(safe):
@@ -239,12 +248,15 @@ def clear_dub_history():
@router.delete("/dub/history/{history_id}")
def delete_single_dub_history(history_id: str):
with db_conn() as conn:
conn.execute("DELETE FROM dub_history WHERE id=?", (history_id,))
def _delete_row():
with db_conn() as conn:
conn.execute("DELETE FROM dub_history WHERE id=?", (history_id,))
# Atomic with the evict — see purge_jobs (#1252 review).
dub_pipeline.purge_jobs([history_id], delete_rows=_delete_row)
safe = _safe_job_dir(history_id)
if safe and os.path.isdir(safe):
shutil.rmtree(safe, ignore_errors=True)
_dub_jobs.pop(history_id, None)
event_bus.emit("dub_history", {"action": "deleted", "id": history_id})
return {"deleted": True}
+9 -8
View File
@@ -12,6 +12,7 @@ from fastapi.responses import FileResponse, StreamingResponse
from core.config import DUB_DIR, dub_seg_path
from core.tasks import task_manager
from core.http_headers import content_disposition
from api.routers.dub_core import _get_job
from services.ffmpeg_utils import (
bed_mix_filter,
@@ -515,7 +516,7 @@ async def dub_download(
return _native_save(out_path, save_path, dl_name, media_type=media_type)
return FileResponse(
out_path, media_type=media_type,
headers={"Content-Disposition": f'attachment; filename="{dl_name}"'},
headers={"Content-Disposition": content_disposition(dl_name)},
)
# Determine whether this export should drive video through a per-segment
@@ -798,7 +799,7 @@ async def dub_download(
return FileResponse(
output_path, media_type="video/mp4",
headers={"Content-Disposition": f'attachment; filename="{dl_name}"', **extra_headers},
headers={"Content-Disposition": content_disposition(dl_name), **extra_headers},
)
@@ -1382,7 +1383,7 @@ async def dub_download_audio(job_id: str, lang: str = Query(None), preserve_bg:
return _native_save(wav_path, save_path, dl_name, media_type="audio/wav")
return FileResponse(
wav_path, media_type="audio/wav",
headers={"Content-Disposition": f'attachment; filename="{dl_name}"'},
headers={"Content-Disposition": content_disposition(dl_name)},
)
@@ -1469,7 +1470,7 @@ async def dub_export_srt(
return Response(
content=srt_content,
media_type="text/plain",
headers={"Content-Disposition": f'attachment; filename="{dl_name}"'},
headers={"Content-Disposition": content_disposition(dl_name)},
)
def _format_vtt_time(seconds):
@@ -1516,7 +1517,7 @@ async def dub_export_vtt(
return Response(
content=vtt_content,
media_type="text/vtt",
headers={"Content-Disposition": f'attachment; filename="{dl_name}"'},
headers={"Content-Disposition": content_disposition(dl_name)},
)
@@ -1557,7 +1558,7 @@ async def dub_export_segments_zip(job_id: str, lang: str = Query(None)):
return Response(
content=zip_buffer.read(),
media_type="application/zip",
headers={"Content-Disposition": f'attachment; filename="segments_{safe_name}.zip"'},
headers={"Content-Disposition": content_disposition(f"segments_{safe_name}.zip")},
)
@router.get("/dub/download-mp3/{job_id}")
@@ -1637,7 +1638,7 @@ async def dub_download_mp3(job_id: str, lang: str = Query(None), preserve_bg: bo
return _native_save(mp3_path, save_path, dl_name, media_type="audio/mpeg")
return FileResponse(
mp3_path, media_type="audio/mpeg",
headers={"Content-Disposition": f'attachment; filename="{dl_name}"'},
headers={"Content-Disposition": content_disposition(dl_name)},
)
@router.get("/dub/export-stems/{job_id}")
@@ -1676,5 +1677,5 @@ async def dub_export_stems(job_id: str, lang: str = Query(None)):
return Response(
content=zip_buffer.read(),
media_type="application/zip",
headers={"Content-Disposition": f'attachment; filename="stems_{safe_name}.zip"'},
headers={"Content-Disposition": content_disposition(f"stems_{safe_name}.zip")},
)
+103 -2
View File
@@ -363,6 +363,39 @@ def _oom_friendly_reraise(e):
f"([WinError 193]). Reinstall or repair that component — the Flush "
f"button won't help here. Underlying error: {e}"
) from e
# #1227: Windows Smart App Control / an App Control (WDAC) policy blocked
# a file the engine needs — "[WinError 4551] An Application Control policy
# has blocked this file". WinError 1260 is the same class from the older
# Software Restriction / AppLocker policies. Not OOM, and Flush can't help:
# the OS is refusing to load the binary at all.
if ("[winerror 4551]" in _low or "[winerror 1260]" in _low
or "application control policy" in _low):
raise RuntimeError(
f"Windows blocked a file OmniVoice needs from running — an "
f"Application Control policy (Smart App Control, WDAC, or "
f"AppLocker) refused to load it. On a personal PC: Windows "
f"Security → App & browser control → Smart App Control → Off "
f"(note Windows only lets you turn it off once — re-enabling "
f"needs a Windows reset), then restart OmniVoice. On a managed/"
f"work PC ask IT to allow the OmniVoice install folder. The Flush "
f"button won't help. Underlying error: {e}"
) from e
# #1221: libsndfile/soundfile could not read or write an audio file. Its
# errors are bare ("LibsndfileError: System error.") so they used to fall
# through to the unrecognized catch-all. audio_io._describe_write_failure
# already names the target for the WRITE path; this covers every other
# libsndfile surface (reading a reference clip, a decode) with the causes
# that actually produce an OS-level audio I/O failure.
if "libsndfile" in _low or "writing the audio file failed" in _low:
raise RuntimeError(
f"An audio file couldn't be read or written (libsndfile failed at "
f"the OS level). This is a file/disk problem, not a memory one: "
f"check the drive isn't full, the output and temp folders exist "
f"and are writable, and that antivirus or OneDrive isn't locking "
f"them (add an OmniVoice exclusion if you use one). If it happens "
f"only with one reference clip, re-import that clip. Underlying "
f"error: {e}"
) from e
# #715: a "[Errno 32] Broken pipe" (BrokenPipeError) surfacing from
# generation is NOT out of memory — it means the backend's stdout/stderr
# pipe to the desktop shell that launched it closed mid-render (an orphaned
@@ -601,11 +634,66 @@ def _run_backend_inference(
except ValueError as e:
# Don't wrap validation errors in OOM message
raise e
raise _language_rejection_or(e, backend, language)
except Exception as e:
rewritten = _language_rejection_or(e, backend, language)
if rewritten is not e:
raise rewritten from e
_oom_friendly_reraise(e)
# #1257: the language picker offers all 646 languages regardless of engine,
# because MLXAudioBackend.supported_languages() returns ["multi"] on the stated
# assumption that "each engine silently ignores languages it doesn't know".
# That assumption is false — the underlying library raises, and the reporter got
# a bare 400 that recited 23 language codes without saying which engine was
# refusing, or that switching engines was the fix.
# Each signature must be about the LANGUAGE itself. "Unsupported language" as a
# bare prefix also matches "Unsupported language model configuration" — a model
# problem handed engine-switch advice it has no use for (#1257 review) — so the
# looser wordings require the rejected thing to end there or be a code/name.
_LANGUAGE_REJECTION_SIGNATURES = (
"invalid language code",
"language not supported",
"language is not supported",
"unsupported language code",
)
#: `unsupported language: xx` / `unsupported language 'xx'` — but not
#: `unsupported language model ...`.
_LANGUAGE_REJECTION_RE = re.compile(
r"unsupported language\s*[:=]|unsupported language\s*['\"]|"
r"unsupported language\s*$",
re.IGNORECASE | re.MULTILINE,
)
def _language_rejection_or(e: BaseException, backend, language):
"""``e`` rewritten with engine context when it's a language rejection.
Returns ``e`` unchanged otherwise, so this is safe to wrap any failure in.
Matched on the message, not the type: the engines multiplex third-party
libraries that each raise their own class.
"""
text = str(e)
low = text.lower()
if not any(sig in low for sig in _LANGUAGE_REJECTION_SIGNATURES) and not (
_LANGUAGE_REJECTION_RE.search(text)
):
return e
engine = getattr(backend, "display_name", None) or getattr(
type(backend), "id", type(backend).__name__
)
requested = f" '{language}'" if language else ""
return ValueError(
f"The {engine} engine can't speak{requested}. OmniVoice offers every "
f"language its default engine supports, but each engine covers a "
f"different set — pick one this engine supports, or switch engine in "
f"Settings → Engines (the OmniVoice engine has the widest coverage) "
f"and generate again. Engine's own message: {e}"
)
def _persist_profile_ref_text(profile_id: str, ref_text: str) -> None:
"""Cache an auto-transcribed reference transcript onto its profile row.
@@ -870,7 +958,15 @@ async def generate_speech(
# /engines/select gate, so this is the only place it's enforced for synth).
from core.device_caps import detect_host_caps
from services.engine_routing import resolve_routing, routing_notice
_routing = resolve_routing(getattr(backend_cls, "gpu_compat", ("cpu",)), detect_host_caps())
# The engine's declared VRAM floor (#1226) — used by the routing gate and,
# below, to let a generate TIMEOUT name the same shortfall. Resolved once:
# every other job on this GPU pool (reference transcribe, assemble) leaves
# it at 0, so only TTS generates can get the under-provisioned wording.
_engine_min_vram_gb = getattr(backend_cls, "min_vram_gb", 0.0)
_routing = resolve_routing(
getattr(backend_cls, "gpu_compat", ("cpu",)), detect_host_caps(),
_engine_min_vram_gb,
)
if _routing["routing_status"] == "unavailable":
# The engine needs an accelerator this host lacks and has no CPU path.
raise HTTPException(status_code=400, detail=_routing["routing_reason"])
@@ -1195,6 +1291,7 @@ async def generate_speech(
max_chunk_chars, crossfade_ms,
),
what="TTS generate",
min_vram_gb=_engine_min_vram_gb,
timeout=_generate_timeout_s(text),
)
sample_rate = _backend.sample_rate
@@ -1210,6 +1307,7 @@ async def generate_speech(
max_chunk_chars, crossfade_ms,
),
what="TTS generate",
min_vram_gb=_engine_min_vram_gb,
timeout=_generate_timeout_s(text),
)
sample_rate = _model.sampling_rate
@@ -1244,6 +1342,7 @@ async def generate_speech(
raw, preview, sample_rate = await run_on_gpu_pool_guarded(
functools.partial(_render_stream_chunk, i, chunk_text),
what="TTS generate",
min_vram_gb=_engine_min_vram_gb,
# Budget scaled to THIS chunk (#1190) — the flat
# 300s here is what made long streamed renders fail
# even after the v0.3.22 scaled budget shipped.
@@ -1344,6 +1443,7 @@ async def generate_speech(
max_chunk_chars, crossfade_ms,
),
what="TTS generate",
min_vram_gb=_engine_min_vram_gb,
timeout=_generate_timeout_s(text),
)
# Read after generation: engines with lazy model loading report
@@ -1360,6 +1460,7 @@ async def generate_speech(
max_chunk_chars, crossfade_ms,
),
what="TTS generate",
min_vram_gb=_engine_min_vram_gb,
timeout=_generate_timeout_s(text),
)
sample_rate = _model.sampling_rate
+2 -1
View File
@@ -39,6 +39,7 @@ from core.config import OUTPUTS_DIR, VOICES_DIR
from core.db import db_conn
from core import event_bus
from core.version import APP_VERSION
from core.http_headers import content_disposition
logger = logging.getLogger("omnivoice.marketplace")
@@ -131,7 +132,7 @@ def export_profile(profile_id: str):
buf,
media_type="application/zip",
headers={
"Content-Disposition": f'attachment; filename="{filename}"',
"Content-Disposition": content_disposition(filename),
"Content-Length": str(buf.getbuffer().nbytes),
},
)
+6 -2
View File
@@ -32,6 +32,7 @@ from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field
from services.model_manager import _gpu_pool, run_on_gpu_pool_guarded
from core.http_headers import content_disposition
logger = logging.getLogger("omnivoice.openai_compat")
@@ -313,7 +314,10 @@ async def create_speech(req: SpeechRequest):
# Routing gate (#21 — no silent CPU fallback), identical to REST /generate.
from core.device_caps import detect_host_caps
from services.engine_routing import resolve_routing, routing_notice
_routing = resolve_routing(getattr(backend, "gpu_compat", ("cpu",)), detect_host_caps())
_routing = resolve_routing(
getattr(backend, "gpu_compat", ("cpu",)), detect_host_caps(),
getattr(backend, "min_vram_gb", 0.0),
)
if _routing["routing_status"] == "unavailable":
raise HTTPException(status_code=400, detail=_routing["routing_reason"])
_routing_notice = routing_notice(_routing) # (status, reason) or None
@@ -463,7 +467,7 @@ async def create_speech(req: SpeechRequest):
_headers = {
"Content-Length": str(len(audio_bytes)),
"Content-Disposition": f'inline; filename="speech.{ext}"',
"Content-Disposition": content_disposition(f"speech.{ext}", disposition="inline"),
}
if _routing_notice:
from services.engine_routing import header_safe_reason
+2 -1
View File
@@ -28,6 +28,7 @@ from core import event_bus
from core.config import VOICES_DIR # noqa: F401 — re-exported for tests/monkeypatch
from core.db import db_conn
from core.version import APP_VERSION
from core.http_headers import content_disposition
from services import persona_bundle as pb
router = APIRouter()
@@ -100,7 +101,7 @@ async def export_persona(
BytesIO(content),
media_type="application/zip",
headers={
"Content-Disposition": f'attachment; filename="{filename}"',
"Content-Disposition": content_disposition(filename),
"Content-Length": str(len(content)),
},
)
+52 -2
View File
@@ -19,6 +19,7 @@ from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from core import prefs
from core.failure import is_hf_connectivity_error
from utils import hf_progress
from utils import download_aggregator
# Weight-floor scan (MM2-07 / #352) lives in ``models.py`` — the lowest module in
@@ -320,6 +321,41 @@ class InstallModelRequest(BaseModel):
repo_id: str
def _is_retryable_download_error(exc: BaseException) -> bool:
"""Whether a failed download attempt is worth retrying.
Decides by CLASSIFICATION, not by exception type. The type-based tuple this
replaced ``(HfHubHTTPError, LocalEntryNotFoundError, OSError)`` silently
excluded ``httpx.RemoteProtocolError``, which inherits ``Exception``: a
4.6 GB model truncated at 4.0 GB escaped all five attempts and aborted the
install (#1224). Any future transport error with a novel base class would
have reopened the same hole.
A user cancel is never retryable, and neither is anything
``is_hf_connectivity_error`` does not recognise.
"""
# Imported here, not at module scope, for the same reason the worker does:
# huggingface_hub is heavy and this module is on the setup import path.
from huggingface_hub.utils import HfHubHTTPError, LocalEntryNotFoundError
if isinstance(exc, _InstallCancelled):
return False
if isinstance(exc, HfHubHTTPError):
# An auth / not-found / gone answer from the Hub is a settled verdict:
# the token is wrong, the repo is gated, or it isn't there. Retrying
# five times with backoff just delays the same message and postpones
# the install cooldown. (Pre-existing behaviour — the type-based tuple
# this replaced retried every HfHubHTTPError; surfaced in #1224 review.)
status = getattr(getattr(exc, "response", None), "status_code", None)
if status in (401, 403, 404, 410):
return False
return True
if isinstance(exc, (LocalEntryNotFoundError, OSError)):
return True
return is_hf_connectivity_error(str(exc))
@router.post("/models/install")
async def install_model(req: InstallModelRequest):
"""Download one HF repo snapshot; progress goes through the shared
@@ -492,8 +528,22 @@ async def install_model(req: InstallModelRequest):
_snapshot_path = snapshot_download(**dl_kwargs)
_validate_snapshot_has_weights(req.repo_id, _snapshot_path)
break
except (HfHubHTTPError, LocalEntryNotFoundError, OSError) as net_err:
if _attempt >= _max_attempts:
except Exception as net_err:
# #1224: a truncated body ("peer closed connection without
# sending complete message body") arrives as
# httpx.RemoteProtocolError, which inherits from Exception
# — NOT OSError — so it escaped the old
# (HfHubHTTPError, LocalEntryNotFoundError, OSError) tuple
# and aborted a 4.6 GB install at 4.0 GB with no retry.
# Widen to Exception and decide by CLASSIFICATION:
# is_hf_connectivity_error is already the single source of
# truth for "transient download failure" and now knows the
# truncation signatures. Anything unrecognised (a cancel, a
# validation failure, a bug) propagates untouched, exactly
# as before.
if _attempt >= _max_attempts or not _is_retryable_download_error(
net_err
):
raise
_backoff = min(30, 2 ** _attempt)
logger.info(
+2 -1
View File
@@ -14,6 +14,7 @@ from fastapi import APIRouter, UploadFile, File, Form, HTTPException
from fastapi.responses import Response
from services.ffmpeg_utils import find_ffmpeg, spawn_subprocess
from core.http_headers import content_disposition
router = APIRouter()
@@ -76,7 +77,7 @@ async def stories_encode(
return Response(
content=encoded,
media_type=mime,
headers={"Content-Disposition": f'attachment; filename="story.{ext}"'},
headers={"Content-Disposition": content_disposition(f"story.{ext}")},
)
finally:
for p in (in_path, out_path):
+16 -1
View File
@@ -90,6 +90,20 @@ async def ws_tts(websocket: WebSocket):
get_backend_class,
)
engine_id = data.get("engine")
# #1224: leave a breadcrumb when memory is already tight before
# a heavy load. /generate has done this since the 16 GB-Mac
# reports, but the streaming path — which the desktop UI tries
# FIRST — never did, so the load most likely to tip the machine
# into an OS OOM kill was the one load with no trail. The
# captured stderr tail is what a SIGKILL report has to go on.
# Advisory only: the OS can reclaim cache, and refusing here
# would brick loads that would actually have coped.
try:
from services.memory_budget import log_if_low
log_if_low(f"TTS stream load ({engine_id or 'active engine'})")
except Exception:
pass
if engine_id:
cls = get_backend_class(engine_id)
backend = cls()
@@ -106,7 +120,8 @@ async def ws_tts(websocket: WebSocket):
from services.engine_routing import resolve_routing, routing_notice
from core.scrub import scrub_text
_routing = resolve_routing(
getattr(backend, "gpu_compat", ("cpu",)), detect_host_caps())
getattr(backend, "gpu_compat", ("cpu",)), detect_host_caps(),
getattr(backend, "min_vram_gb", 0.0))
if _routing["routing_status"] == "unavailable":
await websocket.send_json({
"type": "error",
+156 -17
View File
@@ -28,6 +28,7 @@ out of this backend-only slice.)
from __future__ import annotations
import functools
import os
import platform as _platform
import sys
from dataclasses import dataclass
@@ -52,6 +53,146 @@ DIRECTML_MARKER = "DirectML device present"
# ``wizard._detect_gpu`` (preflight), which already runs it. The probe only
# emits the torch-visible SM-arch caveat (cheap, metadata-only).
# ── ROCm GFX version overrides ───────────────────────────────────────────
# AMD GPUs on ROCm present through ``torch.cuda`` but some consumer parts have
# GFX IDs the installed ROCm build wasn't compiled for. Setting
# ``HSA_OVERRIDE_GFX_VERSION`` runs them on the closest supported architecture.
# Applied (with side effects) by ``model_manager._configure_rocm_if_needed``;
# read here so ``arch_unsupported()`` doesn't flag a GPU we know how to remap.
#
# Values are the TARGET gfx name, not the HSA version string, so callers can
# check whether the installed wheel actually contains that target before
# treating the remap as a solution (``hsa_override_for`` derives the env-var
# form). Remapping onto an architecture the build doesn't ship is not a fix —
# it just moves the failure from "no kernel for gfx1151" to "no kernel for
# gfx1100".
ROCM_GFX_OVERRIDES = {
# RDNA 3.5 (Strix Point / Strix Halo APUs) — override to gfx1100
"gfx1150": "gfx1100", "gfx1151": "gfx1100",
# RDNA 3 (RX 7000 series) — override to gfx1100
"gfx1101": "gfx1100", "gfx1102": "gfx1100", "gfx1103": "gfx1100",
# RDNA 2 (RX 6000 series) — override to gfx1030
"gfx1031": "gfx1030", "gfx1032": "gfx1030", "gfx1034": "gfx1030",
# Vega (RX Vega / Radeon VII) — override to gfx900 / gfx906
"gfx902": "gfx900", "gfx906": "gfx906",
}
def hsa_override_for(target_gfx: str) -> str:
"""``"gfx1100"`` → ``"11.0.0"``, the form HSA_OVERRIDE_GFX_VERSION wants.
The digits are major / minor / step, with the last two characters always
one digit each: gfx1100 11.0.0, gfx1030 10.3.0, gfx906 9.0.6.
"""
digits = _normalize_arch(target_gfx).removeprefix("gfx")
if len(digits) < 3 or not digits.isdigit():
raise ValueError(f"not a gfx architecture name: {target_gfx!r}")
return f"{digits[:-2]}.{digits[-2]}.{digits[-1]}"
def _normalize_arch(tag: str) -> str:
"""``"gfx90a:xnack+"`` → ``"gfx90a"``. Feature flags dropped, lowercased."""
return str(tag).split(":")[0].strip().lower()
def build_arch_list(torch) -> list[str]:
"""This torch build's compiled architecture list, or ``[]`` if unknown.
Prefers the public ``get_arch_list`` and falls back to the private
``_get_arch_list`` (older wheels only expose the latter).
"""
for name in ("get_arch_list", "_get_arch_list"):
fn = getattr(torch.cuda, name, None)
if callable(fn):
try:
return [str(a) for a in (fn() or [])]
except Exception:
return []
return []
def gfx_for_hsa_override(value: str) -> str | None:
"""``"11.0.0"`` → ``"gfx1100"``. The inverse of :func:`hsa_override_for`.
``None`` for anything that isn't a three-part numeric version — the user
set something we don't understand, and a guess is worse than leaving it be.
"""
parts = str(value).strip().split(".")
if len(parts) != 3 or not all(p.isdigit() for p in parts):
return None
major, minor, step = parts
if len(minor) != 1 or len(step) != 1:
return None
return f"gfx{int(major)}{minor}{step}"
def arch_unsupported(torch) -> tuple[str, tuple[str, ...]] | None:
"""``(device_arch, build_archs)`` when device 0's architecture is absent
from this torch build's compiled arch list — i.e. kernels cannot launch
("no kernel image is available for execution"). ``None`` means supported,
unknown, or not applicable.
**CUDA and ROCm name architectures in different namespaces.** A CUDA build
reports ``sm_89`` / ``compute_89``; a ROCm build reports ``gfx1100``. The
check must therefore branch on the build comparing a CUDA ``sm_`` tag
against a ROCm ``gfx`` list can never match, which made *every* ROCm host
look unsupported and silently force-routed it to CPU (#1228). Callers must
get the verdict from here rather than re-deriving a tag.
Never raises: any missing/odd metadata degrades to ``None`` (compatible),
matching the pre-existing fail-open contract.
"""
try:
if not torch.cuda.is_available():
return None
arch_list = build_arch_list(torch)
if not arch_list:
return None
if getattr(getattr(torch, "version", None), "hip", None) is not None:
# ── ROCm / HIP: arch_list holds gfx names ─────────────────────
override = os.environ.get("HSA_OVERRIDE_GFX_VERSION")
if override:
# An override remaps the device onto some other gfx target, so
# the native gfx name no longer describes what will run — but
# the remap is only valid if this build SHIPS that target. A
# stale or copy-pasted value (the #1228 reporter had set
# 11.0.0 on a card that no longer needs it) must not buy a free
# pass into kernels that don't exist. Unparseable values are
# left alone: the user asked for something we don't understand,
# and guessing would be worse than trusting them.
target = gfx_for_hsa_override(override)
if target is None or _normalize_arch(target) in {
_normalize_arch(a) for a in arch_list
}:
return None
return f"{target} (HSA_OVERRIDE_GFX_VERSION={override})", tuple(arch_list)
props = torch.cuda.get_device_properties(0)
gfx = _normalize_arch(getattr(props, "gcnArchName", "") or "")
if not gfx:
return None
build = {_normalize_arch(a) for a in arch_list}
if gfx in build:
return None
# _configure_rocm_if_needed() can remap this GPU onto a supported
# target before any kernel launches — but only counts as a fix if
# the build actually SHIPS that target. Remapping gfx1151 onto
# gfx1100 in a wheel that has neither just relocates the failure.
target = ROCM_GFX_OVERRIDES.get(gfx)
if target and _normalize_arch(target) in build:
return None
return gfx, tuple(arch_list)
# ── CUDA: arch_list holds sm_/compute_ tags ──────────────────────
major, minor = torch.cuda.get_device_capability(0)
sm_tag = f"sm_{major}{minor}"
if sm_tag in arch_list or f"compute_{major}{minor}" in arch_list:
return None
return sm_tag, tuple(arch_list)
except Exception:
# Arch metadata unavailable on this torch build — treat as compatible.
return None
@dataclass(frozen=True)
class HostCaps:
@@ -135,23 +276,16 @@ def _probe() -> HostCaps:
vram_gb = float(total) / (1024 ** 3)
except Exception:
notes.append("VRAM query failed")
# SM-arch mismatch (mirrors model_manager.check_device_compatibility).
try:
major, minor = torch.cuda.get_device_capability(0)
arch_list = getattr(torch.cuda, "_get_arch_list", lambda: [])()
if arch_list:
sm_tag = f"sm_{major}{minor}"
compute_tag = f"compute_{major}{minor}"
if sm_tag not in arch_list and compute_tag not in arch_list:
notes.append(
f"{device_name or 'GPU'} ({sm_tag}) not in this torch "
f"build's archs ({', '.join(arch_list)}) — "
f"{KERNEL_RISK_MARKER}"
)
except Exception:
# Arch metadata unavailable on this torch build — skip the check
# (treated as compatible, exactly as check_device_compatibility).
pass
# Arch mismatch — sm_ tags on CUDA, gfx names on ROCm. Shared with
# model_manager.check_device_compatibility() so probe and loader
# can never disagree (they used to, on every ROCm host — #1228).
mismatch = arch_unsupported(torch)
if mismatch is not None:
device_arch, archs = mismatch
notes.append(
f"{device_name or 'GPU'} ({device_arch}) not in this torch "
f"build's archs ({', '.join(archs)}) — {KERNEL_RISK_MARKER}"
)
# ── Intel XPU via IPEX ───────────────────────────────────────────────
try:
@@ -274,6 +408,11 @@ __all__ = [
"detect_host_caps",
"refresh",
"mlx_supported",
"arch_unsupported",
"gfx_for_hsa_override",
"hsa_override_for",
"build_arch_list",
"ROCM_GFX_OVERRIDES",
"KERNEL_RISK_MARKER",
"DIRECTML_MARKER",
]
+234 -11
View File
@@ -18,6 +18,7 @@ from __future__ import annotations
import os
import platform
import re
import shutil
import sys
from pathlib import Path
from typing import Any, 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.",
@@ -84,6 +91,18 @@ _HF_CONNECTIVITY_SIGNATURES = (
"timed out",
"an error happened while trying to locate the file on the hub", # LocalEntryNotFoundError
"we cannot find the requested files", # LocalEntryNotFoundError
# #1224: a TRUNCATED download — the server closed mid-body, so the client
# got fewer bytes than Content-Length promised. httpx words it "peer closed
# connection without sending complete message body"; urllib3/http.client
# raise IncompleteRead. This is as transient as a refused connection and
# must retry — a 4.6 GB model that dies at 4.0 GB used to abort the whole
# install (and, on the reporter's 16 GB Mac, take the process with it).
"peer closed connection",
"incomplete message body",
"incompleteread",
"incomplete read",
"connection broken", # urllib3 ProtocolError wrapper
"response ended prematurely",
)
# The failure must also be Hugging-Face-shaped — the configured endpoint/host
@@ -247,18 +266,43 @@ def classify(reason: str) -> str:
# argument" wording) keeps this from mislabelling unrelated failures; the
# transformers "errno 2" rule below is unaffected — it also requires the
# transformers + site-packages markers, which this signature lacks.
# #1225: the same errno raised by the DUB video download is a different
# class with a different remedy — the failing directory is the job folder
# under the OmniVoice data dir, not the system temp dir. Checked first so
# a download's errno 22 stops being handed the transcribe path's
# "check your TEMP folder" hint, which sends the user to the wrong place.
if is_os_write_refusal(reason) and any(
marker in low for marker in _DOWNLOAD_CONTEXT_MARKERS
):
return "VIDEO_DOWNLOAD_OS_ERROR"
# #1256: a third-party library shelled out to `ffprobe`/`ffmpeg` BY NAME and
# the OS had nothing to run. OmniVoice's own code always resolves the
# bundled sidecar explicitly, so this only ever comes from a dependency —
# which meant it arrived with no class at all and the user was told the
# engine "stopped with an error OmniVoice doesn't recognize". Checked
# before the generic errno-2 rules, which would otherwise claim it.
if _is_missing_media_tool(low):
return "MEDIA_TOOL_MISSING"
# #1251: Windows refused to map the model because the PAGING FILE is too
# small. The generate path already counted this as an OOM, but that hint
# ("close other apps, use a lighter engine") is the wrong remedy — the
# machine had 32 GB of RAM. Matched on the numeric code too, since the OS
# translates the message text, and in both the Python (`[WinError 1455]`)
# and Rust (`os error 1455`, from the safetensors mmap) spellings.
if "1455" in low and ("winerror" in low or "os error" in low):
return "WINDOWS_PAGING_FILE_TOO_SMALL"
if "paging file is too small" in low:
return "WINDOWS_PAGING_FILE_TOO_SMALL"
if "errno 22" in low:
return "OS_INVALID_ARGUMENT"
# An HF cache whose snapshot entries don't resolve (dangling symlinks /
# zero-byte stand-ins): transformers reports the weights missing ("does
# not appear to have a file named pytorch_model.bin or model.safetensors")
# even though the blobs are fully on disk. model_manager self-heals this
# (delete broken entries → snapshot_download → retry once); the class here
# covers both the raw transformers wording (any load surface can leak it)
# and OmniVoice's own repair messages, so the user-facing error and the
# auto bug report name the class and its automatic repair.
if ("does not appear to have a file named" in low
or "broken file link" in low):
# zero-byte stand-ins) or which is simply missing its weight shard.
# model_manager self-heals this (delete broken entries → snapshot_download
# → retry once); the class here covers the raw transformers wordings (any
# load surface can leak them) and OmniVoice's own repair messages, so the
# user-facing error and the auto bug report name the class and its
# automatic repair.
if is_incomplete_cache_message(low) or "broken file link" in low:
return "MODEL_CACHE_CORRUPT"
if (
"could not import module" in low
@@ -318,6 +362,13 @@ def classify(reason: str) -> str:
# Broken pipe" still classifies as a network blip.
if "unsupported url" in low or "no video formats" in low or "is not a valid url" in low:
return "UNSUPPORTED_VIDEO_URL"
# #1254: reported as intermittent — the same URL failed, then succeeded on
# a retry. That is a per-player-client format set, not real DRM, so the
# download path now escalates the client the way it does for a 403. If
# every client still says DRM, the video genuinely can't be fetched and the
# user needs to hear that rather than retry a fourth time.
if "drm protected" in low or "drm-protected" in low:
return "VIDEO_DRM_PROTECTED"
if (
"broken pipe" in low
or "connection reset" in low
@@ -326,6 +377,25 @@ def classify(reason: str) -> str:
or "timed out" in low
):
return "VIDEO_DOWNLOAD_NETWORK"
# #1227: Windows Smart App Control / WDAC / AppLocker refused to load a
# file the app needs. Matched on the numeric codes (locale-independent —
# the OS translates the message text) plus the English policy phrase.
if (
"[winerror 4551]" in low
or "[winerror 1260]" in low
or "application control policy" in low
):
return "WINDOWS_APP_CONTROL_BLOCKED"
# #1221: libsndfile failed an OS-level audio read/write. Its own wording is
# a bare "System error.", so match the library name — audio_io already
# prefixes the target path and free space onto the write-path failures.
# ``audio_io.AUDIO_WRITE_FAILED_MARKER`` (kept as a literal — core must not
# import services). Matching the marker instead of generic wording like
# "error opening" keeps a failed MODEL/config/archive open from being handed
# the audio remedy, while still classifying the enriched write failures that
# no longer carry the word "libsndfile" verbatim.
if "libsndfile" in low or "writing the audio file failed" in low:
return "AUDIO_IO_FAILED"
# A relocated/corrupted venv whose interpreter can't bootstrap its stdlib —
# the Rust self-heal rebuilds it; this names the class for the toast.
if "no module named 'encodings'" in low:
@@ -411,6 +481,159 @@ def diagnostic(*, reason: str, error_class: str, stage: str) -> str:
return sanitize(block)
# Signatures of the OS refusing a file operation. One list, because two
# consumers must agree: ``classify`` (which picks the class + hint) and
# ``dub_pipeline._with_target_facts`` (which decides whether to attach the
# destination). When they drifted, an ENOENT download classified as a disk
# problem but never got the folder named — the one fact that would have made
# the message actionable (#1225 review).
_OS_WRITE_REFUSAL_SIGNATURES = (
"errno 22", "invalid argument",
"errno 13", "permission denied",
"errno 28", "no space left",
"errno 2", "no such file or directory",
"unable to open for writing",
"unable to rename file",
)
# Wording that places a failure in the video-download path specifically.
_DOWNLOAD_CONTEXT_MARKERS = (
"unable to download video",
"unable to open for writing",
"unable to rename file",
"yt_dlp",
"yt-dlp",
)
#: The media binaries a dependency may shell out to by bare name.
_MEDIA_TOOLS = ("ffprobe", "ffmpeg")
def _is_missing_media_tool(low: str) -> bool:
"""True when the failure is "the OS could not find ffprobe/ffmpeg" (#1256).
Deliberately narrow: it must name the binary *as the thing that could not
be found*, so a perfectly ordinary "ffmpeg failed: no such file or
directory: /path/to/input.wav" — a missing INPUT, an entirely different
problem is not handed the "install the media engine" remedy.
"""
if "no such file or directory" not in low and "[winerror 2]" not in low:
return False
# The name must appear UNQUALIFIED — quoted with no directory part, which
# is how a bare-name spawn fails. A path that merely ends in the tool's
# name ('/tmp/ffmpeg', '~/Movies/my-ffmpeg-export.mp4') is a missing FILE,
# an entirely different problem that must not get the "repair your media
# engine" remedy (#1256 review).
return any(
f"'{tool}'" in low or f'"{tool}"' in low
for tool in _MEDIA_TOOLS
)
#: transformers' two ways of saying "this snapshot has no weight shard".
#:
#: They come from different code paths and share no wording, so matching only
#: the first — which both the classifier and model_manager's self-heal used to
#: do — silently missed half the class (#1273):
#:
#: hub load: "<repo> does not appear to have a file named
#: pytorch_model.bin or model.safetensors"
#: local dir: "Error no file named model.safetensors, or pytorch_model.bin,
#: found in directory <path>"
#:
#: The second is what a load of a *subfolder* inside a cached snapshot raises
#: (e.g. `audio_tokenizer/`), which is exactly where an interrupted download
#: leaves a repo half-written. Both mean the same thing and both are repaired
#: the same way, so both must classify and both must trigger the heal.
_INCOMPLETE_CACHE_PHRASES = (
"does not appear to have a file named",
# Matched as two fragments: the file list between them varies with the
# transformers version and the requested weight variant.
("error no file named", "found in directory"),
)
def is_incomplete_cache_message(text: str) -> bool:
"""True when *text* is transformers reporting a snapshot with no weights.
Takes an already-lowercased string. Shared by :func:`classify` and
model_manager's cache self-heal so the healer and the error message can
never disagree about what an interrupted download looks like.
"""
low = str(text).lower()
for phrase in _INCOMPLETE_CACHE_PHRASES:
if isinstance(phrase, tuple):
if all(part in low for part in phrase):
return True
elif phrase in low:
return True
return False
def is_os_write_refusal(reason: Optional[str]) -> bool:
"""True when *reason* looks like the OS refusing a file operation (a full
or removed drive, a read-only folder, an antivirus/cloud-sync lock) rather
than a network or format failure. Signature match only; never raises."""
low = (reason or "").lower()
return any(sig in low for sig in _OS_WRITE_REFUSAL_SIGNATURES)
def describe_path_target(path: str) -> str:
"""Observable facts about where we were writing — "the folder does not
exist", "the folder is not writable", "1,234 MB free on its drive".
A bare OS error ("[Errno 22] Invalid argument", "System error.") names
neither the target nor the reason, which is what makes those reports
un-actionable (#1225). Attaching what we CAN see distinguishes a full
drive from a removed one from an antivirus/OneDrive lock. Never raises
diagnosis must never replace the failure being diagnosed.
"""
facts: list[str] = []
try:
directory = os.path.dirname(os.path.abspath(path)) or "."
if not os.path.isdir(directory):
facts.append("the folder does not exist")
else:
if not os.access(directory, os.W_OK):
facts.append("the folder is not writable")
try:
free_mb = shutil.disk_usage(directory).free / (1024 ** 2)
facts.append(f"{free_mb:,.0f} MB free on its drive")
except OSError:
facts.append("free space could not be read")
except Exception:
return ""
return "; ".join(facts)
#: Exception types whose ``str()`` is a bare VALUE rather than a sentence, so
#: showing it alone tells the user nothing about what went wrong.
#: ``str(KeyError("mgw39lx3"))`` is ``"'mgw39lx3'"`` — the repr of the key.
_VALUE_ONLY_STR_EXCEPTIONS = (KeyError,)
def describe_exception(exc: BaseException) -> str:
"""``str(exc)`` in a form a human can act on.
#1252/#1253: a dub ingest failed with the toast ``ingest: 'mgw39lx3'`` and
nothing else the entire user-facing reason was the repr of a dict key,
because ``str(KeyError)`` does not mention that a lookup failed, or that it
was an exception at all. The reporter's ``'mgw39lx3'`` was their own job id
reflected back at them with no context.
Naming the class is the floor, not the goal: a failure that reaches here at
all is one nobody wrote a message for. It keeps the report diagnosable
instead of cryptic while the specific path gets its own handling.
"""
text = str(exc).strip()
if not text:
return type(exc).__name__
if isinstance(exc, _VALUE_ONLY_STR_EXCEPTIONS):
return f"{type(exc).__name__}: {text}"
return text
def build_failure(
exc_or_msg: Any,
*,
@@ -424,7 +647,7 @@ def build_failure(
"""
if isinstance(exc_or_msg, BaseException):
error_class = type(exc_or_msg).__name__
raw = str(exc_or_msg).strip() or error_class
raw = describe_exception(exc_or_msg)
else:
error_class = "Error"
raw = str(exc_or_msg).strip() or "Unknown failure"
+90
View File
@@ -0,0 +1,90 @@
"""RFC 6266 ``Content-Disposition`` construction.
#1262: exporting a voice profile whose name isn't spelled in Latin letters
returned a 500:
'latin-1' codec can't encode characters in position 22-25:
ordinal not in range(256)
``attachment; filename="`` is exactly 22 characters, so positions 22-25 were
the first four characters of the user's own profile name. HTTP header values
are latin-1 by definition, and every download endpoint built the header by
f-string interpolation, so any name outside latin-1 Chinese, Japanese,
Korean, Greek, Cyrillic, Hebrew, emoji crashed the request.
The sanitisers in front of those f-strings did not catch it because they all
filtered with ``str.isalnum()``, which is **True for every alphabetic script**,
not just ASCII. ``"我的声音".isalnum()`` is ``True``. They were removing
punctuation and passing the exact characters that break the header.
`content_disposition` is the one construction site: an ASCII-safe
``filename=`` that any client can read, plus the RFC 5987 ``filename*=`` that
gives modern browsers the user's real name back, correctly encoded.
"""
from __future__ import annotations
import re
import unicodedata
from urllib.parse import quote
__all__ = ["ascii_filename", "content_disposition"]
#: Characters Windows forbids in a filename, plus the quoting/injection risks
#: (`"` and `\` end the quoted-string; CR/LF would split the header).
_UNSAFE = re.compile(r'[\\/:*?"<>|\r\n\t]')
def _fold(text: str) -> str:
"""One filename part, reduced to safe ASCII."""
folded = unicodedata.normalize("NFKD", text)
# Drop the combining marks NFKD split off, keeping the base letters.
folded = "".join(c for c in folded if not unicodedata.combining(c))
folded = folded.encode("ascii", "ignore").decode("ascii")
return _UNSAFE.sub("_", folded).strip()
def ascii_filename(filename: str, fallback: str = "download") -> str:
"""A latin-1-safe rendering of *filename* for the legacy ``filename=``.
Accented Latin is folded to its base letters (``Sébastien``
``Sebastien``) rather than deleted, since that stays readable. Scripts with
no ASCII form (CJK, Cyrillic, Hebrew, emoji) have no meaningful fold, so
they drop out and *fallback* carries the name the ``filename*`` parameter
is what actually preserves those, and every browser released this decade
prefers it.
"""
raw = filename or ""
# Split the extension off FIRST: folding runs per-part so a name that is
# entirely non-ASCII loses its stem without also losing ".ovsvoice", which
# is what tells the OS (and the user) what the file actually is.
stem, dot, suffix = raw.rpartition(".")
if not dot:
stem, suffix = raw, ""
stem, suffix = _fold(stem), _fold(suffix)
if not stem.strip("_ ."):
stem = fallback
return f"{stem}.{suffix}" if suffix else stem
def content_disposition(
filename: str,
*,
disposition: str = "attachment",
fallback: str = "download",
) -> str:
"""A ``Content-Disposition`` value that is safe for ANY filename (#1262).
Emits both forms per RFC 6266 §4.3: ``filename=`` for the lowest common
denominator and ``filename*=UTF-8''`` for the real name. Clients that
understand the extended form ignore the plain one, so the user gets
``我的声音.ovsvoice`` while nothing anywhere has to encode it as latin-1.
"""
# The fallback is a caller-supplied string that lands in the header
# verbatim whenever the real name folds away entirely, so it gets the same
# treatment as the name itself — otherwise a non-ASCII or quote/CRLF
# fallback walks straight past every guard here (#1262 review).
safe_fallback = _fold(fallback) or "download"
safe = ascii_filename(filename, fallback=safe_fallback)
encoded = quote(_UNSAFE.sub("_", filename or safe_fallback), safe="")
return f"{disposition}; filename=\"{safe}\"; filename*=UTF-8''{encoded}"
+1 -1
View File
@@ -24,7 +24,7 @@ from pathlib import Path
# tests/test_app_version.py::test_all_version_files_in_lockstep and bumped by
# release.yml's version-bump job, so it stays equal to
# pyproject/tauri.conf/Cargo/package.json.
_FALLBACK_VERSION = "0.4.0"
_FALLBACK_VERSION = "0.4.2"
def _fallback_version() -> str:
+136 -10
View File
@@ -383,6 +383,7 @@ from core.config import OUTPUTS_DIR, VOICES_DIR, CRASH_LOG_PATH
from core.tasks import task_manager
from core import job_store
from services.model_manager import (
ModelLoadInterruptedByShutdown,
begin_shutdown as model_loads_begin_shutdown,
idle_worker,
preload_model,
@@ -460,6 +461,19 @@ except Exception:
pass
# #1256: our own ffmpeg/ffprobe call sites pass an explicit path, so a bundled
# sidecar that isn't on PATH works for us — but a dependency that shells out to
# `ffprobe` by bare name dies with FileNotFoundError, mid-synthesis, on a
# machine where the app's own copy was resolvable the whole time. Publish the
# resolved directories once here, after prefs have restored any FFMPEG_PATH
# override and before any engine loads.
try:
from services.ffmpeg_utils import ensure_media_tools_on_path
ensure_media_tools_on_path()
except Exception:
pass # best-effort: find_ffprobe() still resolves it for our own callers
def _env_flag(name: str, default: bool = False) -> bool:
value = os.environ.get(name)
if value is None:
@@ -874,6 +888,25 @@ async def scalar_docs():
)
def _cors_headers_for(request: Request) -> "dict[str, str]":
"""Allowed-origin headers for a hand-built error response.
CORSMiddleware doesn't always get a shot at `exception_handler`-created
responses, which leaves the browser reporting the error as a bare CORS
failure instead of surfacing the real `detail`. Every error response this
module builds must go through here a 503 whose actionable message the
browser discards is no better than the 500 it replaced.
"""
origin = request.headers.get("origin", "")
if origin and (origin in _allowed or "*" in _allowed):
return {
"Access-Control-Allow-Origin": origin,
"Access-Control-Allow-Credentials": "true",
"Vary": "Origin",
}
return {}
@app.exception_handler(Exception)
async def global_exception_handler(request: Request, exc: Exception):
# Client disconnected mid-stream (browser canceled a <video>/range fetch).
@@ -886,6 +919,46 @@ async def global_exception_handler(request: Request, exc: Exception):
) or "Content-Length" in str(exc):
logger.info("Client disconnect during %s (%s)", request.url, exc_name)
return Response(status_code=499)
# The backend is on its way out and a request asked for a model load
# (#1276). #1174 already made this benign for the *background preload*,
# but a user-initiated request fell through to the generic 500 path below
# — crash log, ERROR traceback, journal entry — so quitting the app while
# a generate was queued surfaced "500 Internal Server Error: model load
# skipped: backend shutting down" and offered to file a bug for it.
#
# Nothing failed: the process is exiting. 503 + Retry-After is what a
# shutting-down server owes a client, and it keeps this out of the
# crash/bug-report pipeline entirely.
#
# Matched by isinstance OR class name: `services.model_manager` can be
# imported under two module names (`main`/`backend.main` on different
# sys.path roots, and the frozen build's own layout), which makes two
# distinct class objects and breaks a bare isinstance. The name check is
# the durable half — don't "simplify" it away.
if isinstance(exc, ModelLoadInterruptedByShutdown) or exc_name == (
"ModelLoadInterruptedByShutdown"
):
# `.path`, not the full URL — a query string can carry tokens and
# newlines, and neither belongs in a log line.
logger.info(
"Model load skipped during shutdown for %s — benign.", request.url.path
)
return JSONResponse(
status_code=503,
content={
# The [shutting_down] marker is what the UI keys off to skip the
# "Report" action (the same convention as [clone_ref_unusable]).
# NOT the bare 503 status: 503 is also how a real engine-load
# timeout and an unavailable engine are reported, and those are
# genuinely reportable bugs — suppressing the report button for
# every 503 would silence exactly the class users need to file.
"detail": (
"[shutting_down] OmniVoice is shutting down, so it didn't "
"start loading the model. Reopen the app and try again."
)
},
headers={"Retry-After": "5", **_cors_headers_for(request)},
)
try:
# Serialize writes so concurrent unhandled exceptions don't interleave frames.
with _crash_log_lock, open(CRASH_LOG_PATH, "a", encoding="utf-8", errors="backslashreplace") as f:
@@ -902,15 +975,7 @@ async def global_exception_handler(request: Request, exc: Exception):
_entry = error_journal.record(
exc, route=str(request.url.path), trace=traceback.format_exc()
)
# CORSMiddleware doesn't always get a shot at `exception_handler`-created
# responses, which leaves the browser reporting every 500 as a bare CORS
# error. Attach the headers manually so the real `detail` bubbles up.
origin = request.headers.get("origin", "")
headers: dict[str, str] = {}
if origin and (origin in _allowed or "*" in _allowed):
headers["Access-Control-Allow-Origin"] = origin
headers["Access-Control-Allow-Credentials"] = "true"
headers["Vary"] = "Origin"
headers: dict[str, str] = _cors_headers_for(request)
# #874: a model download that failed because the CONFIGURED Hugging Face
# mirror (HF_ENDPOINT) is unreachable used to leak the raw transformers
# message ("We couldn't connect to 'https://hf-mirror.com' …") as the 500
@@ -1295,6 +1360,12 @@ if __name__ == "__main__":
)
sys.exit(1)
# Distinct exit code for "the port was already taken" (#1223), so the
# desktop shell can tell that apart from a crash without parsing an
# OS-translated error string. Kept out of the 0-2 range the interpreter
# itself uses, and mirrored in frontend/src-tauri/src/backend.rs.
_EXIT_PORT_IN_USE = 78 # EX_CONFIG, sysexits.h
# Port 3900 picked to dodge common 8000 conflicts (Django/Rails/Jupyter).
# Rust sidecar launcher in lib.rs::BACKEND_PORT must stay in sync.
#
@@ -1305,4 +1376,59 @@ if __name__ == "__main__":
# set OMNIVOICE_BIND_HOST=0.0.0.0 explicitly (see deploy/docker-compose.yml)
# — the host-side port mapping is what enforces 127.0.0.1-only there.
_bind_host = os.environ.get("OMNIVOICE_BIND_HOST", "127.0.0.1")
uvicorn.run(app, host=_bind_host, port=_port)
def _port_taken(host: str, port: int) -> "OSError | None":
"""The EADDRINUSE error a bind would raise, or None if the port is free.
Mirrors uvicorn's own socket options — notably SO_REUSEADDR off
Windows so this can't report "taken" for a TIME_WAIT socket uvicorn
would happily bind. Any non-EADDRINUSE failure returns None: this is a
diagnostic, and uvicorn must remain the authority on whether the real
bind succeeds.
"""
import socket
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe:
if sys.platform != "win32":
probe.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
try:
probe.bind((host, port))
except OSError as exc:
in_use = exc.errno in (48, 98, 10048) or getattr(
exc, "winerror", None
) == 10048
return exc if in_use else None
return None
def _fail_port_in_use(exc: "OSError | None") -> None:
print(
f"FATAL: port {_port} is already in use — another OmniVoice "
f"backend (or another app) is listening on it. Quit the other "
f"instance and relaunch; if nothing is visibly running, an "
f"orphaned backend from a previous session is still holding the "
f"port." + (f" Underlying error: {exc}" if exc else ""),
file=sys.stderr,
flush=True,
)
sys.exit(_EXIT_PORT_IN_USE)
# #1223: uvicorn does NOT let a bind failure reach the caller — it logs the
# raw errno and raises SystemExit(1) from inside its startup, so an
# `except OSError` around uvicorn.run() never fires (verified, not assumed).
# And the message it logs is useless to match on: the Windows wording
# ("only one usage of each socket address is normally permitted") is
# OS-translated into the user's locale. So probe the port ourselves first —
# errno is locale-independent (EADDRINUSE = 48 macOS/BSD, 98 Linux, 10048
# Windows) — and exit with a code the shell can recognise.
if (_bind_err := _port_taken(_bind_host, _port)) is not None:
_fail_port_in_use(_bind_err)
try:
uvicorn.run(app, host=_bind_host, port=_port)
except SystemExit:
# Lost the race between the probe above and uvicorn's own bind (a
# competing process grabbed the port in between). Re-probe: if the port
# is taken now, that is what killed us, whatever exit code uvicorn
# chose.
if _port_taken(_bind_host, _port) is not None:
_fail_port_in_use(None)
raise
+18
View File
@@ -114,6 +114,24 @@ def create_mcp_server():
except Exception:
pass
# Extend the MCP SDK's DNS-rebinding allowlist so agents on non-localhost
# hosts (Docker's host.containers.internal, a LAN IP, a reverse proxy) can
# reach the /mcp endpoint. The SDK default is localhost-only.
_mcp_hosts = os.environ.get("OMNIVOICE_MCP_ALLOWED_HOSTS", "")
if _mcp_hosts.strip():
hosts = [h.strip() for h in _mcp_hosts.split(",") if h.strip()]
try:
mcp.settings.transport_security.allowed_hosts.extend(hosts)
# Also extend origins for both http and https (browser-based MCP
# clients behind a proxy send an Origin header — agent clients
# typically don't, but a reverse proxy may use either scheme).
origins = [
f"{scheme}://{h}" for h in hosts for scheme in ("http", "https")
]
mcp.settings.transport_security.allowed_origins.extend(origins)
except Exception as e:
logger.warning("OMNIVOICE_MCP_ALLOWED_HOSTS not applied (%s)", e)
# ── Helpers ─────────────────────────────────────────────────────────
def _api_base() -> str:
+55 -1
View File
@@ -50,6 +50,7 @@ from __future__ import annotations
import io
import logging
import os
import shutil
import tempfile
from typing import Any, BinaryIO, Union
@@ -196,8 +197,61 @@ def _safe_torchaudio_save(
fmt, e,
)
torchaudio.save(path_or_buf, tensor, sample_rate, format=fmt)
except Exception as e:
# #1221: libsndfile reports OS-level write failures as a bare
# "LibsndfileError: System error." — no path, no errno, nothing the
# user can act on, and it fell through generation.py's classifier to
# "an error OmniVoice doesn't recognize". Name the target and what we
# can observe about it (exists / writable / free space) so the message
# points at the actual problem: a full disk, a read-only or
# antivirus-locked output folder, or a removed drive.
raise _describe_write_failure(e, path_or_buf) from e
#: Stable, language-independent marker prefixed onto every enriched audio-write
#: failure. ``core.failure.classify`` matches on THIS rather than on generic
#: wording like "error opening", which also appears when a model, archive or
#: config file fails to open and would hand those failures the audio remedy.
AUDIO_WRITE_FAILED_MARKER = "Writing the audio file failed"
def _describe_write_failure(e: Exception, path_or_buf: PathOrBuf) -> Exception:
"""``e`` re-raised as a RuntimeError that names the write target, or ``e``
itself when there is nothing to add.
The type is deliberately NOT preserved: ``LibsndfileError.__init__`` takes
an integer libsndfile code, so ``type(e)(message)`` builds an exception
whose ``str()`` raises. Every caller of ``_safe_torchaudio_save`` catches
broadly, and the original stays reachable as ``__cause__``.
Best-effort a failure to diagnose must never replace the real error."""
try:
if not isinstance(path_or_buf, (str, os.PathLike)):
return e # in-memory buffer: nothing to inspect
path = os.fspath(path_or_buf)
if getattr(e, "filename", None) or path in str(e):
return e # already self-describing
directory = os.path.dirname(os.path.abspath(path)) or "."
facts = []
if not os.path.isdir(directory):
facts.append("the folder does not exist")
else:
if not os.access(directory, os.W_OK):
facts.append("the folder is not writable")
try:
free_mb = shutil.disk_usage(directory).free / (1024 ** 2)
facts.append(f"{free_mb:,.0f} MB free on its drive")
except OSError:
facts.append("free space could not be read")
return RuntimeError(
f"{AUDIO_WRITE_FAILED_MARKER}: {type(e).__name__}: {e} — target "
f"{path} ({'; '.join(facts)}). An audio write failing at the OS "
f"level is usually a full drive, a read-only or removed folder, or "
f"antivirus/OneDrive locking the file; add an OmniVoice exclusion "
f"if you use one."
)
except Exception:
raise
return e
def _safe_soundfile_write(
+325 -16
View File
@@ -37,6 +37,7 @@ import subprocess
import sys
import threading
import time
from collections import OrderedDict
from typing import AsyncIterator, Optional
import soundfile as sf
@@ -67,7 +68,45 @@ logger = logging.getLogger("omnivoice.dub_pipeline")
# backward compat during the transition.
_dub_jobs: dict[str, dict] = {}
_dub_jobs_lock = threading.Lock()
# Re-entrant: `save_job` takes this lock itself (see below), and the atomic
# helpers call it while already holding it.
_dub_jobs_lock = threading.RLock()
#: Ingests currently running. Used only so "clear history" can also sweep a job
#: that has no row yet — it would appear in no id list otherwise.
_inflight_jobs: set[str] = set()
#: Recently-deleted job ids, most-recent last. Guarded by ``_dub_jobs_lock``.
#:
#: Dict membership alone cannot express "the user withdrew this job" (#1252
#: review): a job's FIRST persistence creates the entry, so an absent key means
#: "not written yet" for a new job and "deleted" for an established one — two
#: opposite instructions from one signal.
#:
#: Scoped to DELETED, not to in-flight ingests. Scoping it to ingests looked
#: right and closed nothing that mattered: a dub is imported once and rendered
#: many times, so the realistic delete lands during a RENDER, long after its
#: ingest ended — and a render's save would then write the row straight back.
#:
#: Retained 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. ``begin_ingest`` drops an id explicitly, since re-importing is a
#: deliberate revival.
#:
#: Expired by AGE, not by count. A count-bounded LRU is evictable by ordinary
#: use: ``DELETE /dub/history`` purges every row with no limit, so a user
#: clearing a large history mid-render would push the rendering job's own
#: marker out and the render would then write it back (#1252 review). Age
#: cannot be gamed that way — what matters is how long ago the delete happened,
#: not how many others followed it.
#:
#: The count cap is a memory backstop only, set far above any real history:
#: ~4096 short ids is a few hundred KB. Reaching it needs 4096 deletions inside
#: one TTL window, at which point the oldest markers are the least likely to
#: still be held.
_WITHDRAWN_TTL_S = 6 * 3600 # outlives any realistic render or transcribe
_WITHDRAWN_MAX = 4096 # memory backstop, not the eviction policy
_withdrawn_jobs: "OrderedDict[str, float]" = OrderedDict()
_DUB_DIR_REAL = os.path.realpath(DUB_DIR)
_HASH_BUF_SIZE = 1 << 18 # 256 KB chunks for hashing
@@ -197,6 +236,162 @@ def put_job(job_id: str, job: dict) -> None:
_dub_jobs[job_id] = job
def merge_job(job_id: str, updates: dict) -> bool:
"""Merge *updates* into an existing in-memory job. Does NOT persist.
Returns ``False`` when the job is gone which is a real, reachable state,
not a defensive nicety: ingest runs for minutes (demucs, scene detection,
thumbnailing) and ``DELETE /dub/history/{id}`` pops the entry out from
under it. The pipeline used to finish with a bare
``_dub_jobs[job_id].update(...)``, so deleting an in-flight dub surfaced as
the toast ``ingest: 'mgw39lx3'`` ``str(KeyError)`` is the repr of the
key, nothing more (#1252/#1253). Callers treat ``False`` as "the user
withdrew this job" and stop, rather than resurrecting a record that was
deliberately deleted.
"""
with _dub_jobs_lock:
job = _dub_jobs.get(job_id)
if job is None:
return False
job.update(updates)
return True
def _expire_withdrawn(now: float, protected: int = 0) -> None:
"""Drop withdrawal markers that are too old to still matter.
Caller must hold ``_dub_jobs_lock``. Age first that is the policy then
a size cap purely so the mapping cannot grow without bound.
``protected`` is how many markers the current purge just recorded. Those sit
at the end (newest) and are never evicted by the size cap: they are the most
likely to still be held by a running job, and dropping one is exactly the
resurrection this whole mechanism exists to prevent. A single "clear
history" larger than the cap would otherwise force us to discard live
markers which is what broke CI. The bound therefore is
``cap + one purge``, not ``cap``.
"""
cutoff = now - _WITHDRAWN_TTL_S
while _withdrawn_jobs:
_, deleted_at = next(iter(_withdrawn_jobs.items()))
if deleted_at >= cutoff:
break
_withdrawn_jobs.popitem(last=False)
floor = max(_WITHDRAWN_MAX, protected)
while len(_withdrawn_jobs) > floor:
_withdrawn_jobs.popitem(last=False)
def begin_ingest(job_id: str) -> None:
"""Mark an ingest as running.
Re-importing an id is a deliberate revival, so this clears any tombstone
the only thing that legitimately un-deletes a job.
"""
with _dub_jobs_lock:
_inflight_jobs.add(job_id)
_withdrawn_jobs.pop(job_id, None)
def end_ingest(job_id: str) -> None:
"""Mark an ingest as finished, however it ended.
Deliberately does NOT clear the tombstone: the ingest ending is not the
user un-deleting anything, and a render started before the delete can still
be holding that job.
"""
with _dub_jobs_lock:
_inflight_jobs.discard(job_id)
def put_and_save_job(
job_id: str,
job: dict,
*,
filename: str = "",
duration: float = 0.0,
content_hash: str = "",
) -> bool:
""":func:`put_job` and :func:`save_job` as ONE atomic step.
Returns ``False`` when the job was withdrawn the user deleted or cleared
its history while this ingest was running in which case nothing is
written. Gating on the tombstone rather than on dict membership is what
makes this correct for a job's FIRST write, where an absent key is normal
(#1252 review).
"""
with _dub_jobs_lock:
if job_id in _withdrawn_jobs:
return False
_dub_jobs[job_id] = job
save_job(job_id, job, filename, duration, content_hash)
return True
def merge_and_save_job(
job_id: str,
updates: dict,
*,
filename: str = "",
duration: float = 0.0,
content_hash: str = "",
) -> bool:
""":func:`merge_job` and :func:`save_job` as ONE atomic step.
Splitting them leaves a window that resurrects deleted work (#1252 review):
merge succeeds, the user deletes the dub removing the row *and* the
in-memory entry and the pending ``save_job`` then UPSERTs the row straight
back, so a dub the user deleted reappears in history. The delete endpoints
take this same lock around their own row-delete + evict, so the two
sequences cannot interleave at all.
Returns ``False`` when the job is already gone; the caller stops there.
The ``save_job`` write happens INSIDE the lock deliberately. That serialises
dub job-state access against one SQLite UPSERT normally microseconds under
WAL, but up to sqlite3's 5 s default busy timeout if another writer is
holding the write lock. The alternative releasing the lock before the
write is the resurrection race this exists to close, so a rare latency
blip is the better trade. No locked region here calls another locked
function, so the plain (non-reentrant) ``_dub_jobs_lock`` cannot deadlock.
"""
with _dub_jobs_lock:
job = _dub_jobs.get(job_id)
if job is None or job_id in _withdrawn_jobs:
return False
job.update(updates)
save_job(job_id, job, filename, duration, content_hash)
return True
def purge_jobs(job_ids, *, delete_rows, include_inflight: bool = False) -> None:
"""Delete history rows and evict the in-memory records as ONE atomic step.
``delete_rows`` is called with the lock held, so a concurrent
:func:`merge_and_save_job` cannot slip between the row-delete and the
evict and write the job straight back (#1252 review). Both delete
endpoints go through here; ``DELETE /dub/history`` previously never evicted
from memory at all, so an in-flight job survived "clear history" entirely
and re-saved itself on completion.
"""
with _dub_jobs_lock:
delete_rows()
# Deterministic order, de-duplicated. A `set` here made which markers
# the size cap evicts depend on PYTHONHASHSEED — the test for this very
# behaviour passed locally and failed in CI for that reason alone.
targets = list(dict.fromkeys(job_ids))
if include_inflight:
# "Clear history" means everything, including a job whose first row
# hasn't been written yet — it wouldn't appear in `job_ids` at all.
targets += [j for j in sorted(_inflight_jobs) if j not in set(targets)]
now = time.monotonic()
for job_id in targets:
_dub_jobs.pop(job_id, None)
_withdrawn_jobs.pop(job_id, None)
_withdrawn_jobs[job_id] = now # most-recent last
_expire_withdrawn(now, protected=len(targets))
def save_job(job_id: str, job: dict, filename: str = "", duration: float = 0.0, content_hash: str = "") -> None:
"""Persist dub job state to SQLite so it survives restarts. Uses UPSERT
on `id` so repeated saves in a session keep the latest snapshot.
@@ -209,6 +404,22 @@ def save_job(job_id: str, job: dict, filename: str = "", duration: float = 0.0,
keys history restore off language_code, so a frozen "" hid finished
tracks until the user re-picked a language.
"""
with _dub_jobs_lock:
# The withdrawal gate lives HERE, not in the callers (#1252 review).
# Eight call sites across generate / translate / export / core persist
# jobs directly, so gating only the ingest helpers left every
# post-ingest save able to resurrect a dub the user deleted mid-render.
# One choke point closes the class and the ninth caller inherits it.
if job_id in _withdrawn_jobs:
logger.info(
"Dub job %s was deleted while it was still running — not persisting", job_id,
)
return
_persist_job(job_id, job, filename, duration, content_hash)
def _persist_job(job_id: str, job: dict, filename: str, duration: float, content_hash: str) -> None:
"""The actual write. Callers go through :func:`save_job`, which gates it."""
try:
segments = job.get("segments") or []
tracks = list((job.get("dubbed_tracks") or {}).keys())
@@ -502,6 +713,38 @@ def _ensure_browser_playable_mp4(video_path: str) -> str:
_YT_DOWNLOAD_RETRIES = 2 # total attempts = 1 + retries = 3
def _with_target_facts(exc: BaseException, job_dir: str) -> BaseException:
"""``exc`` with the download destination described, when the failure looks
like the OS refusing a file operation (#1225).
Returns ``exc`` untouched for network/format failures their message is
already about the remote side, and appending disk facts would just be
noise. Never raises."""
try:
# Shared with failure.classify() so the "is this a disk problem?"
# answer can't differ between the class we assign and whether we
# bother naming the folder (#1225 review).
if not failure.is_os_write_refusal(str(exc)):
return exc
facts = failure.describe_path_target(os.path.join(job_dir, "original.mp4"))
if not facts:
return exc
msg = (
f"{exc} — saving to {job_dir} ({facts}). The OS refused the write, "
f"so retrying the same link won't help: check the drive isn't full, "
f"the folder is writable, and antivirus or a cloud-sync client "
f"(OneDrive, Dropbox) isn't locking it."
)
try:
return type(exc)(msg)
except Exception:
# Not every exception class takes a plain message (soundfile's
# LibsndfileError wants an int code). Keep the text, drop the type.
return RuntimeError(msg)
except Exception:
return exc
def _is_transient_download_error(exc: BaseException) -> bool:
"""True when a download failure is worth retrying (broken pipe / net drop).
@@ -524,10 +767,23 @@ _YT_PLAYER_CLIENTS = ["tv", "android", "web_safari"]
def _is_forbidden_download_error(exc: BaseException) -> bool:
"""True for an HTTP 403 — not transient (the same client keeps 403ing), but
often fixable by switching the YouTube player client."""
"""True for a failure the CURRENT player client can't get past, but another
one commonly can.
A 403 is the original case (#625): extraction worked, the media fetch was
refused, and the same client keeps refusing. "This video is DRM protected"
(#1254) behaves identically and belongs here for the same reason — YouTube
serves a DRM-only format set to *some* player clients for videos that are
not actually DRM'd. The reporter saw it fail and then succeed on a plain
retry of the same URL, which is exactly what a per-client format set looks
like from outside. Escalating the client is the fix; a bare retry only
works when the next attempt happens to draw a different one.
"""
s = str(exc)
return "403" in s or "Forbidden" in s
if "403" in s or "Forbidden" in s:
return True
low = s.lower()
return "drm protected" in low or "drm-protected" in low
def _cleanup_partial_download(job_dir: str) -> None:
@@ -568,6 +824,22 @@ def yt_download_sync(
import glob
import yt_dlp
outtmpl = os.path.join(job_dir, "original.%(ext)s")
# #1225: yt-dlp surfaces an OS write rejection as a bare
# "Unable to download video: [Errno 22] Invalid argument" — no path, no
# reason, and three manual retries all fail identically because nothing
# about it is transient. Fail here instead, naming the directory, when we
# can already see it won't work.
_target_facts = failure.describe_path_target(outtmpl)
if "not writable" in _target_facts or "does not exist" in _target_facts:
# Worded so classify() places it in the download path: it must carry
# both an OS-refusal signature and download context, or the user gets
# no hint at all — the failure this PR exists to fix (#1225 review).
raise OSError(
f"Unable to download video: unable to open for writing in "
f"{job_dir} ({_target_facts}). The video downloads into this job "
f"folder under your OmniVoice data directory — check it exists, is "
f"writable, and isn't locked by antivirus or a cloud-sync client."
)
ydl_opts: dict = {
"outtmpl": outtmpl,
# Prefer h264+aac streams so the merged mp4 is natively decodable
@@ -658,7 +930,15 @@ def yt_download_sync(
)
time.sleep(2 * transient_used) # brief, increasing backoff
continue
raise
# #1225: an OS-level rejection (errno 22 / EACCES / ENOSPC) tells
# the user nothing on its own. Attach what we can observe about
# the destination so the message identifies a full drive, a
# removed folder, or an antivirus/cloud-sync lock. Wording keeps
# the yt-dlp text so classify() still sees the download context.
described = _with_target_facts(exc, job_dir)
if described is exc:
raise
raise described from exc
root, _ = os.path.splitext(path)
mp4 = root + ".mp4"
if os.path.exists(mp4):
@@ -781,6 +1061,9 @@ async def ingest_pipeline(
# Audio-only jobs (#119) skip scene detection + thumbnailing below; the
# transcribe → translate → TTS core is identical.
input_type = (source.get("input_type") or "video").lower()
# Declare the run so a "clear history" arriving before this job's first
# persistence can still withdraw it (#1252 review).
begin_ingest(job_id)
try:
if source.get("kind") == "url":
url = source["url"]
@@ -949,8 +1232,12 @@ async def ingest_pipeline(
"youtube_subs": youtube_subs_by_lang or None,
"input_type": input_type,
}
put_job(job_id, full_job)
save_job(job_id, full_job, filename, dur, content_hash)
if not put_and_save_job(
job_id, full_job, filename=filename, duration=dur, content_hash=content_hash,
):
logger.info("Dub job %s was deleted during ingest — discarding its result", job_id)
yield prep_event("cancelled")
return
yield prep_event("extract_done", job_id=job_id, duration=round(dur, 2), filename=filename)
yield prep_event("cached",
has_bg=bool(no_vocals_path and os.path.exists(no_vocals_path)),
@@ -973,8 +1260,12 @@ async def ingest_pipeline(
"youtube_subs": youtube_subs_by_lang or None,
"input_type": input_type,
}
put_job(job_id, partial)
save_job(job_id, partial, filename, dur, content_hash)
if not put_and_save_job(
job_id, partial, filename=filename, duration=dur, content_hash=content_hash,
):
logger.info("Dub job %s was deleted during ingest — discarding its result", job_id)
yield prep_event("cancelled")
return
yield prep_event("extract_done", job_id=job_id, duration=round(dur, 2), filename=filename)
vocals_path = os.path.join(job_dir, "vocals.wav")
@@ -1066,13 +1357,30 @@ async def ingest_pipeline(
logger.warning("Thumbnail extraction failed for %s: %s", job_id, e)
yield prep_event("warning", **failure.build_failure(e, stage="thumbnail", include_diagnostic=False))
_dub_jobs[job_id].update({
"vocals_path": vocals_path,
"no_vocals_path": no_vocals_path,
"thumb_path": thumb_path if (thumb_path and os.path.exists(thumb_path)) else None,
"scene_cuts": scene_cuts,
})
save_job(job_id, _dub_jobs[job_id], filename, dur, content_hash)
# The job can legitimately be gone by now — everything above takes
# minutes and `DELETE /dub/history/{id}` pops the record. Deleting
# an in-flight dub used to raise KeyError here and surface as the
# toast `ingest: 'mgw39lx3'` (#1252/#1253). A withdrawn job is not
# an error: stop quietly rather than re-persisting what the user
# just deleted.
# Merge and persist as one step: a delete landing BETWEEN them
# would remove the row and then have it written straight back, so
# the dub the user deleted reappears in history (#1252 review).
if not merge_and_save_job(
job_id,
{
"vocals_path": vocals_path,
"no_vocals_path": no_vocals_path,
"thumb_path": thumb_path if (thumb_path and os.path.exists(thumb_path)) else None,
"scene_cuts": scene_cuts,
},
filename=filename,
duration=dur,
content_hash=content_hash,
):
logger.info("Dub job %s was deleted during ingest — discarding its result", job_id)
yield prep_event("cancelled")
return
yield prep_event("ready", job_id=job_id, duration=round(dur, 2), filename=filename)
except asyncio.CancelledError:
@@ -1092,5 +1400,6 @@ async def ingest_pipeline(
yield prep_event("error", **failure.build_failure(e, stage="ingest"))
return
finally:
end_ingest(job_id)
with _active_procs_lock:
_active_procs.pop(job_id, None)
+13 -12
View File
@@ -58,14 +58,16 @@ def _force_compile_requested() -> bool:
def _cuda_arch_supported_for_compile() -> "tuple[bool, str]":
"""Check the GPU's compute capability against this torch build's arch list.
"""Check the GPU's architecture against this torch build's arch list.
New GPU architectures (e.g. Blackwell sm_120, issue #278) routinely break
torch.compile/Triton before upstream support lands: the eager model runs
via PTX forward-compat, but Inductor/Triton kernel compilation targets the
new arch directly and fails mid-generation. If the device's ``sm_XY`` tag
is absent from ``torch.cuda.get_arch_list()`` we treat compile as
unsupported and use eager.
new arch directly and fails mid-generation. If the device's arch tag is
absent from this build's arch list we treat compile as unsupported and use
eager. The comparison is delegated to ``core.device_caps.arch_unsupported``
so it stays CUDA/ROCm-aware a ROCm build lists ``gfx`` names, and the
old ``sm_`` comparison here disabled compile on every AMD host (#1228).
Returns ``(supported, reason)``. Fails open any probe error returns
``(True, "")`` so a weird torch build never silently loses the
@@ -75,22 +77,21 @@ def _cuda_arch_supported_for_compile() -> "tuple[bool, str]":
try:
import torch
from core.device_caps import arch_unsupported
if not torch.cuda.is_available():
return True, ""
major, minor = torch.cuda.get_device_capability(0)
arch_list = list(getattr(torch.cuda, "get_arch_list", lambda: [])() or [])
if not arch_list:
return True, ""
sm_tag = f"sm_{major}{minor}"
if sm_tag in arch_list or f"compute_{major}{minor}" in arch_list:
mismatch = arch_unsupported(torch)
if mismatch is None:
return True, ""
device_arch, arch_list = mismatch
try:
device_name = torch.cuda.get_device_name(0)
except Exception:
device_name = "GPU"
return False, (
f"{device_name} (compute capability {major}.{minor} / {sm_tag}) is not "
f"in this PyTorch build's supported arch list ({', '.join(arch_list)})"
f"{device_name} ({device_arch}) is not in this PyTorch build's "
f"supported arch list ({', '.join(arch_list)})"
)
except Exception:
logger.debug("CUDA arch probe for torch.compile failed; assuming supported", exc_info=True)
+50 -8
View File
@@ -31,19 +31,57 @@ class RoutingResult(TypedDict):
routing_reason: str | None # raw, pre-scrub
def _caveat(caps: HostCaps) -> str | None:
"""A kernel-risk caveat string for an otherwise-accelerated host, or None.
Advisory notes (multi-GPU, VRAM-query-failed, DirectML) never qualify."""
def _caveat(caps: HostCaps, min_vram_gb: float = 0.0) -> str | None:
"""A caveat string for an otherwise-accelerated host, or None.
Two kinds, kernel risk first (it's the more severe):
* a driver/arch mismatch that may fail at kernel launch;
* (#1226/#1222) a GPU that will run, but has less VRAM than the engine
declares it needs. Two users on 4 GB cards ran the ``omnivoice`` engine
and only learned their hardware was under-provisioned AFTER waiting out
the full compute budget and being told the job "was too heavy". Routing
showed a clean green "accelerated" throughout, because family membership
was the only thing checked. Advisory, not blocking the driver can page
to system RAM, and short inputs fit where long ones don't.
Advisory probe notes (multi-GPU, VRAM-query-failed, DirectML) never
qualify. A VRAM figure of 0 means the probe failed; don't guess from it.
"""
for note in caps.notes:
if KERNEL_RISK_MARKER in note:
return f"{caps.family.upper()} selected, but: {note}"
# Dedicated-VRAM families ONLY. On MPS, HostCaps.vram_gb is a heuristic
# (system RAM / 2, see device_caps) for a UNIFIED memory pool — comparing
# it against a floor measured on discrete CUDA hardware would tell every
# 8 GB Mac its 4 GB "VRAM" is too small for an engine that runs fine there.
# Different memory model, different (unmeasured) floor; don't guess.
if (
caps.family in ("cuda", "rocm")
and min_vram_gb > 0
and 0 < caps.vram_gb < min_vram_gb
):
device = caps.device_name or caps.family.upper()
return (
f"{device} has {caps.vram_gb:.1f} GB VRAM; this engine wants about "
f"{min_vram_gb:.0f} GB. It will run, but expect slow generations "
f"that may time out. Unload other models before generating, keep "
f"the text short, or pick a lighter engine."
)
return None
def resolve_routing(gpu_compat: tuple[str, ...], caps: HostCaps) -> RoutingResult:
def resolve_routing(
gpu_compat: tuple[str, ...],
caps: HostCaps,
min_vram_gb: float = 0.0,
) -> RoutingResult:
"""Resolve the effective device + status for an engine on this host.
Rules are evaluated in order; the first match wins (see spec §2)."""
Rules are evaluated in order; the first match wins (see spec §2).
``min_vram_gb`` is the engine's declared VRAM floor (``TTSBackend
.min_vram_gb``); 0 disables the under-provisioned-GPU caveat. Optional so
every existing caller keeps its exact behaviour."""
targets = tuple(gpu_compat or ())
fam = caps.family
@@ -60,7 +98,7 @@ def resolve_routing(gpu_compat: tuple[str, ...], caps: HostCaps) -> RoutingResul
return {
"effective_device": fam,
"routing_status": "accelerated",
"routing_reason": _caveat(caps),
"routing_reason": _caveat(caps, min_vram_gb),
}
# 3. CPU-native engine (declares ONLY cpu) has nothing to fall back FROM,
@@ -143,7 +181,11 @@ def header_safe_reason(reason: str | None) -> str | None:
return cleaned[:256] or None
def routing_fields(gpu_compat: tuple[str, ...], caps: HostCaps) -> dict:
def routing_fields(
gpu_compat: tuple[str, ...],
caps: HostCaps,
min_vram_gb: float = 0.0,
) -> dict:
"""The three serialization-ready routing keys for a ``list_backends`` entry.
Resolves routing and applies the redaction contract: ``routing_reason`` is
@@ -155,7 +197,7 @@ def routing_fields(gpu_compat: tuple[str, ...], caps: HostCaps) -> dict:
"""
from core.scrub import scrub_text
r = resolve_routing(tuple(gpu_compat or ()), caps)
r = resolve_routing(tuple(gpu_compat or ()), caps, min_vram_gb)
reason = r["routing_reason"]
return {
"effective_device": r["effective_device"],
+61
View File
@@ -314,6 +314,67 @@ def find_ffprobe():
return None
def ensure_media_tools_on_path() -> list[str]:
"""Put the resolved ffmpeg/ffprobe on ``PATH`` for third-party code (#1256).
OmniVoice's own call sites always resolve an explicit path, so a bundled
sidecar that was never on ``PATH`` works fine for us. Our dependencies do
not get that courtesy: a library that shells out to ``ffprobe`` by bare
name dies with ``FileNotFoundError: [Errno 2] No such file or directory:
'ffprobe'``. The reporter of #1256 hit that mid-synthesis and was told the
engine had "stopped with an error OmniVoice doesn't recognize", on a Mac
where the app's OWN ffprobe was sitting on disk, resolvable, the whole
time.
Prepending the resolved binaries' directories fixes every such dependency
at once, rather than chasing them one import at a time. Prepended (not
appended) so the copy we validated wins over a broken system one.
Returns the directories added. Idempotent, best-effort, never raises.
"""
added: list[str] = []
try:
directories: list[str] = []
for resolve in (find_ffmpeg, find_ffprobe):
try:
path = resolve()
except Exception:
continue
if not path:
continue
directory = os.path.dirname(os.path.abspath(path))
if directory and directory not in directories:
directories.append(directory)
current = os.environ.get("PATH", "")
entries = current.split(os.pathsep) if current else []
# Case-insensitive comparison on Windows/macOS, where PATH is not
# case-sensitive and "already present" must not depend on casing.
normalize = os.path.normcase
present = {normalize(e) for e in entries if e}
for directory in directories:
if normalize(directory) in present:
continue
entries.insert(0, directory)
present.add(normalize(directory))
added.append(directory)
if added:
os.environ["PATH"] = os.pathsep.join(entries)
# Count, not paths: a user-set FFMPEG_PATH resolves under their home
# directory, and absolute home paths must not reach the log
# (#1256 review). find_ffmpeg/find_ffprobe already log their own
# resolution at debug level when that detail is wanted.
logger.info(
"Published %d media-tool director%s on PATH so dependencies can "
"find ffmpeg/ffprobe (#1256)",
len(added), "y" if len(added) == 1 else "ies",
)
except Exception as e: # diagnosis must never break the thing it helps
logger.debug("ensure_media_tools_on_path failed (non-fatal): %s", e)
return added
async def _spawn_async(cmd, **kwargs):
"""Try asyncio subprocess; fall back to thread-based subprocess on Windows
where ProactorEventLoop may not be available (e.g. under uvicorn --reload)."""
+39
View File
@@ -225,6 +225,45 @@ async def unload(model_id: str) -> dict:
return {"unloaded": "diarization", "success": True}
return {"unloaded": "diarization", "success": False, "reason": "not loaded"}
# The warm dictation ASR (#1247, same defect). It is listed with
# ``"unloadable": True`` and had no branch either — found by the contract
# test written for the engine case, which is the whole reason that test
# enumerates the listing instead of hard-coding ids.
if model_id == "capture-asr":
import services.asr_backend as ab
if getattr(ab, "_capture_backend", None) is None:
return {"unloaded": model_id, "success": False, "reason": "not loaded"}
# idle_s=0 → release now. Still declines while a dictation stream holds
# a lease; yanking the model out from under an open session is exactly
# what the lease exists to prevent.
if ab.release_idle_capture_backend(0.0):
return {"unloaded": model_id, "success": True}
return {"unloaded": model_id, "success": False, "reason": "in use by dictation"}
# In-process engines (#1247). `list_loaded_models` has advertised these as
# `engine:<id>` with `"unloadable": True` since they were made visible in
# the panel — but this dispatcher never grew a branch for them, so pressing
# Unload on any of those rows answered `400 Unknown model id:
# engine:kittentts`. The engines already implement `unload()`; only the
# routing was missing.
if model_id.startswith("engine:"):
engine_id = model_id.split(":", 1)[1]
from api.routers.engines import _ENGINE_INSTANCES
for cls, inst in list(_ENGINE_INSTANCES.items()):
if (getattr(cls, "id", cls.__name__)) != engine_id:
continue
held = any(
getattr(inst, attr, None) is not None
for attr in getattr(inst, "_MODEL_ATTRS", ("_model", "_tts"))
)
if not held:
return {"unloaded": model_id, "success": False, "reason": "not loaded"}
inst.unload() # idempotent by contract; frees device caches itself
return {"unloaded": model_id, "success": True}
return {"unloaded": model_id, "success": False, "reason": "not loaded"}
raise ValueError(f"Unknown model id: {model_id}")
+130 -48
View File
@@ -418,7 +418,8 @@ def _swallow_abandoned(fut) -> None:
async def run_on_gpu_pool_guarded(fn, *, what: str = "GPU job",
timeout: "float | None" = None,
executor=None,
queue_timeout: "float | None" = None):
queue_timeout: "float | None" = None,
min_vram_gb: float = 0.0):
"""Run blocking ``fn`` on the GPU pool, bounding **execution** — not the
wait for a free worker.
@@ -440,6 +441,12 @@ async def run_on_gpu_pool_guarded(fn, *, what: str = "GPU job",
``fn`` must be a zero-arg callable wrap args with ``functools.partial``.
Executors without ``reset`` (a plain ThreadPoolExecutor in tests) still get
both bounds; only the reset step is skipped.
``min_vram_gb`` is the declared VRAM floor of the engine this job belongs
to (``TTSBackend.min_vram_gb``); it only shapes the timeout MESSAGE. Left
at 0 the default, and correct for every non-TTS job on this pool
(reference transcribe, watermarking, dub steps) the under-provisioned-GPU
wording is never used, because nothing measured says it applies (#1226).
"""
loop = asyncio.get_running_loop()
ex = executor if executor is not None else _get_gpu_pool()
@@ -504,7 +511,7 @@ async def run_on_gpu_pool_guarded(fn, *, what: str = "GPU job",
# Phase 2 — execution. The clock starts here: this job owns a worker.
try:
return await asyncio.wait_for(fut, timeout=timeout)
except asyncio.TimeoutError:
except asyncio.TimeoutError as timeout_exc:
# wait_for already cancelled the asyncio wrapper; the worker thread
# keeps going regardless. Consume whatever it eventually produces.
fut.add_done_callback(_swallow_abandoned)
@@ -521,10 +528,12 @@ async def run_on_gpu_pool_guarded(fn, *, what: str = "GPU job",
except Exception:
logger.exception("GPU pool reset after %s timeout failed",
_log_safe(what))
raise GpuJobTimeoutError(_timeout_guidance(what, timeout))
raise GpuJobTimeoutError(
_timeout_guidance(what, timeout, min_vram_gb)
) from timeout_exc
def _timeout_guidance(what: str, timeout: float) -> str:
def _timeout_guidance(what: str, timeout: float, min_vram_gb: float = 0.0) -> str:
"""Device-aware timeout message (#896): a CPU-only host must never be told
to "set the engine to CPU" or blamed on VRAM on CPU the job is simply
compute-bound. GPU hosts keep the VRAM-contention guidance.
@@ -538,9 +547,12 @@ def _timeout_guidance(what: str, timeout: float) -> str:
callers something to do about it.
"""
family = "cuda" # conservative default: GPU wording if the probe fails
device_name, vram_gb = "", 0.0
try:
from core.device_caps import detect_host_caps
family = detect_host_caps().family
_caps = detect_host_caps()
family = _caps.family
device_name, vram_gb = _caps.device_name, _caps.vram_gb
except Exception: # noqa: BLE001 — guidance must never mask the timeout
pass
common = (
@@ -559,6 +571,32 @@ def _timeout_guidance(what: str, timeout: float) -> str:
"expect very long single generations, raise "
"OMNIVOICE_GENERATE_TIMEOUT_S."
)
# #1226/#1222: two users on 4 GB cards were told, generically, that the GPU
# "is VRAM-starved" — true, but it read as a transient contention problem
# they could flush their way out of, when their card was simply too small
# for the engine they had selected. Say so instead — but ONLY when the
# caller passed the engine's measured floor and the host is a dedicated-
# VRAM family below it. This function serves every GPU-pool job (reference
# transcribe, watermarking, dub steps, CPU-only engines on a GPU host), so
# a threshold applied without knowing whose job it is would confidently
# misdiagnose most of them. And on MPS `vram_gb` is a unified-memory
# heuristic (RAM/2), not a dedicated pool to compare against.
if (
min_vram_gb > 0
and family in ("cuda", "rocm")
and 0 < vram_gb < min_vram_gb
):
return common + (
f"{device_name or 'this GPU'} has {vram_gb:.1f} GB of VRAM and "
f"this engine wants about {min_vram_gb:.0f} GB — generations here "
f"are slow enough to hit the limit even with nothing else loaded. "
f"The durable fix is a lighter engine (OmniVoice GGUF and "
f"Supertonic-3 are tuned for small/no GPU) or shorter text; "
f"Flush caches / Unload the resident model (top toolbar or "
f"Settings → Models) frees what little headroom there is. (Raise "
f"OMNIVOICE_GENERATE_TIMEOUT_S if you'd rather let long "
f"generations run.)"
)
return common + (
"most often the GPU is VRAM-starved (a resident model and this job "
"contend for memory). For a durable fix, Flush caches / Unload the "
@@ -612,27 +650,26 @@ _loading_detail: dict = {
"progress": None, # 0-100 percentage (None = indeterminate)
}
# ── ROCm GFX version overrides ───────────────────────────────────────
# AMD GPUs on ROCm report through torch.cuda but may need
# HSA_OVERRIDE_GFX_VERSION for unsupported GFX IDs.
_ROCM_GFX_OVERRIDES = {
# RDNA 3 (RX 7000 series) — override to gfx1100
"gfx1101": "11.0.0", "gfx1102": "11.0.0", "gfx1103": "11.0.0",
# RDNA 2 (RX 6000 series) — override to gfx1030
"gfx1031": "10.3.0", "gfx1032": "10.3.0", "gfx1034": "10.3.0",
# Vega (RX Vega / Radeon VII) — override to gfx900
"gfx902": "9.0.0", "gfx906": "9.0.6",
}
def _configure_rocm_if_needed(torch):
"""Auto-set HSA_OVERRIDE_GFX_VERSION for AMD GPUs on ROCm.
ROCm-enabled PyTorch reports `torch.cuda.is_available() == True` but
some consumer AMD GPUs have GFX IDs not in the official support matrix.
Setting HSA_OVERRIDE_GFX_VERSION lets them run with the closest
some consumer AMD GPUs have GFX IDs the installed build wasn't compiled
for. Setting HSA_OVERRIDE_GFX_VERSION lets them run with the closest
supported architecture.
The override is applied **only when the native gfx is genuinely absent
from this build's arch list**. Newer ROCm wheels support parts that used
to need remapping (gfx1151/Strix Halo is native from ROCm 7.x), and
overriding a natively-supported GPU forces it onto foreign kernels for no
reason so the map is a fallback, not an unconditional rewrite.
"""
from core.device_caps import (
ROCM_GFX_OVERRIDES,
build_arch_list,
hsa_override_for,
)
if os.environ.get("HSA_OVERRIDE_GFX_VERSION"):
return # User already set it manually
try:
@@ -644,41 +681,77 @@ def _configure_rocm_if_needed(torch):
props = torch.cuda.get_device_properties(0)
gcn_arch = getattr(props, "gcnArchName", "") or ""
gfx_id = gcn_arch.split(":")[0].strip().lower()
if gfx_id in _ROCM_GFX_OVERRIDES:
override = _ROCM_GFX_OVERRIDES[gfx_id]
os.environ["HSA_OVERRIDE_GFX_VERSION"] = override
logger.info("ROCm: auto-set HSA_OVERRIDE_GFX_VERSION=%s for %s (%s)",
override, device_name, gfx_id)
target = ROCM_GFX_OVERRIDES.get(gfx_id)
if not target:
return
arch_list = {a.split(":")[0].strip().lower() for a in build_arch_list(torch)}
if not arch_list:
# Metadata unavailable — an UNKNOWN build, not a confirmed
# mismatch. Remapping on a guess could push a natively-supported
# GPU onto foreign kernels, so fail open and change nothing.
logger.debug(
"ROCm: no arch list from this torch build; leaving "
"HSA_OVERRIDE_GFX_VERSION unset for %s (%s)", device_name, gfx_id,
)
return
if gfx_id in arch_list:
logger.info("ROCm: %s (%s) is natively supported by this build; "
"no HSA_OVERRIDE_GFX_VERSION needed", device_name, gfx_id)
return
if target not in arch_list:
# The remap target isn't in this build either — setting the
# override would only change WHICH kernel is missing. Leave it
# unset so check_device_compatibility() reports the real mismatch
# and the CPU fallback engages.
logger.warning(
"ROCm: %s (%s) is unsupported by this build and its remap "
"target %s is missing too — not setting "
"HSA_OVERRIDE_GFX_VERSION.", device_name, gfx_id, target,
)
return
override = hsa_override_for(target)
os.environ["HSA_OVERRIDE_GFX_VERSION"] = override
logger.info("ROCm: auto-set HSA_OVERRIDE_GFX_VERSION=%s (%s) for %s (%s)",
override, target, device_name, gfx_id)
except Exception as e:
logger.debug("ROCm GFX auto-config skipped: %s", e)
def check_device_compatibility():
"""Check if PyTorch supports the current GPU's compute capability.
"""Check if PyTorch supports the current GPU's architecture.
Returns (compatible, warning_message). Compatible is True if OK or
no discrete GPU is present.
no discrete GPU is present. The arch comparison itself lives in
``core.device_caps.arch_unsupported()`` shared with the probe, and
CUDA/ROCm-aware (a ROCm build lists ``gfx``, not ``sm_`` #1228).
"""
from core.device_caps import arch_unsupported
torch = _lazy_torch()
if not torch.cuda.is_available():
return True, None
mismatch = arch_unsupported(torch)
if mismatch is None:
return True, None
device_arch, arch_list = mismatch
try:
major, minor = torch.cuda.get_device_capability(0)
device_name = torch.cuda.get_device_name(0)
sm_tag = f"sm_{major}{minor}"
arch_list = getattr(torch.cuda, "_get_arch_list", lambda: [])()
if arch_list:
compute_tag = f"compute_{major}{minor}"
if sm_tag not in arch_list and compute_tag not in arch_list:
return False, (
f"{device_name} (compute capability {major}.{minor} / {sm_tag}) "
f"is not supported by this PyTorch build. "
f"Supported architectures: {', '.join(arch_list)}. "
f"Try: pip install torch --index-url https://download.pytorch.org/whl/nightly/cu128"
)
except Exception:
pass
return True, None
device_name = "GPU"
if getattr(getattr(torch, "version", None), "hip", None) is not None:
return False, (
f"{device_name} ({device_arch}) is not supported by this ROCm "
f"PyTorch build. Supported architectures: {', '.join(arch_list)}. "
f"Set HSA_OVERRIDE_GFX_VERSION to the closest supported target "
f"(e.g. 11.0.0 for a gfx11xx card) or install a ROCm build that "
f"lists {device_arch}."
)
return False, (
f"{device_name} ({device_arch}) is not supported by this PyTorch build. "
f"Supported architectures: {', '.join(arch_list)}. "
f"Try: pip install torch --index-url "
f"https://download.pytorch.org/whl/nightly/cu128"
)
def get_best_device():
@@ -951,14 +1024,23 @@ def should_preload_tts_asr() -> bool:
def _is_incomplete_cache_error(exc: BaseException) -> bool:
"""True when `exc` is the truncated-HF-cache class (#352 / #581).
"""True when `exc` is the truncated-HF-cache class (#352 / #581 / #1273).
transformers raises an OSError whose message contains "does not appear to
have a file named " when the on-disk snapshot has config/tokenizer files
but no weight shard the signature of an interrupted download. We match on
that phrase (stable across transformers 4.x/5.x) rather than the error type,
since the same OSError type covers unrelated I/O failures."""
return "does not appear to have a file named" in str(exc)
transformers raises an OSError when the on-disk snapshot has config and
tokenizer files but no weight shard the signature of an interrupted
download. We match on the message (stable across transformers 4.x/5.x)
rather than the error type, since the same OSError type covers unrelated
I/O failures.
There are TWO wordings, and this used to match only the first, so a
half-written repo whose *subfolder* failed to load (#1273:
"Error no file named model.safetensors, … found in directory
/snapshots/<rev>/audio_tokenizer") got neither the automatic repair nor
an actionable message just a raw 500. `core.failure` owns the phrase
list so the heal and the error text can't drift apart."""
from core.failure import is_incomplete_cache_message
return is_incomplete_cache_message(str(exc))
def _hf_offline() -> bool:
+121 -22
View File
@@ -106,27 +106,87 @@ def _is_closed_client_error(e) -> bool:
def _retry_once_with_fresh_hf_client(loader, what: str):
"""Run ``loader()`` — a model constructor that may download from the HF
Hub on first use. On the specific closed-client failure above, reset the
hub's shared client and retry exactly ONCE. Any other failure (and a
repeat closed-client failure) propagates untouched, where the generation
error classifier labels it as a network problem (#880)."""
try:
return loader()
except Exception as e:
if not _is_closed_client_error(e):
raise
logger.warning(
"%s: HF Hub httpx client was closed mid-download (%s); "
"retrying once with a fresh client.", what, e,
)
Hub on first use retrying transient download failures.
Two failure shapes are retried, with deliberately different budgets:
* the httpx **closed-client** lifecycle error (#880) — retried exactly
ONCE, after resetting the hub's shared session. It's a client-state bug,
not a network condition: if a fresh session hits it again, repeating
won't help, and #880 chose to surface it rather than loop.
* any **transient download** failure ``core.failure
.is_hf_connectivity_error`` recognises refused/reset connections, DNS,
timeouts, and (#1224) a truncated body ("peer closed connection without
sending complete message body"). A multi-GB model that dies at 90% is
the single most retry-worthy failure in the path, so this gets the full
bounded budget. The HF cache is resumable (correctly-sized blobs are
skipped by hash), so each retry continues rather than restarting.
Anything unrecognised propagates untouched, where the generation error
classifier labels it.
"""
from core.failure import is_hf_connectivity_error
attempts = max(1, _int_env("OMNIVOICE_MODEL_LOAD_RETRIES", 3))
backoff = max(0.0, _float_env("OMNIVOICE_MODEL_LOAD_BACKOFF_S", 2.0))
client_reset_used = False
attempt = 0
while True:
try:
from huggingface_hub.utils import close_session
close_session()
except Exception: # pragma: no cover — hub too old / API renamed
return loader()
except Exception as e:
if _is_closed_client_error(e):
if client_reset_used:
raise # #880: single-shot — a second one is not transient
client_reset_used = True
logger.warning(
"%s: HF Hub httpx client was closed mid-download (%s); "
"retrying once with a fresh client.", what, e,
)
try:
from huggingface_hub.utils import close_session
close_session()
except Exception: # pragma: no cover — hub too old / renamed
logger.warning(
"%s: couldn't reset the HF Hub client; retrying anyway.",
what,
)
# Deliberately does NOT consume a download attempt: the two
# budgets are independent, and letting the session reset eat
# one left a resumable multi-GB download a retry short of its
# configured budget (#1224 review).
continue # immediate — nothing to back off from
attempt += 1
if not is_hf_connectivity_error(str(e)) or attempt >= attempts:
raise
logger.warning(
"%s: couldn't reset the HF Hub client; retrying anyway.", what,
"%s: model download failed (%s); retrying (attempt %d/%d). "
"Already-downloaded files are reused.",
what, e, attempt, attempts,
)
return loader()
if backoff:
import time as _time
_time.sleep(backoff * attempt)
def _int_env(name: str, default: int) -> int:
try:
return int(os.environ.get(name, default))
except (TypeError, ValueError):
return default
def _float_env(name: str, default: float) -> float:
try:
value = float(os.environ.get(name, default))
except (TypeError, ValueError):
return default
# inf/nan parse fine and then poison the caller: `sleep(inf)` raises
# OverflowError, turning a retryable download failure into an unrelated
# crash that hides the original error (#1224 review).
if value != value or value in (float("inf"), float("-inf")):
return default
return value
# ── Protocol ────────────────────────────────────────────────────────────────
@@ -222,6 +282,22 @@ class TTSBackend(ABC):
#: not enforced — actual device selection lives in the engine's loader.
gpu_compat: tuple[str, ...] = ("cpu",)
#: Approximate VRAM (GB) the engine needs to render comfortably on a
#: dedicated GPU. Metadata, like ``gpu_compat`` — never enforced, because a
#: hard refuse would block hosts that would actually cope (drivers page to
#: system RAM, and a short input can fit where a long one won't).
#:
#: What it IS for: telling the user BEFORE they wait (#1226/#1222). 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" — after the fact, with no hint
#: that their card was under-provisioned for the engine they'd picked.
#: Routing showed a clean green "accelerated" the whole time, because
#: family membership was the only thing anything checked.
#:
#: 0 means "no meaningful floor" (CPU-class engines) and never warns.
min_vram_gb: float = 0.0
@abstractmethod
def generate(
self,
@@ -439,6 +515,15 @@ class OmniVoiceBackend(TTSBackend):
id = "omnivoice"
display_name = "OmniVoice (600 languages, zero-shot)"
gpu_compat = ("cuda", "mps", "cpu")
# Derived from the pool's own per-job budget (_GPU_VRAM_PER_JOB_GB = 5.0 in
# model_manager, itself measured from the ~1.6 GB forward + autoregressive
# decode and the co-loaded WhisperX on the clone path), plus room for the
# resident weights. Below this the driver pages to system RAM and a render
# that should take seconds runs for minutes — which is precisely what the
# 4 GB reporters in #1226/#1222 hit. Deliberately the only engine with a
# floor: the rest have no measured figure, and inventing one would put a
# confident number in the UI that nothing backs.
min_vram_gb = 6.0
def __init__(self, model=None):
# The live OmniVoice instance. Reuses the singleton owned by
@@ -749,7 +834,12 @@ class VoxCPM2Backend(TTSBackend):
from voxcpm import VoxCPM # type: ignore[import-not-found]
checkpoint = os.environ.get("OMNIVOICE_VOXCPM_MODEL", "openbmb/VoxCPM2")
logger.info("Loading VoxCPM2 from %s", checkpoint)
self._model = VoxCPM.from_pretrained(checkpoint, load_denoiser=False)
# #1224: this first-use download is multi-GB. Unretried, a truncated
# body at 90% aborted the load outright.
self._model = _retry_once_with_fresh_hf_client(
lambda: VoxCPM.from_pretrained(checkpoint, load_denoiser=False),
"VoxCPM2",
)
def generate(self, text, **kw) -> torch.Tensor:
self._ensure_loaded()
@@ -884,7 +974,10 @@ class MossTTSNanoBackend(TTSBackend):
"OMNIVOICE_MOSS_TTS_MODEL", "OpenMOSS-Team/MOSS-TTS-Nano"
)
logger.info("Loading MOSS-TTS-Nano from %s", checkpoint)
self._model = MossTTSNano.from_pretrained(checkpoint, trust_remote_code=True)
self._model = _retry_once_with_fresh_hf_client(
lambda: MossTTSNano.from_pretrained(checkpoint, trust_remote_code=True),
"MOSS-TTS-Nano",
)
def generate(self, text, **kw) -> torch.Tensor:
self._ensure_loaded()
@@ -2020,7 +2113,10 @@ def list_backends() -> list[dict]:
"isolation_mode": isolation,
"gpu_compat": list(gpu_compat),
# effective_device / routing_status / routing_reason (scrubbed):
**routing_fields(gpu_compat, caps),
"min_vram_gb": getattr(cls, "min_vram_gb", 0.0) or None,
# effective_device / routing_status / routing_reason (scrubbed);
# the reason now also carries the under-provisioned-GPU caveat.
**routing_fields(gpu_compat, caps, getattr(cls, "min_vram_gb", 0.0)),
})
# #981: mlx-audio multiplexes 7+ curated models behind one backend id
# — surface the roster + the currently-active pick so Settings can
@@ -2266,7 +2362,10 @@ async def resolve_generation_backend(
from core.device_caps import detect_host_caps
from services.engine_routing import resolve_routing
routing = resolve_routing(getattr(backend_cls, "gpu_compat", ("cpu",)), detect_host_caps())
routing = resolve_routing(
getattr(backend_cls, "gpu_compat", ("cpu",)), detect_host_caps(),
getattr(backend_cls, "min_vram_gb", 0.0),
)
if routing["routing_status"] == "unavailable":
raise ValueError(routing["routing_reason"])
+109
View File
@@ -331,3 +331,112 @@ def test_lifespan_shutdown_mid_load_is_clean_and_clears_sentinel(
finally:
release.set()
run_sentinel._reset_for_tests()
def test_request_during_shutdown_gets_503_not_a_crash_shaped_500():
"""A user-initiated request that triggers a load while the backend is
shutting down must answer 503, not 500 (#1276).
#1174 made this benign for the background preload, but a request took the
generic unhandled-exception path: crash log, ERROR traceback, and an
error-journal entry that feeds the bug-report pipeline. Quitting the app
with a generate queued therefore surfaced "500 Internal Server Error:
model load skipped: backend shutting down" and offered to file a bug for
a normal teardown.
"""
from fastapi import FastAPI
from fastapi.testclient import TestClient
import main as main_mod
app = FastAPI()
@app.get("/boom")
async def _boom():
raise ModelLoadInterruptedByShutdown("model load skipped: backend shutting down")
app.add_exception_handler(Exception, main_mod.global_exception_handler)
with TestClient(app, raise_server_exceptions=False) as client:
resp = client.get("/boom")
assert resp.status_code == 503, resp.status_code
# Answer a shutting-down server owes a client, so the UI can retry rather
# than treat it as a fault.
assert resp.headers.get("Retry-After") == "5"
detail = resp.json()["detail"]
# Cross-layer contract: the UI keys off this marker to drop the "Report"
# action (utils/errorToast.jsx). It must NOT key off the 503 status alone —
# a real engine-load timeout and an unavailable engine are 503 too, and
# those are reportable bugs. Renaming this marker breaks that; keep in sync.
assert "[shutting_down]" in detail
# Actionable, and free of the internal phrasing that read as a crash.
assert "shutting down" in detail
assert "Reopen the app" in detail
assert "Internal Server Error" not in detail
def test_shutdown_request_is_not_written_to_the_crash_log_or_journal(tmp_path, monkeypatch):
"""The same teardown must leave no crash-log entry and no journal record —
those are what the auto bug reporter reads (#1276)."""
from fastapi import FastAPI
from fastapi.testclient import TestClient
import main as main_mod
from core import error_journal
crash_log = tmp_path / "crash.log"
monkeypatch.setattr(main_mod, "CRASH_LOG_PATH", str(crash_log))
recorded = []
monkeypatch.setattr(
error_journal, "record", lambda *a, **k: recorded.append(a) or {}
)
app = FastAPI()
@app.get("/boom")
async def _boom():
raise ModelLoadInterruptedByShutdown("model load skipped: backend shutting down")
app.add_exception_handler(Exception, main_mod.global_exception_handler)
with TestClient(app, raise_server_exceptions=False) as client:
assert client.get("/boom").status_code == 503
assert not crash_log.exists(), crash_log.read_text()
assert recorded == []
def test_shutdown_class_is_matched_across_duplicate_imports():
"""The handler must recognise the shutdown class even when it arrives from
a second copy of the module.
``services.model_manager`` gets imported under more than one module name
depending on which sys.path root is active (and in the frozen build), so
``ModelLoadInterruptedByShutdown`` can exist as two distinct class objects.
A bare ``isinstance`` silently fails there and the user is back to a 500
which is exactly what happened when both test suites ran in one session.
"""
from fastapi import FastAPI
from fastapi.testclient import TestClient
import main as main_mod
# A same-named class from a *different* module object — what a duplicate
# import produces.
Impostor = type(
"ModelLoadInterruptedByShutdown", (RuntimeError,), {"__module__": "other.copy"}
)
assert not isinstance(Impostor("x"), ModelLoadInterruptedByShutdown)
app = FastAPI()
@app.get("/boom")
async def _boom():
raise Impostor("model load skipped: backend shutting down")
app.add_exception_handler(Exception, main_mod.global_exception_handler)
with TestClient(app, raise_server_exceptions=False) as client:
assert client.get("/boom").status_code == 503
+3 -3
View File
@@ -90,12 +90,12 @@ There's also a Compose file in the repo with `cpu` / `gpu` / `rocm` profiles
|-----|--------------|
| `:latest` | **Rolling preview** — latest commit on `main`, at or ahead of the last release. This is the preview channel; pin `:stable` for production. |
| `:stable` | Most recent versioned release (updated on every `v*` git tag) |
| `:0.3.22` | Exact release version |
| `:0.3` | Latest patch within the `0.3` minor |
| `:0.4.1` | Exact release version |
| `:0.4` | Latest patch within the `0.4` minor |
| `:main` | Alias of the same rolling `main` build as `:latest` |
| `:sha-xxxxxxx` | A specific commit (produced by manual workflow dispatch) |
| `:rocm` | **AMD GPU (ROCm) build** of the rolling preview — the ROCm analogue of `:latest` |
| `:stable-rocm`, `:0.3.22-rocm`, `:0.3-rocm`, `:sha-xxxxxxx-rocm` | ROCm builds of the corresponding tags above |
| `:stable-rocm`, `:0.4.1-rocm`, `:0.4-rocm`, `:sha-xxxxxxx-rocm` | ROCm builds of the corresponding tags above |
Preview builds always come from `main` and never version-sort below `:stable`,
so upgrades flow naturally. The same images and tags
+1 -1
View File
@@ -95,7 +95,7 @@ bug to fix immediately, not backlog.
| Channel | Source | Produced by | How to verify |
|---|---|---|---|
| GitHub Release: installers + signed `latest.json` (**Stable** updater channel) | the `vX.Y.Z` tag | `release.yml` on tag push | Release page has dmg (arm+intel), msi/exe, AppImage/deb, `latest.json`; body = the CHANGELOG section, not the auto-generated fallback |
| GitHub Release: installers + signed `latest.json` (**Stable** updater channel) | the `vX.Y.Z` tag | `release.yml` on tag push | Release page has dmg (arm+intel), msi/exe, AppImage/deb, `latest.json`; body = the CHANGELOG section (not the auto-generated fallback), followed by per-platform checksums and a **Contributors** avatar strip (owner + every PR author for the tag — the `contributors-strip` job) |
| **Preview** updater channel (rolling `preview` prerelease) | **`main` only** | `release.yml` nightly cron / manual dispatch | preview `latest.json` stamps `X.Y.Z-N` and semver-sorts above stable |
| GHCR CUDA image: `:X.Y.Z`, `:X.Y`, `:stable` | the tag | `docker.yml` on tag push | `docker manifest inspect ghcr.io/debpalash/omnivoice-studio:X.Y.Z` |
| GHCR ROCm image: `:X.Y.Z-rocm`, `:X.Y-rocm`, `:stable-rocm` | the tag | `docker.yml` on tag push | same, with `-rocm` suffix |
Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 145 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

+20 -11
View File
@@ -13,12 +13,12 @@ and [`palashdeb/omnivoice-studio` on Docker Hub](https://hub.docker.com/r/palash
> |-----|--------------|
> | `:latest` | **Rolling preview** — latest commit on `main`, at or ahead of the last release. This is the preview channel; pin `:stable` for production. |
> | `:stable` | Most recent versioned release (updated on every `v*` git tag) |
> | `:0.3.22` | Exact release version |
> | `:0.3` | Latest patch within the 0.3 minor |
> | `:0.4.1` | Exact release version |
> | `:0.4` | Latest patch within the 0.4 minor |
> | `:main` | Alias of the same rolling `main` build as `:latest` |
> | `:sha-xxxxxxx` | Specific commit (produced by manual workflow dispatch) |
> | `:rocm` | **AMD GPU (ROCm) build** of the rolling preview — the ROCm analogue of `:latest` |
> | `:stable-rocm`, `:0.3.22-rocm`, `:0.3-rocm`, `:sha-xxxxxxx-rocm` | ROCm builds of the corresponding CUDA tags above |
> | `:stable-rocm`, `:0.4.1-rocm`, `:0.4-rocm`, `:sha-xxxxxxx-rocm` | ROCm builds of the corresponding CUDA tags above |
>
> Versioning rule: preview builds always come from `main` and never
> version-sort below `:stable` — upgrades flow naturally.
@@ -89,15 +89,22 @@ PublishPort=127.0.0.1:3900:3900
Volume=omnivoice-data:/app/omnivoice_data
```
Release pins exist too: `:stable-rocm`, `:0.3.22-rocm`, `:0.3-rocm` mirror
Release pins exist too: `:stable-rocm`, `:0.4.1-rocm`, `:0.4-rocm` mirror
the CUDA tags exactly.
> **RDNA3 consumer cards (RX 7900 XTX/XT, gfx1100):** the backend auto-sets
> `HSA_OVERRIDE_GFX_VERSION` for consumer GFX IDs missing from ROCm's official
> support matrix, so try without any override first. If the GPU still isn't
> detected, force it explicitly with `-e HSA_OVERRIDE_GFX_VERSION=11.0.0`
> **Consumer cards and APUs (RX 6000/7000, Strix Point/Halo):** the backend
> auto-sets `HSA_OVERRIDE_GFX_VERSION` when — and only when — your card's GFX
> ID is missing from the shipped ROCm build's architecture list, so try
> without any override first. Overriding a natively-supported GPU (gfx1151 on
> ROCm 7.x, for example) only forces it onto foreign kernels. If the GPU still
> isn't used, force it explicitly with `-e HSA_OVERRIDE_GFX_VERSION=11.0.0`
> (user-set on the container — it is deliberately **not** baked into the
> image, because the right value depends on your card).
> image, because the right value depends on your card); a value you set is
> always respected as-is.
>
> **Rootless / non-root hosts:** if `/dev/kfd` is group-owned, the container
> user needs those groups too — add `--group-add` for your host's `render` and
> `video` GIDs (`getent group render video`).
Verify the container sees the GPU:
@@ -107,8 +114,10 @@ docker exec omnivoice python3 -c \
```
(ROCm-built PyTorch reports through `torch.cuda.*``True` plus your card's
name means GPU acceleration is active. The Settings → System panel shows the
same device.)
name means torch can see the GPU.) That check alone isn't proof the app is
using it: **Settings → System** shows the device OmniVoice actually resolved.
If it reads `cpu` while the command above prints `True`, the backend log line
starting `Falling back to CPU:` names the architecture mismatch it hit.
## Docker Compose (recommended)
+8
View File
@@ -30,6 +30,14 @@ To bind this agent to a specific voice, send an
`X-OmniVoice-Client-Id` header (e.g. `claude-code`). See
[per-agent voices](#per-agent-voices).
**Agents in Docker or on another machine:** the MCP SDK rejects non-localhost
Host headers by default (DNS-rebinding guard). Set
`OMNIVOICE_MCP_ALLOWED_HOSTS` to a comma-separated list of host patterns the
agent connects from (e.g. `host.containers.internal:*,192.168.1.50:*`).
Keep this on a trusted LAN or behind TLS (Tailscale Serve, a reverse proxy
with HTTPS) — the MCP transport is not authenticated, so don't expose it on
the open internet.
### stdio (clients that only speak stdio)
Use the bundled shim — it proxies stdio ↔ the mounted HTTP endpoint. Drop
+1 -1
View File
@@ -170,5 +170,5 @@ Setup questions get answered in
[Discord](https://discord.gg/bzQavDfVV9) (usually within hours), bugs
go to
[GitHub Issues](https://github.com/debpalash/OmniVoice-Studio/issues)
— see [SUPPORT.md](../../SUPPORT.md) for what to include. Welcome
— see [SUPPORT.md](../../.github/SUPPORT.md) for what to include. Welcome
over.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "omnivoice-studio",
"version": "0.4.0",
"version": "0.4.2",
"private": true,
"license": "AGPL-3.0-only",
"type": "module",
+1 -1
View File
@@ -2941,7 +2941,7 @@ dependencies = [
[[package]]
name = "omnivoice-studio"
version = "0.4.0"
version = "0.4.2"
dependencies = [
"arboard",
"dirs-next",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "omnivoice-studio"
version = "0.4.0"
version = "0.4.2"
description = "OmniVoice Studio AI voice cloning & dubbing desktop app"
authors = ["Debpalash"]
license = "AGPL-3.0-only"
+33
View File
@@ -149,6 +149,39 @@ fn raw_http_get(url: &str, timeout: Duration) -> Result<String, String> {
Ok(buf)
}
/// Exit code `backend/main.py` uses when it could not bind the port (#1223).
/// Keep in sync with `_EXIT_PORT_IN_USE` there.
pub const EXIT_PORT_IN_USE: i32 = 78;
/// Kill whoever holds `port`, then confirm it actually came free.
///
/// #1223: every caller used to kill-then-sleep-then-spawn unconditionally, so
/// a holder we cannot kill — a different user's process, a `taskkill` blocked
/// by policy, a socket sitting in TIME_WAIT that the Windows `netstat`
/// LISTENING filter can't even see — was indistinguishable from success. The
/// backend then died on the bind with a raw errno and the user got "Backend
/// died (exit code 1)".
///
/// Returns true when the port is free afterwards. Polls rather than sleeping a
/// flat interval: the common case (our own orphan) frees in well under 500ms,
/// and the uncommon case deserves longer than one guess.
pub fn free_port_or_report(port: u16) -> bool {
kill_orphan_on_port(port);
for _ in 0..20 {
if !port_in_use(port) {
return true;
}
std::thread::sleep(Duration::from_millis(100));
}
log::error!(
"Port {} is still held after attempting to kill its owner — the \
backend cannot bind it. Another application (or a process owned by a \
different user) is using the port.",
port
);
false
}
/// Kill whatever process owns the port.
#[cfg(unix)]
pub fn kill_orphan_on_port(port: u16) {
+61 -7
View File
@@ -260,8 +260,24 @@ pub fn respawn_backend(
if crate::backend::port_in_use(backend_port()) {
log::warn!("Port {} in use — taking ownership", backend_port());
set_backend_kill_intended(true); // deliberate kill, not a crash (#941)
crate::backend::kill_orphan_on_port(backend_port());
std::thread::sleep(Duration::from_millis(500));
// #1223: verify the port actually came free. Spawning into a port
// we failed to reclaim just moves the failure into the backend,
// where it surfaced as an unexplained "exit code 1".
if !crate::backend::free_port_or_report(backend_port()) {
set_stage(
&stage_handle,
BootstrapStage::Failed {
message: format!(
"Port {} is already in use by another application, \
and OmniVoice could not free it. Quit whatever is \
using that port (another copy of OmniVoice, or an \
app that claimed it) and try again.",
backend_port()
),
},
);
return;
}
}
spawn_backend_and_wait(&app, &stage_handle);
});
@@ -399,7 +415,24 @@ pub fn spawn_backend_and_wait(app: &tauri::AppHandle, stage_handle: &Arc<Mutex<B
);
return;
}
let msg = if err_tail.is_empty() {
// #1223: the backend exits EXIT_PORT_IN_USE when it could not
// bind its port. That is a conflict, not a crash — say what to
// do instead of dumping a traceback whose one meaningful line
// is an OS-translated errno.
let msg = if real_exit
.as_ref()
.and_then(|e| e.code)
.is_some_and(|c| c == crate::backend::EXIT_PORT_IN_USE)
{
format!(
"Port {} is already in use, so the backend could not \
start. Another copy of OmniVoice or an app that \
claimed that port is holding it. Quit it and try \
again; if nothing is visibly running, an orphaned \
backend from a previous session still has the port.",
backend_port()
)
} else if err_tail.is_empty() {
format!("Backend process exited ({}) — no error output captured", exit_info)
} else {
format!("Backend process exited ({}):\n{}", exit_info, err_tail)
@@ -578,10 +611,31 @@ fn supervise_backend(app: &tauri::AppHandle, stage_handle: &Arc<Mutex<BootstrapS
// poll has already stopped post-Ready, so the stage alone won't show).
let _ = app.emit("backend-restarting", exit_info.clone());
set_stage(stage_handle, BootstrapStage::StartingBackend);
// Clear any orphan still holding the port before the respawn.
if crate::backend::port_in_use(backend_port()) {
crate::backend::kill_orphan_on_port(backend_port());
std::thread::sleep(Duration::from_millis(300));
// Clear any orphan still holding the port before the respawn. #1223:
// if it can't be cleared, respawning just reproduces the bind failure
// — stop and say so rather than burning a restart attempt.
if crate::backend::port_in_use(backend_port())
&& !crate::backend::free_port_or_report(backend_port())
{
set_stage(
stage_handle,
BootstrapStage::Failed {
// Wording note: every one of these must contain a phrase
// `BootstrapSplash.detectHints` matches ("port … in use"),
// because that is what turns an English Rust message into
// the LOCALISED `bootstrap.hint_port` the user actually
// reads. Pinned in frontend/src/test/portInUseHint.test.js
// — an earlier draft of this one said "is held by" and
// silently lost the translated guidance.
message: format!(
"Port {} is still in use by another application and \
OmniVoice could not free it, so the backend can't \
restart. Quit whatever is using that port and relaunch.",
backend_port()
),
},
);
return;
}
let child = crate::backend::spawn_backend(app, Some(stage_handle));
track_backend_child(app, child);
-6
View File
@@ -1165,9 +1165,6 @@ function App() {
return (
<div style={{ zoom: uiScale }}>
<BootstrapSplash stage={bootstrapStage} message={bootstrapMessage} />
<Suspense fallback={null}>
<LogsFooter />
</Suspense>
</div>
);
}
@@ -1204,9 +1201,6 @@ function App() {
}}
/>
</Suspense>
<Suspense fallback={null}>
<LogsFooter />
</Suspense>
</div>
);
}
+19 -2
View File
@@ -1,11 +1,28 @@
import { API, apiUrl, apiFetch, apiJson } from './client';
import { useAppStore } from '../store';
export async function generateSpeech(
formData: FormData,
{ signal }: { signal?: AbortSignal } = {},
): Promise<Response> {
// Returns the full Response so callers can stream the WAV blob + read headers.
return apiFetch('/generate', { method: 'POST', body: formData, signal });
// Count this synth as in flight for as long as it runs. Installing an update
// relaunches the process, so the updater has to know a synth is running
// (utils/appBusy) — and the guard belongs HERE, at the one call every synth
// path shares: the Generate tab, voice previews, the compare modal, the
// stories editor and profile previews all reach /generate through this
// function. Tracking it in any single caller would leave the others able to
// be silently discarded, and a new caller would have to remember to opt in.
//
// A count rather than a flag because these overlap: a boolean would be
// cleared by whichever request settled first while the rest were still
// running. `finally` so an abort or a network error releases it too.
useAppStore.getState().addTtsInflight?.(1);
try {
// Returns the full Response so callers can stream the WAV blob + read headers.
return await apiFetch('/generate', { method: 'POST', body: formData, signal });
} finally {
useAppStore.getState().addTtsInflight?.(-1);
}
}
export async function listHistory(): Promise<unknown> {
+5
View File
@@ -52,6 +52,11 @@ interface EngineBackend {
last_error?: string | null;
isolation_mode?: 'in-process' | 'subprocess';
gpu_compat?: GPUTarget[];
// Approximate VRAM (GB) the engine wants on a dedicated GPU; null when the
// engine declares no meaningful floor (#1226). Advisory metadata — when the
// host has less, `routing_reason` carries the caveat and the matrix renders
// it under an otherwise-accelerated row.
min_vram_gb?: number | null;
// Routing (#21) — the device this engine uses on this machine + why.
effective_device?: EffectiveDevice;
routing_status?: RoutingStatus;
+12 -1
View File
@@ -141,7 +141,18 @@ export function detectHints(message, logs = []) {
if (/uv sync failed/i.test(all)) hints.push('bootstrap.hint_uv_sync');
if (/hatchling|build_editable/i.test(all)) hints.push('bootstrap.hint_build_backend');
if (/ffmpeg/i.test(all) && /download|timeout/i.test(all)) hints.push('bootstrap.hint_ffmpeg');
if (/port.*in use|address.*in use/i.test(all)) hints.push('bootstrap.hint_port');
// #1223: Windows' WSAEADDRINUSE text is "only one usage of each socket
// address is normally permitted" it contains neither "port ... in use" nor
// "address ... in use", and the OS translates it into the user's locale
// (the report that surfaced this was in Russian). Match the locale-
// independent errnos too: 10048 (Windows), 48 (macOS/BSD), 98 (Linux), and
// the backend's own EX_CONFIG exit code for this case.
if (
/port.*in use|address.*in use|errno 10048|errno 48|errno 98|only one usage of each socket|exit code 78/i.test(
all,
)
)
hints.push('bootstrap.hint_port');
if (/no error output/i.test(all)) hints.push('bootstrap.hint_silent_crash');
if (/seems stuck at|never reported ready/i.test(all)) hints.push('bootstrap.hint_stuck');
if (/blocking GitHub|couldn't download Python|python-build-standalone|dns error/i.test(all))
+101
View File
@@ -0,0 +1,101 @@
/**
* "An update is available" as a toast with actions, not a wall of text.
*
* The release notes for a version are the whole changelog section, which for
* v0.4.1 was 42 bullets. Anything that renders them inline becomes unusable:
* older builds put them in a blocking OS dialog that filled the screen and had
* to be dismissed before the app could be touched.
*
* The opposite failure is just as real this app's only remaining signal was a
* 6-pixel dot beside the version number in the footer, which nobody notices.
*
* So: a toast that states the version, offers the two actions that matter, and
* leaves. The notes stay one click away in Settings Updates, where there is
* room for them.
*/
import React from 'react';
import { useTranslation } from 'react-i18next';
import toast from 'react-hot-toast';
import { useAppStore } from '../store';
import { installUpdate } from '../utils/updater';
import { isAppBusy } from '../utils/appBusy';
/** Toast id — one per version, so a re-check can't stack duplicates. */
const toastId = (version) => `update-available-${version}`;
function UpdateToastBody({ id, version }) {
const { t } = useTranslation();
const openNotes = () => {
useAppStore.getState().openSettingsTab?.('updates');
toast.dismiss(id);
};
const install = () => {
// Installing relaunches the process, so anything in flight is lost not
// just a dub synth, but an upload, a transcription, a translation, an
// export or a standalone TTS run. `isAppBusy` is the shared answer;
// UpdatesPanel asks it too, because that path is reachable without this
// toast and the two checks must not drift.
if (isAppBusy(useAppStore.getState())) {
toast(t('update.busy'), { icon: '⏳' });
return;
}
toast.dismiss(id);
installUpdate(useAppStore.getState());
};
return (
<div className="flex flex-col gap-2" data-testid="update-toast">
<div className="leading-snug">
{t('update.toast_available', {
version,
defaultValue: 'OmniVoice Studio {{version}} is available',
})}
</div>
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
onClick={install}
className="rounded px-2 py-[3px] text-[0.72rem] font-semibold [background:var(--chrome-accent)] text-black hover:opacity-90"
>
{t('update.toast_install', { defaultValue: 'Install and restart' })}
</button>
<button
type="button"
onClick={openNotes}
className="rounded px-2 py-[3px] text-[0.72rem] underline opacity-80 hover:opacity-100"
>
{t('update.toast_whats_new', { defaultValue: "What's new" })}
</button>
<button
type="button"
onClick={() => toast.dismiss(id)}
className="ml-auto rounded px-2 py-[3px] text-[0.72rem] opacity-60 hover:opacity-100"
>
{t('update.toast_later', { defaultValue: 'Later' })}
</button>
</div>
</div>
);
}
/**
* Announce *version* once. Idempotent per version: react-hot-toast replaces a
* toast with the same id rather than stacking, so the 6-hourly re-check can't
* pile up copies of the same announcement.
*
* Deliberately never auto-dismisses an update the user hasn't answered is
* still true. It does not block anything, and "Later" removes it.
*/
export function showUpdateToast(version) {
if (!version) return;
const id = toastId(version);
toast.custom((tst) => <UpdateToastBody id={tst.id} version={version} />, {
id,
duration: Infinity,
position: 'bottom-right',
});
}
export default UpdateToastBody;
+28 -4
View File
@@ -21,6 +21,7 @@ import { prepareReleases } from '../utils/updatePresentation';
import { setChannel } from '../utils/channelControl';
import { fetchChangelog, fetchBackupState } from '../utils/updatesApi';
import { APP_VERSION } from '../utils/appVersion';
import { isAppBusy } from '../utils/appBusy';
import MarkdownLite from './MarkdownLite';
import ChangelogViewer from './ChangelogViewer';
@@ -37,7 +38,11 @@ export default function UpdatesPanel() {
const releasesStatus = useAppStore((s) => s.releasesStatus);
const loadReleases = useAppStore((s) => s.loadReleases);
const dismissUpdate = useAppStore((s) => s.dismissUpdate);
// Subscribed, not read via getState() `busy` disables the install button,
// so it has to re-render when the work starts or finishes.
const dubStep = useAppStore((s) => s.dubStep);
const pillStage = useAppStore((s) => s.stage);
const ttsInflight = useAppStore((s) => s.ttsInflight);
const [changelog, setChangelog] = useState([]);
const [backup, setBackup] = useState(null);
@@ -66,9 +71,18 @@ export default function UpdatesPanel() {
if (v) useAppStore.getState().setWhatsNewSeenVersion?.(v);
}, [appVersion]);
const busy = dubStep === 'generating';
// Installing relaunches the process. `dubStep === 'generating'` used to be
// the whole check, which let a relaunch through during an upload, a
// transcription, a translation, an export or a standalone synth.
//
// Subscribed (not getState()) so this re-renders when work starts or stops:
// it greys the button out and says why, rather than letting the user click
// and get a toast back.
const busy = isAppBusy({ dubStep, stage: pillStage, ttsInflight });
const onInstall = () => {
if (busy) {
// The click-time read is the authority work can start between the last
// render and the click, so `busy` above cannot be the safety check.
if (isAppBusy(useAppStore.getState())) {
toast(t('update.busy'), { icon: '⏳' });
return;
}
@@ -81,7 +95,12 @@ export default function UpdatesPanel() {
<div className="updates-panel">
<div className="updates-panel__live">
{status === 'available' && (
<button className="updates-panel__cta" onClick={onInstall}>
<button
className="updates-panel__cta"
onClick={onInstall}
disabled={busy}
title={busy ? t('update.busy') : undefined}
>
<Download size={13} /> {t('update.available', { version: version || '' })} ·{' '}
{t('update.install')}
</button>
@@ -95,7 +114,12 @@ export default function UpdatesPanel() {
</span>
)}
{status === 'ready' && (
<button className="updates-panel__cta" onClick={onInstall}>
<button
className="updates-panel__cta"
onClick={onInstall}
disabled={busy}
title={busy ? t('update.busy') : undefined}
>
<RotateCw size={13} /> {t('update.restart')}
</button>
)}
+9 -4
View File
@@ -1438,7 +1438,8 @@
"report": "الإبلاغ عن هذا الخطأ",
"report_failed": "تعذّر فتح تقرير الخطأ. حاول مرة أخرى، أو انسخ التفاصيل أعلاه إلى مشكلة جديدة.",
"searchIssues": "البحث عن مشكلات مشابهة",
"unexpected": "خطأ غير متوقع: {{message}}"
"unexpected": "خطأ غير متوقع: {{message}}",
"backend_shutting_down": "يجري إغلاق OmniVoice. أعد فتح التطبيق وحاول مرة أخرى."
},
"keyboard": {
"title": "اختصارات لوحة المفاتيح",
@@ -1891,11 +1892,15 @@
"install_hint": "نزّل التحديث وأعد التشغيل إلى الإصدار الجديد",
"downloading": "جارٍ التحديث… {{pct}}%",
"restart": "أعد التشغيل للتحديث",
"busy": "أكمل الدبلجة أولاً — ثم ثبّت التحديث.",
"busy": "انتظر حتى ينتهي العمل الجاري — ثم ثبّت التحديث.",
"whats_new": "ما هو الجديد",
"failed": "فشل التحديث",
"retry": "أعد المحاولة",
"dismiss": "استبعاد"
"dismiss": "استبعاد",
"toast_available": "الإصدار {{version}} من OmniVoice Studio متاح",
"toast_install": "تثبيت وإعادة التشغيل",
"toast_whats_new": "ما الجديد",
"toast_later": "لاحقًا"
},
"archetypes": {
"featured": "مميز",
@@ -2279,4 +2284,4 @@
"output_title": "ما أبلغ عنه التطبيق",
"retry_hint": "جرّب \"إعادة المحاولة\" من الإعدادات → السجلات → الواجهة الخلفية. وإذا فشلت مجددًا، فإن \"تنظيف وإعادة المحاولة\" يعيد بناء البيئة من الصفر."
}
}
}
+12 -7
View File
@@ -308,8 +308,8 @@
"frontend": "Frontend",
"tauri": "Tauri",
"cancelOp": "Vorgang abbrechen",
"dismiss": "Entlassen",
"dismissStatus": "Status entlassen",
"dismiss": "Schließen",
"dismissStatus": "Status schließen",
"search": "Suchen…",
"no_matches": "Keine Übereinstimmungen",
"recent_and_popular": "Neu und beliebt",
@@ -1276,7 +1276,7 @@
"later": "Vielleicht später",
"star": "Stern auf GitHub",
"opt_out": "Fragen Sie nicht noch einmal",
"dismiss_aria": "Entlassen"
"dismiss_aria": "Schließen"
}
},
"enterprise": {
@@ -1438,7 +1438,8 @@
"report": "Diesen Fehler melden",
"report_failed": "Der Fehlerbericht konnte nicht geöffnet werden. Versuche es erneut oder kopiere die obigen Details in ein neues Issue.",
"searchIssues": "Ähnliche Probleme suchen",
"unexpected": "Unerwarteter Fehler: {{message}}"
"unexpected": "Unerwarteter Fehler: {{message}}",
"backend_shutting_down": "OmniVoice wird beendet. Öffnen Sie die App erneut und versuchen Sie es noch einmal."
},
"keyboard": {
"title": "Tastaturkürzel",
@@ -1891,11 +1892,15 @@
"install_hint": "Update herunterladen und in die neue Version neu starten",
"downloading": "Wird aktualisiert… {{pct}} %",
"restart": "Zum Aktualisieren neu starten",
"busy": "Beende zuerst deine Synchronisation dann installiere das Update.",
"busy": "Warten Sie, bis die laufende Arbeit fertig ist dann installieren Sie das Update.",
"whats_new": "Was ist neu?",
"failed": "Update fehlgeschlagen",
"retry": "Versuchen Sie es noch einmal",
"dismiss": "Entlassen"
"dismiss": "Schließen",
"toast_available": "OmniVoice Studio {{version}} ist verfügbar",
"toast_install": "Installieren und neu starten",
"toast_whats_new": "Neuerungen",
"toast_later": "Später"
},
"archetypes": {
"featured": "Hervorgehoben",
@@ -2279,4 +2284,4 @@
"output_title": "Meldung der App",
"retry_hint": "Versuche \"Erneut versuchen\" unter Einstellungen → Protokolle → Backend. Schlägt es erneut fehl, baut \"Bereinigen & erneut versuchen\" die Umgebung neu auf."
}
}
}
+8 -3
View File
@@ -43,11 +43,15 @@
"install_hint": "Download the update and restart into the new version",
"downloading": "Updating… {{pct}}%",
"restart": "Restart to update",
"busy": "Finish your dub first — then install the update.",
"busy": "Wait for the work in progress to finish — then install the update.",
"whats_new": "What's new",
"failed": "Update failed",
"retry": "Retry",
"dismiss": "Dismiss"
"dismiss": "Dismiss",
"toast_available": "OmniVoice Studio {{version}} is available",
"toast_install": "Install and restart",
"toast_whats_new": "What's new",
"toast_later": "Later"
},
"updates": {
"tab": "Updates",
@@ -1911,7 +1915,8 @@
"report": "Report this bug",
"report_failed": "Couldn't open the bug report. Please try again, or copy the details above into a new issue.",
"searchIssues": "Search similar issues",
"unexpected": "Unexpected error: {{message}}"
"unexpected": "Unexpected error: {{message}}",
"backend_shutting_down": "OmniVoice is shutting down. Reopen the app and try again."
},
"crash": {
"notice": "The voice backend crashed ({{exit}}) {{ago}} ago and is being restarted automatically.",
+9 -4
View File
@@ -1438,7 +1438,8 @@
"report": "Informar de este error",
"report_failed": "No se pudo abrir el informe de error. Inténtalo de nuevo o copia los detalles anteriores en una incidencia nueva.",
"searchIssues": "Buscar problemas similares",
"unexpected": "Error inesperado: {{message}}"
"unexpected": "Error inesperado: {{message}}",
"backend_shutting_down": "OmniVoice se está cerrando. Vuelve a abrir la aplicación e inténtalo de nuevo."
},
"keyboard": {
"title": "Atajos de teclado",
@@ -1891,11 +1892,15 @@
"install_hint": "Descarga la actualización y reinicia en la nueva versión",
"downloading": "Actualizando… {{pct}}%",
"restart": "Reiniciar para actualizar",
"busy": "Termina tu doblaje primero — luego instala la actualización.",
"busy": "Espera a que termine el trabajo en curso — luego instala la actualización.",
"whats_new": "¿Qué hay de nuevo?",
"failed": "La actualización falló",
"retry": "Reintentar",
"dismiss": "Descartar"
"dismiss": "Descartar",
"toast_available": "OmniVoice Studio {{version}} está disponible",
"toast_install": "Instalar y reiniciar",
"toast_whats_new": "Novedades",
"toast_later": "Más tarde"
},
"archetypes": {
"featured": "Destacado",
@@ -2279,4 +2284,4 @@
"output_title": "Lo que informó la aplicación",
"retry_hint": "Prueba \"Reintentar\" en Ajustes → Registros → Backend. Si vuelve a fallar, \"Limpiar y reintentar\" reconstruye el entorno desde cero."
}
}
}
+10 -5
View File
@@ -309,7 +309,7 @@
"tauri": "Taureau",
"cancelOp": "Annuler l'opération",
"dismiss": "Rejeter",
"dismissStatus": "Statut de rejet",
"dismissStatus": "Ignorer le statut",
"search": "Rechercher…",
"no_matches": "Aucune correspondance",
"recent_and_popular": "Récents et populaires",
@@ -1438,7 +1438,8 @@
"report": "Signaler ce bogue",
"report_failed": "Impossible d'ouvrir le rapport de bug. Réessayez, ou copiez les détails ci-dessus dans un nouveau ticket.",
"searchIssues": "Rechercher des problèmes similaires",
"unexpected": "Erreur inattendue : {{message}}"
"unexpected": "Erreur inattendue : {{message}}",
"backend_shutting_down": "OmniVoice est en cours de fermeture. Rouvrez lapplication et réessayez."
},
"keyboard": {
"title": "Raccourcis clavier",
@@ -1891,11 +1892,15 @@
"install_hint": "Télécharger la mise à jour et redémarrer dans la nouvelle version",
"downloading": "Mise à jour… {{pct}} %",
"restart": "Redémarrer pour mettre à jour",
"busy": "Terminez d'abord votre doublage, puis installez la mise à jour.",
"busy": "Attendez la fin du travail en cours, puis installez la mise à jour.",
"whats_new": "Quoi de neuf",
"failed": "La mise à jour a échoué",
"retry": "Réessayer",
"dismiss": "Rejeter"
"dismiss": "Rejeter",
"toast_available": "OmniVoice Studio {{version}} est disponible",
"toast_install": "Installer et redémarrer",
"toast_whats_new": "Nouveautés",
"toast_later": "Plus tard"
},
"archetypes": {
"featured": "En vedette",
@@ -2279,4 +2284,4 @@
"output_title": "Ce que l'application a signalé",
"retry_hint": "Essayez « Réessayer » dans Paramètres → Journaux → Backend. En cas de nouvel échec, « Nettoyer et réessayer » reconstruit l'environnement de zéro."
}
}
}
+9 -4
View File
@@ -1438,7 +1438,8 @@
"report": "इस बग की रिपोर्ट करें",
"report_failed": "बग रिपोर्ट नहीं खुल सकी। कृपया फिर से कोशिश करें, या ऊपर दिए विवरण को नए इशू में कॉपी करें।",
"searchIssues": "मिलते-जुलते मुद्दे खोजें",
"unexpected": "अप्रत्याशित त्रुटि: {{message}}"
"unexpected": "अप्रत्याशित त्रुटि: {{message}}",
"backend_shutting_down": "OmniVoice बंद हो रहा है। ऐप दोबारा खोलें और फिर कोशिश करें।"
},
"keyboard": {
"title": "कीबोर्ड शॉर्टकट",
@@ -1891,11 +1892,15 @@
"install_hint": "अपडेट डाउनलोड करें और नए संस्करण में पुनः आरंभ करें",
"downloading": "अपडेट हो रहा है… {{pct}}%",
"restart": "अपडेट करने के लिए पुनः आरंभ करें",
"busy": "पहले अपनी डबिंग पूरी करें — फिर अपडेट इंस्टॉल करें।",
"busy": "चल रहे काम के पूरा होने का इंतज़ार करें — फिर अपडेट इंस्टॉल करें।",
"whats_new": "नया क्या है",
"failed": "अद्यतन विफल रहा",
"retry": "पुनः प्रयास करें",
"dismiss": "ख़ारिज करें"
"dismiss": "ख़ारिज करें",
"toast_available": "OmniVoice Studio {{version}} उपलब्ध है",
"toast_install": "इंस्टॉल करें और पुनः आरंभ करें",
"toast_whats_new": "नया क्या है",
"toast_later": "बाद में"
},
"archetypes": {
"featured": "विशेष रुप से प्रदर्शित",
@@ -2279,4 +2284,4 @@
"output_title": "ऐप ने क्या बताया",
"retry_hint": "सेटिंग्स → लॉग → बैकएंड में \"पुनः प्रयास\" आज़माएँ। फिर भी विफल हो तो \"साफ़ करें और पुनः प्रयास\" वातावरण को नए सिरे से बनाता है।"
}
}
}
+9 -4
View File
@@ -1438,7 +1438,8 @@
"report": "Laporkan bug ini",
"report_failed": "Tidak dapat membuka laporan bug. Coba lagi, atau salin detail di atas ke isu baru.",
"searchIssues": "Cari masalah serupa",
"unexpected": "Kesalahan tak terduga: {{message}}"
"unexpected": "Kesalahan tak terduga: {{message}}",
"backend_shutting_down": "OmniVoice sedang ditutup. Buka kembali aplikasinya lalu coba lagi."
},
"keyboard": {
"title": "Pintasan keyboard",
@@ -1891,11 +1892,15 @@
"install_hint": "Unduh pembaruan dan mulai ulang ke versi baru",
"downloading": "Memperbarui… {{pct}}%",
"restart": "Mulai ulang untuk memperbarui",
"busy": "Selesaikan sulih suara Anda dulu — lalu instal pembaruannya.",
"busy": "Tunggu pekerjaan yang sedang berjalan selesai — lalu instal pembaruannya.",
"whats_new": "Apa yang baru",
"failed": "Pembaruan gagal",
"retry": "Coba lagi",
"dismiss": "Singkirkan"
"dismiss": "Singkirkan",
"toast_available": "OmniVoice Studio {{version}} tersedia",
"toast_install": "Instal dan mulai ulang",
"toast_whats_new": "Yang baru",
"toast_later": "Nanti"
},
"archetypes": {
"featured": "Unggulan",
@@ -2279,4 +2284,4 @@
"output_title": "Yang dilaporkan aplikasi",
"retry_hint": "Coba \"Coba lagi\" di Pengaturan → Log → Backend. Jika gagal lagi, \"Bersihkan & Coba lagi\" akan membangun ulang lingkungan dari awal."
}
}
}
+9 -4
View File
@@ -1438,7 +1438,8 @@
"report": "Segnala questo bug",
"report_failed": "Impossibile aprire la segnalazione. Riprova oppure copia i dettagli qui sopra in una nuova issue.",
"searchIssues": "Cerca problemi simili",
"unexpected": "Errore imprevisto: {{message}}"
"unexpected": "Errore imprevisto: {{message}}",
"backend_shutting_down": "OmniVoice si sta chiudendo. Riapri lapp e riprova."
},
"keyboard": {
"title": "Scorciatoie da tastiera",
@@ -1891,11 +1892,15 @@
"install_hint": "Scarica l'aggiornamento e riavvia nella nuova versione",
"downloading": "Aggiornamento… {{pct}}%",
"restart": "Riavvia per aggiornare",
"busy": "Completa prima il tuo doppiaggio, poi installa l'aggiornamento.",
"busy": "Attendi il completamento del lavoro in corso, poi installa laggiornamento.",
"whats_new": "Cosa c'è di nuovo",
"failed": "Aggiornamento non riuscito",
"retry": "Riprova",
"dismiss": "Ignora"
"dismiss": "Ignora",
"toast_available": "OmniVoice Studio {{version}} è disponibile",
"toast_install": "Installa e riavvia",
"toast_whats_new": "Novità",
"toast_later": "Più tardi"
},
"archetypes": {
"featured": "In primo piano",
@@ -2279,4 +2284,4 @@
"output_title": "Cosa ha segnalato l'app",
"retry_hint": "Prova \"Riprova\" in Impostazioni → Log → Backend. Se fallisce di nuovo, \"Pulisci e riprova\" ricostruisce l'ambiente da zero."
}
}
}
+12 -7
View File
@@ -308,8 +308,8 @@
"frontend": "フロントエンド",
"tauri": "タウリ",
"cancelOp": "操作をキャンセルする",
"dismiss": "解雇する",
"dismissStatus": "ステータスを却下",
"dismiss": "閉じる",
"dismissStatus": "ステータスを閉じる",
"search": "検索…",
"no_matches": "一致しません",
"recent_and_popular": "最近の人気の",
@@ -1276,7 +1276,7 @@
"later": "たぶん後で",
"star": "GitHub でスターを付ける",
"opt_out": "二度と聞かないでください",
"dismiss_aria": "解雇する"
"dismiss_aria": "閉じる"
}
},
"enterprise": {
@@ -1438,7 +1438,8 @@
"report": "このバグを報告",
"report_failed": "バグレポートを開けませんでした。もう一度お試しいただくか、上記の内容を新しい Issue にコピーしてください。",
"searchIssues": "類似の問題を検索",
"unexpected": "予期しないエラー: {{message}}"
"unexpected": "予期しないエラー: {{message}}",
"backend_shutting_down": "OmniVoice を終了しています。アプリを開き直してからもう一度お試しください。"
},
"keyboard": {
"title": "キーボードショートカット",
@@ -1891,11 +1892,15 @@
"install_hint": "アップデートをダウンロードして新しいバージョンで再起動します",
"downloading": "更新中… {{pct}}%",
"restart": "再起動してアップデート",
"busy": "先に吹き替えを完了してから、アップデートをインストールしてください。",
"busy": "実行中の処理が終わってから、アップデートをインストールしてください。",
"whats_new": "新機能",
"failed": "アップデートに失敗しました",
"retry": "再試行",
"dismiss": "解雇する"
"dismiss": "閉じる",
"toast_available": "OmniVoice Studio {{version}} が利用可能です",
"toast_install": "インストールして再起動",
"toast_whats_new": "新機能",
"toast_later": "後で"
},
"archetypes": {
"featured": "注目の",
@@ -2279,4 +2284,4 @@
"output_title": "アプリが報告した内容",
"retry_hint": "設定 → ログ → バックエンド の「再試行」をお試しください。再度失敗する場合は「クリーンして再試行」で環境を一から再構築します。"
}
}
}
+9 -4
View File
@@ -1438,7 +1438,8 @@
"report": "이 버그 신고",
"report_failed": "버그 리포트를 열지 못했습니다. 다시 시도하거나 위 내용을 새 이슈에 복사해 주세요.",
"searchIssues": "유사한 문제 검색",
"unexpected": "예기치 않은 오류: {{message}}"
"unexpected": "예기치 않은 오류: {{message}}",
"backend_shutting_down": "OmniVoice를 종료하는 중입니다. 앱을 다시 열고 시도하세요."
},
"keyboard": {
"title": "키보드 단축키",
@@ -1891,11 +1892,15 @@
"install_hint": "업데이트를 다운로드하고 새 버전으로 다시 시작합니다",
"downloading": "업데이트 중… {{pct}}%",
"restart": "다시 시작하여 업데이트",
"busy": "먼저 더빙을 완료한 후 업데이트를 설치하세요.",
"busy": "진행 중인 작업이 끝난 후 업데이트를 설치하세요.",
"whats_new": "새로운 소식",
"failed": "업데이트 실패",
"retry": "재시도",
"dismiss": "닫기"
"dismiss": "닫기",
"toast_available": "OmniVoice Studio {{version}}을(를) 사용할 수 있습니다",
"toast_install": "설치 후 재시작",
"toast_whats_new": "새로운 기능",
"toast_later": "나중에"
},
"archetypes": {
"featured": "추천",
@@ -2279,4 +2284,4 @@
"output_title": "앱이 보고한 내용",
"retry_hint": "설정 → 로그 → 백엔드에서 \"다시 시도\"를 눌러 보세요. 다시 실패하면 \"정리 후 다시 시도\"가 환경을 처음부터 다시 구성합니다."
}
}
}
+9 -4
View File
@@ -1438,7 +1438,8 @@
"report": "Deze bug melden",
"report_failed": "Kon het bugrapport niet openen. Probeer het opnieuw of kopieer de details hierboven naar een nieuwe issue.",
"searchIssues": "Vergelijkbare problemen zoeken",
"unexpected": "Onverwachte fout: {{message}}"
"unexpected": "Onverwachte fout: {{message}}",
"backend_shutting_down": "OmniVoice wordt afgesloten. Open de app opnieuw en probeer het nog eens."
},
"keyboard": {
"title": "Sneltoetsen",
@@ -1891,11 +1892,15 @@
"install_hint": "Download de update en start opnieuw op in de nieuwe versie",
"downloading": "Bijwerken… {{pct}}%",
"restart": "Opnieuw opstarten om bij te werken",
"busy": "Voltooi eerst je nasynchronisatie — installeer daarna de update.",
"busy": "Wacht tot het lopende werk klaar is — installeer daarna de update.",
"whats_new": "Wat is er nieuw",
"failed": "Update mislukt",
"retry": "Opnieuw proberen",
"dismiss": "Negeren"
"dismiss": "Negeren",
"toast_available": "OmniVoice Studio {{version}} is beschikbaar",
"toast_install": "Installeren en herstarten",
"toast_whats_new": "Wat is er nieuw",
"toast_later": "Later"
},
"archetypes": {
"featured": "Uitgelicht",
@@ -2279,4 +2284,4 @@
"output_title": "Wat de app meldde",
"retry_hint": "Probeer \"Opnieuw\" in Instellingen → Logboeken → Backend. Mislukt het weer, dan bouwt \"Opschonen en opnieuw\" de omgeving helemaal opnieuw op."
}
}
}
+9 -4
View File
@@ -1438,7 +1438,8 @@
"report": "Zgłoś ten błąd",
"report_failed": "Nie udało się otworzyć zgłoszenia błędu. Spróbuj ponownie lub skopiuj powyższe szczegóły do nowego zgłoszenia.",
"searchIssues": "Szukaj podobnych problemów",
"unexpected": "Nieoczekiwany błąd: {{message}}"
"unexpected": "Nieoczekiwany błąd: {{message}}",
"backend_shutting_down": "OmniVoice się zamyka. Otwórz aplikację ponownie i spróbuj jeszcze raz."
},
"keyboard": {
"title": "Skróty klawiaturowe",
@@ -1891,11 +1892,15 @@
"install_hint": "Pobierz aktualizację i uruchom ponownie w nowej wersji",
"downloading": "Aktualizowanie… {{pct}}%",
"restart": "Uruchom ponownie, aby zaktualizować",
"busy": "Najpierw dokończ dubbing — potem zainstaluj aktualizację.",
"busy": "Poczekaj na zakończenie trwającej pracy — potem zainstaluj aktualizację.",
"whats_new": "Co nowego?",
"failed": "Aktualizacja nie powiodła się",
"retry": "Spróbuj ponownie",
"dismiss": "Odrzuć"
"dismiss": "Odrzuć",
"toast_available": "OmniVoice Studio {{version}} jest dostępna",
"toast_install": "Zainstaluj i uruchom ponownie",
"toast_whats_new": "Co nowego",
"toast_later": "Później"
},
"archetypes": {
"featured": "Polecane",
@@ -2279,4 +2284,4 @@
"output_title": "Co zgłosiła aplikacja",
"retry_hint": "Spróbuj „Ponów” w Ustawienia → Dzienniki → Backend. Jeśli znów się nie uda, „Wyczyść i ponów” odbuduje środowisko od zera."
}
}
}
+9 -4
View File
@@ -1438,7 +1438,8 @@
"report": "Relatar este bug",
"report_failed": "Não foi possível abrir o relatório de bug. Tente novamente ou copie os detalhes acima para uma nova issue.",
"searchIssues": "Pesquisar problemas semelhantes",
"unexpected": "Erro inesperado: {{message}}"
"unexpected": "Erro inesperado: {{message}}",
"backend_shutting_down": "O OmniVoice está sendo encerrado. Reabra o aplicativo e tente novamente."
},
"keyboard": {
"title": "Atalhos de teclado",
@@ -1891,11 +1892,15 @@
"install_hint": "Baixe a atualização e reinicie na nova versão",
"downloading": "Atualizando… {{pct}}%",
"restart": "Reiniciar para atualizar",
"busy": "Termine sua dublagem primeiro — depois instale a atualização.",
"busy": "Aguarde o trabalho em andamento terminar — depois instale a atualização.",
"whats_new": "O que há de novo",
"failed": "Falha na atualização",
"retry": "Tentar novamente",
"dismiss": "Dispensar"
"dismiss": "Dispensar",
"toast_available": "OmniVoice Studio {{version}} está disponível",
"toast_install": "Instalar e reiniciar",
"toast_whats_new": "Novidades",
"toast_later": "Mais tarde"
},
"archetypes": {
"featured": "Destaque",
@@ -2279,4 +2284,4 @@
"output_title": "O que o app relatou",
"retry_hint": "Tente \"Tentar novamente\" em Configurações → Logs → Backend. Se falhar de novo, \"Limpar e tentar novamente\" reconstrói o ambiente do zero."
}
}
}
+12 -7
View File
@@ -308,8 +308,8 @@
"frontend": "Внешний интерфейс",
"tauri": "Тавр",
"cancelOp": "Отменить операцию",
"dismiss": "Увольнять",
"dismissStatus": "Отклонить статус",
"dismiss": "Закрыть",
"dismissStatus": "Закрыть статус",
"search": "Поиск…",
"no_matches": "Нет совпадений",
"recent_and_popular": "Последние и популярные",
@@ -1276,7 +1276,7 @@
"later": "Может быть, позже",
"star": "Звезда на GitHub",
"opt_out": "Не спрашивай больше",
"dismiss_aria": "Увольнять"
"dismiss_aria": "Закрыть"
}
},
"enterprise": {
@@ -1438,7 +1438,8 @@
"report": "Сообщить об этой ошибке",
"report_failed": "Не удалось открыть отчёт об ошибке. Попробуйте ещё раз или скопируйте детали выше в новое обращение.",
"searchIssues": "Искать похожие проблемы",
"unexpected": "Непредвиденная ошибка: {{message}}"
"unexpected": "Непредвиденная ошибка: {{message}}",
"backend_shutting_down": "OmniVoice завершает работу. Откройте приложение заново и повторите попытку."
},
"keyboard": {
"title": "Сочетания клавиш",
@@ -1891,11 +1892,15 @@
"install_hint": "Загрузить обновление и перезапустить в новой версии",
"downloading": "Обновление… {{pct}}%",
"restart": "Перезапустить для обновления",
"busy": "Сначала завершите дубляж — затем установите обновление.",
"busy": "Дождитесь завершения текущей задачи — затем установите обновление.",
"whats_new": "Что нового",
"failed": "Обновление не удалось",
"retry": "Повторить попытку",
"dismiss": "Увольнять"
"dismiss": "Закрыть",
"toast_available": "OmniVoice Studio {{version}} доступна",
"toast_install": "Установить и перезапустить",
"toast_whats_new": "Что нового",
"toast_later": "Позже"
},
"archetypes": {
"featured": "Рекомендуемые",
@@ -2279,4 +2284,4 @@
"output_title": "Что сообщило приложение",
"retry_hint": "Попробуйте «Повторить» в Настройки → Журналы → Бэкенд. Если снова не выйдет, «Очистить и повторить» пересоберёт окружение с нуля."
}
}
}
+9 -4
View File
@@ -1438,7 +1438,8 @@
"report": "Rapportera den här buggen",
"report_failed": "Det gick inte att öppna felrapporten. Försök igen, eller kopiera detaljerna ovan till ett nytt ärende.",
"searchIssues": "Sök liknande problem",
"unexpected": "Oväntat fel: {{message}}"
"unexpected": "Oväntat fel: {{message}}",
"backend_shutting_down": "OmniVoice stängs av. Öppna appen igen och försök på nytt."
},
"keyboard": {
"title": "Kortkommandon",
@@ -1891,11 +1892,15 @@
"install_hint": "Ladda ner uppdateringen och starta om i den nya versionen",
"downloading": "Uppdaterar… {{pct}} %",
"restart": "Starta om för att uppdatera",
"busy": "Slutför din dubbning först installera sedan uppdateringen.",
"busy": "Vänta tills det pågående arbetet är klart installera sedan uppdateringen.",
"whats_new": "Vad är nytt",
"failed": "Uppdateringen misslyckades",
"retry": "Försök igen",
"dismiss": "Avvisa"
"dismiss": "Avvisa",
"toast_available": "OmniVoice Studio {{version}} är tillgänglig",
"toast_install": "Installera och starta om",
"toast_whats_new": "Nyheter",
"toast_later": "Senare"
},
"archetypes": {
"featured": "Utvalda",
@@ -2279,4 +2284,4 @@
"output_title": "Vad appen rapporterade",
"retry_hint": "Prova \"Försök igen\" under Inställningar → Loggar → Backend. Misslyckas det igen bygger \"Rensa och försök igen\" om miljön från grunden."
}
}
}
+9 -4
View File
@@ -1438,7 +1438,8 @@
"report": "รายงานข้อบกพร่องนี้",
"report_failed": "เปิดรายงานข้อบกพร่องไม่ได้ โปรดลองอีกครั้ง หรือคัดลอกรายละเอียดด้านบนไปยังรายงานใหม่",
"searchIssues": "ค้นหาปัญหาที่คล้ายกัน",
"unexpected": "ข้อผิดพลาดที่ไม่คาดคิด: {{message}}"
"unexpected": "ข้อผิดพลาดที่ไม่คาดคิด: {{message}}",
"backend_shutting_down": "OmniVoice กำลังปิดอยู่ เปิดแอปอีกครั้งแล้วลองใหม่"
},
"keyboard": {
"title": "แป้นพิมพ์ลัด",
@@ -1891,11 +1892,15 @@
"install_hint": "ดาวน์โหลดอัปเดตและรีสตาร์ทเป็นเวอร์ชันใหม่",
"downloading": "กำลังอัปเดต… {{pct}}%",
"restart": "รีสตาร์ทเพื่ออัปเดต",
"busy": "ทำการพากย์เสียงของคุณให้เสร็จก่อน แล้วจึงติดตั้งอัปเดต",
"busy": "รอให้งานที่กำลังทำอยู่เสร็จก่อน แล้วจึงติดตั้งอัปเดต",
"whats_new": "มีอะไรใหม่",
"failed": "การอัปเดตล้มเหลว",
"retry": "ลองอีกครั้ง",
"dismiss": "ยกเลิก"
"dismiss": "ยกเลิก",
"toast_available": "OmniVoice Studio {{version}} พร้อมใช้งาน",
"toast_install": "ติดตั้งและรีสตาร์ท",
"toast_whats_new": "มีอะไรใหม่",
"toast_later": "ภายหลัง"
},
"archetypes": {
"featured": "จุดเด่น",
@@ -2279,4 +2284,4 @@
"output_title": "สิ่งที่แอปรายงาน",
"retry_hint": "ลอง \"ลองใหม่\" ใน การตั้งค่า → บันทึก → แบ็กเอนด์ หากยังล้มเหลว \"ล้างและลองใหม่\" จะสร้างสภาพแวดล้อมขึ้นใหม่ทั้งหมด"
}
}
}
+9 -4
View File
@@ -1438,7 +1438,8 @@
"report": "Bu hatayı bildir",
"report_failed": "Hata raporu açılamadı. Tekrar deneyin veya yukarıdaki ayrıntıları yeni bir konuya kopyalayın.",
"searchIssues": "Benzer sorunları ara",
"unexpected": "Beklenmeyen hata: {{message}}"
"unexpected": "Beklenmeyen hata: {{message}}",
"backend_shutting_down": "OmniVoice kapanıyor. Uygulamayı yeniden açıp tekrar dene."
},
"keyboard": {
"title": "Klavye kısayolları",
@@ -1891,11 +1892,15 @@
"install_hint": "Güncellemeyi indir ve yeni sürümle yeniden başlat",
"downloading": "Güncelleniyor… %{{pct}}",
"restart": "Güncellemek için yeniden başlat",
"busy": "Önce dublajını bitir — sonra güncellemeyi yükle.",
"busy": "Devam eden işlem bitene kadar bekle — sonra güncellemeyi yükle.",
"whats_new": "Yenilikler",
"failed": "Güncelleme başarısız oldu",
"retry": "Yeniden dene",
"dismiss": "Reddet"
"dismiss": "Reddet",
"toast_available": "OmniVoice Studio {{version}} kullanılabilir",
"toast_install": "Yükle ve yeniden başlat",
"toast_whats_new": "Yenilikler",
"toast_later": "Daha sonra"
},
"archetypes": {
"featured": "Öne Çıkanlar",
@@ -2279,4 +2284,4 @@
"output_title": "Uygulamanın bildirdiği",
"retry_hint": "Ayarlar → Günlükler → Arka uç bölümünden \"Yeniden dene\"yi deneyin. Yine başarısız olursa \"Temizle ve yeniden dene\" ortamı sıfırdan kurar."
}
}
}
+9 -4
View File
@@ -1438,7 +1438,8 @@
"report": "Повідомити про цю помилку",
"report_failed": "Не вдалося відкрити звіт про помилку. Спробуйте ще раз або скопіюйте деталі вище в нове звернення.",
"searchIssues": "Шукати схожі проблеми",
"unexpected": "Неочікувана помилка: {{message}}"
"unexpected": "Неочікувана помилка: {{message}}",
"backend_shutting_down": "OmniVoice завершує роботу. Відкрийте застосунок знову та спробуйте ще раз."
},
"keyboard": {
"title": "Комбінації клавіш",
@@ -1891,11 +1892,15 @@
"install_hint": "Завантажити оновлення та перезапустити в новій версії",
"downloading": "Оновлення… {{pct}}%",
"restart": "Перезапустити для оновлення",
"busy": "Спочатку завершіть дубляж — потім встановіть оновлення.",
"busy": "Зачекайте, доки завершиться поточна робота — потім встановіть оновлення.",
"whats_new": "Що нового",
"failed": "Не вдалося оновити",
"retry": "Повторіть спробу",
"dismiss": "Відхилити"
"dismiss": "Відхилити",
"toast_available": "OmniVoice Studio {{version}} доступна",
"toast_install": "Встановити та перезапустити",
"toast_whats_new": "Що нового",
"toast_later": "Пізніше"
},
"archetypes": {
"featured": "Рекомендовані",
@@ -2279,4 +2284,4 @@
"output_title": "Що повідомив застосунок",
"retry_hint": "Спробуйте «Повторити» в Налаштування → Журнали → Бекенд. Якщо знову не вдасться, «Очистити й повторити» перебудує середовище з нуля."
}
}
}
+9 -4
View File
@@ -1438,7 +1438,8 @@
"report": "Báo cáo lỗi này",
"report_failed": "Không mở được báo cáo lỗi. Vui lòng thử lại, hoặc sao chép chi tiết ở trên vào một issue mới.",
"searchIssues": "Tìm các vấn đề tương tự",
"unexpected": "Lỗi không mong muốn: {{message}}"
"unexpected": "Lỗi không mong muốn: {{message}}",
"backend_shutting_down": "OmniVoice đang tắt. Mở lại ứng dụng rồi thử lại."
},
"keyboard": {
"title": "Phím tắt",
@@ -1891,11 +1892,15 @@
"install_hint": "Tải bản cập nhật và khởi động lại vào phiên bản mới",
"downloading": "Đang cập nhật… {{pct}}%",
"restart": "Khởi động lại để cập nhật",
"busy": "Hoàn tất lồng tiếng của bạn trước — rồi cài đặt bản cập nhật.",
"busy": "Chờ công việc đang chạy hoàn tất — rồi cài đặt bản cập nhật.",
"whats_new": "Có gì mới",
"failed": "Cập nhật không thành công",
"retry": "Thử lại",
"dismiss": "Loại bỏ"
"dismiss": "Loại bỏ",
"toast_available": "OmniVoice Studio {{version}} đã có",
"toast_install": "Cài đặt và khởi động lại",
"toast_whats_new": "Có gì mới",
"toast_later": "Để sau"
},
"archetypes": {
"featured": "Nổi bật",
@@ -2279,4 +2284,4 @@
"output_title": "Nội dung ứng dụng báo về",
"retry_hint": "Hãy thử \"Thử lại\" trong Cài đặt → Nhật ký → Backend. Nếu vẫn lỗi, \"Dọn dẹp & Thử lại\" sẽ dựng lại môi trường từ đầu."
}
}
}
+12 -7
View File
@@ -96,8 +96,8 @@
"frontend": "前端",
"tauri": "Tauri",
"cancelOp": "取消操作",
"dismiss": "解雇",
"dismissStatus": "解除状态",
"dismiss": "关闭",
"dismissStatus": "关闭状态",
"search": "搜索...",
"no_matches": "没有匹配项",
"recent_and_popular": "最近和热门",
@@ -1239,7 +1239,7 @@
"later": "也许稍后",
"star": "在 GitHub 上加星标",
"opt_out": "不要再问",
"dismiss_aria": "解雇"
"dismiss_aria": "关闭"
}
},
"enterprise": {
@@ -1444,7 +1444,8 @@
"report": "报告此错误",
"report_failed": "无法打开错误报告。请重试,或将上面的详细信息复制到新的 issue 中。",
"searchIssues": "搜索类似问题",
"unexpected": "意外错误:{{message}}"
"unexpected": "意外错误:{{message}}",
"backend_shutting_down": "OmniVoice 正在关闭。请重新打开应用后再试。"
},
"keyboard": {
"title": "键盘快捷键",
@@ -1898,11 +1899,15 @@
"install_hint": "下载更新并重启到新版本",
"downloading": "更新中… {{pct}}%",
"restart": "重启以更新",
"busy": "请先完成配音,再安装更新。",
"busy": "请等待正在进行的任务完成,再安装更新。",
"whats_new": "更新内容",
"failed": "更新失败",
"retry": "重试",
"dismiss": "解雇"
"dismiss": "关闭",
"toast_available": "OmniVoice Studio {{version}} 可用",
"toast_install": "安装并重启",
"toast_whats_new": "新增内容",
"toast_later": "稍后"
},
"archetypes": {
"featured": "精选",
@@ -2286,4 +2291,4 @@
"output_title": "应用报告的内容",
"retry_hint": "请在 设置 → 日志 → 后端 中尝试“重试”。若再次失败,“清理并重试”会从头重建运行环境。"
}
}
}
+12 -7
View File
@@ -308,8 +308,8 @@
"frontend": "前端",
"tauri": "金牛座",
"cancelOp": "取消操作",
"dismiss": "解僱",
"dismissStatus": "解除狀態",
"dismiss": "關閉",
"dismissStatus": "關閉狀態",
"search": "搜尋...",
"no_matches": "沒有匹配項",
"recent_and_popular": "最近和熱門",
@@ -1276,7 +1276,7 @@
"later": "也許稍後",
"star": "在 GitHub 上加星標",
"opt_out": "不要再問",
"dismiss_aria": "解僱"
"dismiss_aria": "關閉"
}
},
"enterprise": {
@@ -1438,7 +1438,8 @@
"report": "回報此錯誤",
"report_failed": "無法開啟錯誤回報。請重試,或將上方的詳細資訊複製到新的 issue 中。",
"searchIssues": "搜尋類似問題",
"unexpected": "未預期的錯誤:{{message}}"
"unexpected": "未預期的錯誤:{{message}}",
"backend_shutting_down": "OmniVoice 正在關閉。請重新開啟應用程式後再試。"
},
"keyboard": {
"title": "鍵盤快速鍵",
@@ -1891,11 +1892,15 @@
"install_hint": "下載更新並重新啟動至新版本",
"downloading": "更新中… {{pct}}%",
"restart": "重新啟動以更新",
"busy": "請先完成配音,再安裝更新。",
"busy": "請等待正在進行的工作完成,再安裝更新。",
"whats_new": "有什麼新消息",
"failed": "更新失敗",
"retry": "重試",
"dismiss": "解僱"
"dismiss": "關閉",
"toast_available": "OmniVoice Studio {{version}} 可用",
"toast_install": "安裝並重新啟動",
"toast_whats_new": "新增內容",
"toast_later": "稍後"
},
"archetypes": {
"featured": "精選",
@@ -2279,4 +2284,4 @@
"output_title": "應用程式回報的內容",
"retry_hint": "請在 設定 → 記錄 → 後端 中嘗試「重試」。若再次失敗,「清理並重試」會從頭重建執行環境。"
}
}
}
+6 -2
View File
@@ -2533,12 +2533,16 @@ input[type="file"]::file-selector-button:hover {
}
.app-startup__title { font-size: 18px; color: #ebdbb2; }
.app-wizard-wrap {
/* Fill viewport above the fixed LogsFooter */
/* Fills the WHOLE viewport: the first-run wizard deliberately renders no
LogsFooter (studio chrome belongs to the studio), so there is nothing to
reserve space for. While it did reserve 28px, the wizard's own root was
`fixed inset-0` and escaped this box anyway its pinned Continue / HF
token row rendered underneath the status bar and was unreachable. */
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: var(--logs-footer-height, 28px);
bottom: 0;
overflow: hidden;
background: var(--color-bg, #1d2021);
display: flex;
+6
View File
@@ -6,6 +6,12 @@ if (import.meta.env.DEV && !window.__vite_plugin_react_preamble_installed__) {
window.__vite_plugin_react_preamble_installed__ = true;
}
// Web-platform gap fills for the oldest WebView we support (macOS 12 ships
// WKWebView 15.6). MUST be first: these are touched during the first React
// render, so a missing one throws mid-render and leaves a dead window rather
// than a degraded feature (#1245).
import './utils/webCompat.js';
// AudioContext autoplay-policy unlock MUST install before any module that
// constructs an AudioContext (wavesurfer.js, the AEC tap, the dictation
// capture, etc.). The side-effecting import patches `window.AudioContext`
+6 -1
View File
@@ -294,7 +294,12 @@ export default function SetupWizard({ onReady }) {
};
return (
<div className="fixed inset-0 flex flex-col items-center overflow-hidden bg-bg px-6 pt-12 font-sans text-fg">
// `absolute`, not `fixed`: this mounts inside `.app-wizard-wrap`, and a
// fixed root would ignore that box and lay its pinned footer out against
// the viewport putting Continue and the HF-token card behind the status
// bar, off the bottom of the window. `pb-4` keeps the pinned row off the
// very edge now that it really is the last thing on screen.
<div className="absolute inset-0 flex flex-col items-center overflow-hidden bg-bg px-6 pb-4 pt-12 font-sans text-fg">
<div className="flex w-full max-w-[1100px] flex-1 flex-col">
{/* ── Masthead: identical identity to setup + install acts ────────── */}
<header
+20
View File
@@ -45,6 +45,22 @@ export interface GenerateSlice {
designSeed: number | null;
keepSeed: boolean;
/**
* How many synth requests are in flight right now.
*
* A COUNT, not a flag. Syntheses overlap the Generate tab, voice previews,
* the compare modal, the stories editor and profile previews can all be
* running at once, and a boolean would be cleared by whichever finished
* first while the others were still going, letting the updater relaunch and
* discard them.
*
* Maintained by `api/generate.ts` around the single `/generate` call every
* one of those paths goes through, so a new caller is covered without having
* to remember this exists. Read via `utils/appBusy`. Transient: never
* persisted, since a synth cannot survive a reload.
*/
ttsInflight: number;
setText: (v: string) => void;
setRefText: (v: string) => void;
setInstruct: (v: string) => void;
@@ -65,6 +81,8 @@ export interface GenerateSlice {
setDesignSeed: (v: number | null) => void;
setKeepSeed: (v: boolean) => void;
/** +1 on synth start, -1 on settle. Floors at 0; never goes negative. */
addTtsInflight: (delta: number) => void;
}
const INITIAL_VD: VDStates = {
@@ -97,6 +115,7 @@ export const createGenerateSlice: StateCreator<GenerateSlice, [], [], GenerateSl
designSeed: null,
keepSeed: false,
ttsInflight: 0,
setText: (v) => set({ text: v }),
setRefText: (v) => set({ refText: v }),
@@ -121,4 +140,5 @@ export const createGenerateSlice: StateCreator<GenerateSlice, [], [], GenerateSl
setDesignSeed: (v) => set({ designSeed: v }),
setKeepSeed: (v) => set({ keepSeed: v }),
addTtsInflight: (delta) => set((st) => ({ ttsInflight: Math.max(0, st.ttsInflight + delta) })),
});
@@ -0,0 +1,124 @@
/**
* First-run wizard chrome: the pinned action row must stay on screen, and the
* studio's status bar must not appear before the user reaches the studio.
*
* The bug: `SetupWizard`'s root was `fixed inset-0`, so it laid itself out
* against the VIEWPORT rather than `.app-wizard-wrap` the box App.jsx sizes
* to stop above the fixed `LogsFooter`. Its pinned footer (Continue, Back, and
* the "set a Hugging Face token" card) therefore rendered underneath the status
* bar, clipped off the bottom of the window: on the Models & engines step the
* Continue button was simply unreachable.
*
* Both halves are pinned here the root must not be `fixed`, and the wizard
* branch must render no `LogsFooter` because either one alone reintroduces
* the clip.
*/
import React from 'react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { I18nextProvider } from 'react-i18next';
import fs from 'node:fs';
import path from 'node:path';
import i18n from '../i18n';
vi.mock('../components/WizardLibrary', () => ({ default: () => null }));
vi.mock('../components/MediaEngineCard', () => ({ default: () => null }));
vi.mock('../components/MirrorRescue', () => ({ default: () => null }));
vi.mock('../components/DictationDemo', () => ({ default: () => null }));
vi.mock('../components/HfTokenCard', () => ({
default: ({ className }) => <div data-testid="hf-token-card" className={className} />,
}));
vi.mock('../api/external', () => ({ openExternal: vi.fn(() => Promise.resolve()) }));
vi.mock('../api/hooks', () => ({
useSetupStatus: () => ({
data: { models_ready: true, missing: [], hf_cache_dir: '/tmp/hf' },
refetch: vi.fn(),
}),
usePreflight: () => ({
data: { ok: true, has_warnings: false, checks: [] },
isLoading: false,
refetch: vi.fn(),
}),
}));
vi.mock('../api/client', () => ({
apiJson: vi.fn(() => Promise.resolve({ available: false, prompted: true })),
apiFetch: vi.fn(() => Promise.resolve({ ok: true, json: () => Promise.resolve({}) })),
API: '',
}));
import SetupWizard from '../pages/SetupWizard';
const withI18n = (node) => <I18nextProvider i18n={i18n}>{node}</I18nextProvider>;
const readSrc = (rel) => fs.readFileSync(path.resolve(__dirname, '..', rel), 'utf8');
beforeEach(() => {
document.body.innerHTML = '';
});
describe('SetupWizard — the pinned action row stays on screen', () => {
it('does not position its root against the viewport', () => {
const { container } = render(withI18n(<SetupWizard onReady={() => {}} />));
const root = container.firstElementChild;
// `fixed` ignores .app-wizard-wrap's box; `absolute` fills it.
expect(root.className).not.toMatch(/\bfixed\b/);
expect(root.className).toMatch(/\babsolute\b/);
// ...and the pinned row needs clearance now that it really is the last
// thing on screen flush against the window edge is the same bug's
// cosmetic tail.
expect(root.className).toMatch(/\bpb-\d/);
});
it('keeps Continue and the HF-token card OUT of the scrolling region', async () => {
render(withI18n(<SetupWizard onReady={() => {}} />));
fireEvent.click(await screen.findByText(/All good — continue/i));
// Models step: the token card and the action row are siblings of the
// scroller, not children of it otherwise they scroll away instead of
// staying pinned.
const card = await screen.findByTestId('hf-token-card');
expect(card.className).toMatch(/shrink-0/);
const cta = await screen.findByText(/Required models ready/i);
const row = cta.closest('div');
expect(row.className).toMatch(/shrink-0/);
let el = card;
while (el) {
expect(el.className || '').not.toMatch(/overflow-y-auto/);
el = el.parentElement;
}
});
});
describe('studio chrome does not appear before the studio', () => {
it('renders no LogsFooter in the first-run wizard or the pre-wizard splash', () => {
const app = readSrc('App.jsx');
// Split App.jsx at the last pre-studio early return. Everything above is a
// screen the user sees BEFORE the studio (awaiting_setup splash,
// !setupChecked splash, the wizard); everything below is the studio.
const split = app.indexOf('// Block the main UI until Rust reports the backend is ready');
expect(split).toBeGreaterThan(0);
const preStudio = app.slice(app.indexOf("if (bootstrapStage === 'awaiting_setup')"), split);
const studio = app.slice(split);
// Asserted per-branch rather than as a global count: a bare count of 1
// would still pass if the mount MOVED from the studio into the splash.
expect(preStudio).not.toContain('LogsFooter');
expect(studio.match(/<LogsFooter\s*\/>/g) || []).toHaveLength(1);
});
it('gives the wizard the whole viewport, since nothing is reserved below it', () => {
const css = readSrc('index.css');
const rule = css.slice(css.indexOf('.app-wizard-wrap {'));
const body = rule.slice(0, rule.indexOf('}'));
// A leftover `bottom: var(--logs-footer-height)` would strand a dead 28px
// gap under the wizard now that no footer is rendered there.
expect(body).not.toContain('--logs-footer-height');
expect(body).toMatch(/bottom:\s*0;/);
});
});
+167
View File
@@ -0,0 +1,167 @@
/**
* The update announcement must be a toast with actions not a wall of text,
* and not a 6-pixel dot.
*
* Release notes for a version are the whole changelog section: v0.4.1's was 42
* bullets. Older builds put that in a blocking OS dialog that filled the screen
* and had to be dismissed before the app could be used at all. Removing the
* dialog left the opposite failure the only remaining signal was a dot beside
* the version number in the footer, which is easy to never notice.
*
* What is pinned here: the toast says which version, offers install and a route
* to the notes, never renders the notes inline, and cannot stack duplicates
* when the 6-hourly re-check fires again.
*/
import React from 'react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { I18nextProvider } from 'react-i18next';
import i18n from '../i18n';
// vi.hoisted: vi.mock factories are lifted above every import, so anything
// they close over has to be hoisted with them rather than declared below.
const { toastCalls, dismissed, installUpdate, openSettingsTab, storeState } = vi.hoisted(() => ({
toastCalls: [],
dismissed: [],
installUpdate: vi.fn(),
openSettingsTab: vi.fn(),
storeState: { dubStep: 'idle', stage: 'idle', ttsInflight: 0 },
}));
vi.mock('react-hot-toast', () => {
const toast = vi.fn();
toast.custom = vi.fn((render, opts) => {
toastCalls.push({ render, opts });
return opts?.id;
});
toast.dismiss = vi.fn((id) => dismissed.push(id));
return { default: toast, toast };
});
vi.mock('../utils/updater', () => ({ installUpdate: (...a) => installUpdate(...a) }));
vi.mock('../store', () => ({
useAppStore: Object.assign(() => undefined, {
getState: () => ({ openSettingsTab, ...storeState }),
}),
}));
import UpdateToastBody, { showUpdateToast } from '../components/UpdateToast';
const withI18n = (node) => <I18nextProvider i18n={i18n}>{node}</I18nextProvider>;
beforeEach(() => {
toastCalls.length = 0;
dismissed.length = 0;
installUpdate.mockClear();
openSettingsTab.mockClear();
Object.assign(storeState, { dubStep: 'idle', stage: 'idle', ttsInflight: 0 });
});
afterEach(() => vi.clearAllMocks());
describe('showUpdateToast', () => {
it('announces the version', async () => {
showUpdateToast('0.4.2');
expect(toastCalls).toHaveLength(1);
render(withI18n(toastCalls[0].render({ id: 'x' })));
expect(await screen.findByText(/0\.4\.2 is available/i)).toBeTruthy();
});
it('uses one id per version, so a re-check cannot stack duplicates', () => {
showUpdateToast('0.4.2');
showUpdateToast('0.4.2');
// Same id both times react-hot-toast replaces rather than appends.
expect(toastCalls.map((c) => c.opts.id)).toEqual([
'update-available-0.4.2',
'update-available-0.4.2',
]);
});
it('never auto-dismisses — an unanswered update is still true', () => {
showUpdateToast('0.4.2');
expect(toastCalls[0].opts.duration).toBe(Infinity);
});
it('does nothing without a version', () => {
showUpdateToast(null);
expect(toastCalls).toHaveLength(0);
});
});
describe('the toast body', () => {
it('offers install, notes and dismiss', async () => {
render(withI18n(<UpdateToastBody id="t1" version="0.4.2" />));
expect(await screen.findByText(/Install and restart/i)).toBeTruthy();
expect(screen.getByText(/What's new/i)).toBeTruthy();
expect(screen.getByText(/Later/i)).toBeTruthy();
});
it('routes to the notes instead of rendering them', async () => {
render(withI18n(<UpdateToastBody id="t1" version="0.4.2" />));
fireEvent.click(await screen.findByText(/What's new/i));
expect(openSettingsTab).toHaveBeenCalledWith('updates');
expect(dismissed).toContain('t1');
});
it('installs on request', async () => {
render(withI18n(<UpdateToastBody id="t1" version="0.4.2" />));
fireEvent.click(await screen.findByText(/Install and restart/i));
expect(installUpdate).toHaveBeenCalled();
expect(dismissed).toContain('t1');
});
// Installing relaunches the process. The guard used to be
// `dubStep === 'generating'` alone, which let the relaunch through and
// silently discarded the work during every other long operation.
it.each([
['a dub upload', { dubStep: 'uploading' }],
['a dub transcription', { dubStep: 'transcribing' }],
['a dub synth', { dubStep: 'generating' }],
['a dub being stopped', { dubStep: 'stopping' }],
['a translation', { stage: 'translating' }],
['an ASR model load', { stage: 'loading-model' }],
['a dictation capture', { stage: 'recording' }],
['an export', { stage: 'exporting' }],
['a standalone TTS synth', { ttsInflight: 1 }],
['a voice preview overlapping another synth', { ttsInflight: 2 }],
])('refuses to restart during %s', async (_label, state) => {
Object.assign(storeState, state);
render(withI18n(<UpdateToastBody id="t1" version="0.4.2" />));
fireEvent.click(await screen.findByText(/Install and restart/i));
expect(installUpdate).not.toHaveBeenCalled();
});
// The mirror image: a guard that never lets go is just a broken updater.
it.each([
['nothing is running', { dubStep: 'idle', stage: 'idle', ttsInflight: 0 }],
['the transcript is open for editing', { dubStep: 'editing' }],
['a dub has finished', { dubStep: 'done' }],
])('still installs when %s', async (_label, state) => {
Object.assign(storeState, state);
render(withI18n(<UpdateToastBody id="t1" version="0.4.2" />));
fireEvent.click(await screen.findByText(/Install and restart/i));
expect(installUpdate).toHaveBeenCalled();
});
it('Later removes it without installing', async () => {
render(withI18n(<UpdateToastBody id="t1" version="0.4.2" />));
fireEvent.click(await screen.findByText(/Later/i));
expect(dismissed).toContain('t1');
expect(installUpdate).not.toHaveBeenCalled();
});
it('renders no release notes inline, however long they are', () => {
const { container } = render(withI18n(<UpdateToastBody id="t1" version="0.4.2" />));
// The 42-bullet changelog is what made the old dialog unusable. The toast
// takes no notes prop at all this asserts the shape stays that way.
expect(container.textContent).not.toMatch(/Highlights|### |^- /m);
expect(container.textContent.length).toBeLessThan(200);
});
});
+53
View File
@@ -0,0 +1,53 @@
/**
* `isAppBusy` decides whether it is safe to throw away the running process.
*
* The bug it replaces: the updater asked `dubStep === 'generating'` and nothing
* else, so installing an update relaunched the app discarding the work
* during a dub upload, a transcription, a translation, an export, or a
* standalone synth. Two copies of that check existed and could drift.
*
* These cases are the contract. Adding a long-running operation to the app
* means adding it here too.
*/
import { describe, it, expect } from 'vitest';
import { isAppBusy } from '../utils/appBusy';
const idle = { dubStep: 'idle', stage: 'idle', ttsInflight: 0 };
describe('isAppBusy', () => {
it.each([
['dub upload', { dubStep: 'uploading' }],
['dub transcription', { dubStep: 'transcribing' }],
['dub synth', { dubStep: 'generating' }],
['dub being stopped', { dubStep: 'stopping' }],
['ASR model load', { stage: 'loading-model' }],
['dictation capture', { stage: 'recording' }],
['transcription', { stage: 'transcribing' }],
['translation', { stage: 'translating' }],
['pill-tracked synth', { stage: 'generating' }],
['export encode', { stage: 'exporting' }],
['refine pass', { stage: 'refining' }],
['standalone TTS synth', { ttsInflight: 1 }],
['overlapping synths', { ttsInflight: 3 }],
])('is busy during %s', (_label, state) => {
expect(isAppBusy({ ...idle, ...state })).toBe(true);
});
it.each([
['nothing running', {}],
// Waiting on the user is not an operation. Counting it would block updates
// for as long as a transcript tab stays open.
['an open transcript', { dubStep: 'editing' }],
['a finished dub', { dubStep: 'done' }],
['a finished pill', { stage: 'done' }],
['a failed pill', { stage: 'error' }],
])('is not busy with %s', (_label, state) => {
expect(isAppBusy({ ...idle, ...state })).toBe(false);
});
it('treats a missing state as not busy rather than throwing', () => {
expect(isAppBusy(undefined)).toBe(false);
expect(isAppBusy(null)).toBe(false);
expect(isAppBusy({})).toBe(false);
});
});
@@ -61,3 +61,67 @@ describe('toastErrorWithReport user-fixable marker mapping (#1188)', () => {
}
});
});
// #1276: a shutdown is not a fault and must not offer a bug report. Matched on
// the [shutting_down] MARKER, never on the bare 503 status 503 is also how a
// real engine-load timeout and an unavailable engine are reported (#1246,
// #1260, #1277), and those are genuine bugs users need to be able to file.
describe('toastErrorWithReport shutdown handling (#1276)', () => {
beforeEach(() => {
toastErrorMock.mockClear();
});
const withStatus = (message, status) => {
const e = new Error(message);
e.name = 'ApiError';
e.status = status;
return e;
};
const SHUTDOWN =
"[shutting_down] OmniVoice is shutting down, so it didn't start loading " +
'the model. Reopen the app and try again.';
it('shows localized guidance for the shutdown marker, with no Report action', () => {
const err = withStatus(SHUTDOWN, 503);
toastErrorWithReport(err.message, err);
expect(toastErrorMock).toHaveBeenCalledTimes(1);
expect(toastErrorMock).toHaveBeenCalledWith('t:errors.backend_shutting_down', {
duration: 8000,
});
});
// The regression this exists to prevent: an earlier version of the fix keyed
// off the 503 status alone, which would have removed the Report button from
// the compute-timeout class the exact bug #1277 was filed for.
it('still offers Report for a 503 that is a real failure', () => {
const err = withStatus(
'503 Service Unavailable: TTS generate ran for more than 300s of actual ' +
'compute time and was abandoned',
503,
);
toastErrorWithReport(err.message, err);
expect(toastErrorMock).toHaveBeenCalledTimes(1);
// A render function, not a string the reportable path.
expect(typeof toastErrorMock.mock.calls[0][0]).toBe('function');
});
it('still offers Report for an engine-unavailable 503', () => {
const err = withStatus("503 Service Unavailable: TTS engine 'xtts' is unavailable", 503);
toastErrorWithReport(err.message, err);
expect(typeof toastErrorMock.mock.calls[0][0]).toBe('function');
});
it('still offers Report for a genuine 500', () => {
const err = withStatus('500 Internal Server Error: something actually broke', 500);
toastErrorWithReport(err.message, err);
expect(typeof toastErrorMock.mock.calls[0][0]).toBe('function');
});
it('still offers Report when there is no status at all', () => {
toastErrorWithReport('Something broke', new Error('Something broke'));
expect(typeof toastErrorMock.mock.calls[0][0]).toBe('function');
});
});
+111
View File
@@ -0,0 +1,111 @@
/**
* #1223 "Backend died (exit code 1)" when port 3900 was already taken.
*
* The backend log said:
* ERROR: [Errno 10048] error while attempting to bind on address
* ('127.0.0.1', 3900): обычно разрешается только одно использование адреса
*
* Two reasons the user got nothing useful:
*
* - `detectHints` only matched /port.*in use|address.*in use/. Windows'
* WSAEADDRINUSE wording ("only one usage of each socket address is normally
* permitted") contains NEITHER phrase and Windows translates it into the
* user's locale, so no English phrase can be relied on at all. The correct
* hint string existed in en.json and was simply unreachable on Windows.
* - `crashCauseHint` had no branch for it, so a port conflict was described
* with the small-GPU VRAM guidance.
*
* Both are pinned here on the locale-independent signals: the errno and the
* backend's dedicated exit code.
*/
import { describe, it, expect } from 'vitest';
import { detectHints } from '../components/BootstrapSplash';
import { crashCauseHint } from '../utils/backendCrash';
const line = (s) => [{ line: s }];
describe('detectHints — port-in-use (#1223)', () => {
it('matches the Windows errno even when the message is localised', () => {
// The reporter's actual log line, Russian text and all.
const russian =
"ERROR: [Errno 10048] error while attempting to bind on address ('127.0.0.1', 3900): " +
'обычно разрешается только одно использование адреса сокета';
expect(detectHints('', line(russian))).toContain('bootstrap.hint_port');
});
it('matches the English Windows wording', () => {
const english =
"[Errno 10048] error while attempting to bind on address ('127.0.0.1', 3900): " +
'only one usage of each socket address (protocol/network address/port) is normally permitted';
expect(detectHints('', line(english))).toContain('bootstrap.hint_port');
});
it.each([
['macOS/BSD', "[Errno 48] error while attempting to bind on address ('127.0.0.1', 3900)"],
['Linux', "[Errno 98] error while attempting to bind on address ('127.0.0.1', 3900)"],
])('matches the %s errno', (_os, msg) => {
expect(detectHints('', line(msg))).toContain('bootstrap.hint_port');
});
it("matches the backend's dedicated exit code with no log at all", () => {
// The crash path where stderr was never captured — the exit code is the
// only signal left.
expect(detectHints('Backend process exited (exit code 78)')).toContain('bootstrap.hint_port');
});
it('still matches the pre-existing English phrasings', () => {
expect(detectHints('', line('address already in use'))).toContain('bootstrap.hint_port');
expect(detectHints('', line('Port 3900 is in use'))).toContain('bootstrap.hint_port');
});
it('does not fire on unrelated failures', () => {
expect(detectHints('', line('uv sync failed'))).not.toContain('bootstrap.hint_port');
// The generic fallback must still be the only hint here.
expect(detectHints('something else entirely')).toEqual(['bootstrap.hint_default']);
});
});
describe('crashCauseHint — port conflict is not a memory problem (#1223)', () => {
it('explains the port conflict for the dedicated exit code', () => {
const hint = crashCauseHint({ exit_code: 78, signal: null });
expect(hint).toMatch(/port 3900 is already in use/i);
expect(hint).not.toMatch(/VRAM|RAM/);
});
it('leaves the OS-OOM branch alone', () => {
expect(crashCauseHint({ exit_code: null, signal: 9 })).toMatch(/ran out of/i);
});
it('leaves the default VRAM branch alone', () => {
expect(crashCauseHint({ exit_code: 1, signal: null })).toMatch(/VRAM/);
});
});
describe('the Rust failure messages reach the localised hint (#1223)', () => {
// These are the two BootstrapStage::Failed messages bootstrap.rs emits when
// it cannot free the port. They are English (as every Rust-side failure
// message is), but they only need to be MATCHABLE: detectHints turns them
// into `bootstrap.hint_port`, which IS localised. If either message is
// reworded so the matcher misses it, the user loses the translated guidance
// — which is exactly the #1223 failure mode, one layer up.
it.each([
[
'take-ownership',
'Port 3900 is already in use by another application, and OmniVoice could not free it. ' +
'Quit whatever is using that port (another copy of OmniVoice, or an app that claimed it) ' +
'and try again.',
],
[
'respawn',
'Port 3900 is still in use by another application and OmniVoice could not free it, so the ' +
"backend can't restart. Quit whatever is using that port and relaunch.",
],
[
'early-exit',
'Port 3900 is already in use, so the backend could not start. Another copy of OmniVoice — ' +
'or an app that claimed that port — is holding it.',
],
])('%s message maps to the localised port hint', (_which, message) => {
expect(detectHints(message)).toContain('bootstrap.hint_port');
});
});
+113
View File
@@ -0,0 +1,113 @@
/**
* Every synth path must register as "in flight" for as long as it runs, so the
* updater can't relaunch the process and discard it.
*
* Two ways the earlier version of this got it wrong:
*
* 1. It was tracked in useTTS, so the Generate tab counted but voice
* previews, the compare modal, the stories editor and profile previews
* all of which call generateSpeech directly did not.
* 2. It was a boolean, so two overlapping syntheses cleared each other:
* whichever settled first said "nothing is running" while the other was
* still going.
*
* Both are fixed by counting, at the one call they all share.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
const { apiFetch, state } = vi.hoisted(() => ({
apiFetch: vi.fn(),
state: { ttsInflight: 0 },
}));
vi.mock('../api/client', () => ({
API: '',
apiUrl: (p) => p,
apiFetch: (...a) => apiFetch(...a),
apiJson: vi.fn(),
}));
vi.mock('../store', () => ({
useAppStore: {
getState: () => ({
...state,
addTtsInflight: (d) => {
state.ttsInflight = Math.max(0, state.ttsInflight + d);
},
}),
},
}));
import { generateSpeech } from '../api/generate';
import { isAppBusy } from '../utils/appBusy';
import { createGenerateSlice } from '../store/generateSlice';
beforeEach(() => {
state.ttsInflight = 0;
apiFetch.mockReset();
});
describe('synth in-flight tracking', () => {
it('counts a synth as busy for as long as the request runs', async () => {
let release;
apiFetch.mockReturnValue(
new Promise((res) => {
release = () => res({ ok: true });
}),
);
const p = generateSpeech(new FormData());
expect(isAppBusy(state)).toBe(true);
release();
await p;
expect(isAppBusy(state)).toBe(false);
});
it('survives overlapping syntheses — the first to finish does not clear the rest', async () => {
const releases = [];
apiFetch.mockImplementation(() => new Promise((res) => releases.push(() => res({ ok: true }))));
const a = generateSpeech(new FormData()); // Generate tab
const b = generateSpeech(new FormData()); // a voice preview alongside it
expect(state.ttsInflight).toBe(2);
releases[0]();
await a;
// b is still synthesising — a relaunch here would discard it.
expect(isAppBusy(state)).toBe(true);
releases[1]();
await b;
expect(isAppBusy(state)).toBe(false);
});
it('releases on failure and on abort, not just on success', async () => {
apiFetch.mockRejectedValueOnce(new Error('network died'));
await expect(generateSpeech(new FormData())).rejects.toThrow('network died');
expect(isAppBusy(state)).toBe(false);
const abort = new DOMException('aborted', 'AbortError');
apiFetch.mockRejectedValueOnce(abort);
await expect(generateSpeech(new FormData())).rejects.toThrow();
expect(isAppBusy(state)).toBe(false);
});
// Against the real slice, not a stand-in: an unbalanced release must not
// drive the count below zero, or a later synth would start already "idle".
it('never goes negative', () => {
let s = { ttsInflight: 0 };
const set = (fn) => {
s = { ...s, ...fn(s) };
};
const slice = createGenerateSlice(set, () => s, {});
slice.addTtsInflight(-1);
slice.addTtsInflight(-5);
expect(s.ttsInflight).toBe(0);
slice.addTtsInflight(1);
expect(s.ttsInflight).toBe(1);
expect(isAppBusy(s)).toBe(true);
});
});
@@ -0,0 +1,94 @@
/**
* The Install button in Settings Updates must be greyed out while work is
* running and, just as importantly, must NOT be greyed out otherwise.
*
* Installing relaunches the process, so a click during a synth or a dub throws
* that work away. The click handler is the safety check (work can start
* between the last render and the click), but the disabled state is what tells
* the user why the button won't do anything.
*
* The failure mode this pins is the expensive one: a busy predicate that is
* accidentally always true makes the app permanently un-updatable, which is
* far worse than the bug it was added to fix.
*/
import React from 'react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen } from '@testing-library/react';
const { storeState, installUpdate } = vi.hoisted(() => ({
storeState: {},
installUpdate: vi.fn(),
}));
const BASE = {
updateStatus: 'available',
updateVersion: '0.4.2',
updateNotes: null,
updateError: null,
updateProgress: 0,
appVersion: '0.4.1',
updateChannel: 'stable',
releases: [],
releasesStatus: 'idle',
loadReleases: vi.fn(),
dismissUpdate: vi.fn(),
setWhatsNewSeenVersion: vi.fn(),
dubStep: 'idle',
stage: 'idle',
ttsInflight: 0,
};
vi.mock('../store', () => ({
useAppStore: Object.assign((sel) => sel(storeState), { getState: () => storeState }),
}));
vi.mock('../utils/updater', () => ({
installUpdate: (...a) => installUpdate(...a),
checkForUpdate: vi.fn(),
}));
vi.mock('../utils/updatesApi', () => ({
fetchChangelog: () => Promise.resolve([]),
fetchBackupState: () => Promise.resolve(null),
}));
vi.mock('../utils/channelControl', () => ({ setChannel: vi.fn() }));
vi.mock('../utils/updatePresentation', () => ({ prepareReleases: () => [] }));
vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (k) => k }) }));
vi.mock('react-hot-toast', () => ({ default: Object.assign(vi.fn(), { error: vi.fn() }) }));
import UpdatesPanel from '../components/UpdatesPanel';
const renderWith = (over) => {
Object.keys(storeState).forEach((k) => delete storeState[k]);
Object.assign(storeState, BASE, over);
return render(<UpdatesPanel />);
};
beforeEach(() => installUpdate.mockClear());
describe('UpdatesPanel install button', () => {
it('is enabled when nothing is running', () => {
renderWith({});
expect(screen.getByRole('button', { name: /update\.install/ })).not.toBeDisabled();
});
it.each([
['a dub upload', { dubStep: 'uploading' }],
['a translation', { stage: 'translating' }],
['a synth', { ttsInflight: 1 }],
['overlapping synths', { ttsInflight: 2 }],
])('is disabled during %s', (_label, over) => {
renderWith(over);
expect(screen.getByRole('button', { name: /update\.install/ })).toBeDisabled();
});
it('disables the restart button too, not just install', () => {
renderWith({ updateStatus: 'ready', ttsInflight: 1 });
expect(screen.getByRole('button', { name: /update\.restart/ })).toBeDisabled();
});
it('an open transcript does not block updating', () => {
// 'editing' waits on the user; treating it as busy would make the app
// un-updatable for as long as a dub tab stays open.
renderWith({ dubStep: 'editing' });
expect(screen.getByRole('button', { name: /update\.install/ })).not.toBeDisabled();
});
});
+205
View File
@@ -0,0 +1,205 @@
/**
* #1245: the app was dead on arrival on macOS 12 (Monterey).
*
* The reporter's whole session was one line `view:launchpad` and
* "Last backend response: none this session — it may never have started".
* The stack is a React render throwing:
*
* AbortSignal.timeout is not a function.
* (In 'AbortSignal.timeout(2e3)', 'AbortSignal.timeout' is undefined)
*
* `useRealtimeEvents` polls backend health with `AbortSignal.timeout(2000)`
* on mount. That method arrived in Safari 16.0; `tauri.conf.json` declares
* `minimumSystemVersion: "12.0"` and `docs/install/macos.md` promises macOS 12,
* which ships WKWebView **15.6**. So this was not an exotic environment it
* was the floor we advertise.
*
* Two halves are pinned:
* 1. the polyfill behaves like the real thing (aborts, and with TimeoutError);
* 2. no app module reaches for a post-floor API that nothing fills in the
* whole class, not just the one method that got reported.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
import { installAbortSignalTimeout } from '../utils/webCompat.js';
const SRC = path.resolve(__dirname, '..');
describe('AbortSignal.timeout polyfill', () => {
let original;
beforeEach(() => {
vi.useFakeTimers();
original = AbortSignal.timeout;
});
afterEach(() => {
vi.useRealTimers();
AbortSignal.timeout = original;
});
it('fills the method in when the WebView lacks it', () => {
// Reproduce Monterey: the method is simply absent.
delete AbortSignal.timeout;
expect(AbortSignal.timeout).toBeUndefined();
installAbortSignalTimeout();
expect(typeof AbortSignal.timeout).toBe('function');
});
it('aborts after the delay, with a TimeoutError reason', () => {
delete AbortSignal.timeout;
installAbortSignalTimeout();
const signal = AbortSignal.timeout(2000);
expect(signal.aborted).toBe(false);
vi.advanceTimersByTime(1999);
expect(signal.aborted).toBe(false);
vi.advanceTimersByTime(1);
expect(signal.aborted).toBe(true);
// `reason` is how a caller tells a timeout apart from a user cancel.
expect(signal.reason?.name).toBe('TimeoutError');
});
it('does not clobber a native implementation', () => {
const native = vi.fn(() => new AbortController().signal);
AbortSignal.timeout = native;
installAbortSignalTimeout();
expect(AbortSignal.timeout).toBe(native);
});
it('is installed before the app boots', () => {
// The gap fill is worthless if it loads after the chunk that throws.
const main = fs.readFileSync(path.join(SRC, 'main.jsx'), 'utf8');
const compat = main.indexOf('utils/webCompat');
const app = main.indexOf('main-app.jsx');
expect(compat).toBeGreaterThan(-1);
expect(app).toBeGreaterThan(-1);
expect(compat).toBeLessThan(app);
});
});
/**
* The recurrence guard. `AbortSignal.timeout` was not special it was
* whichever post-floor API we happened to reach for first. Each entry is an
* API that does NOT exist in Safari 15.6 (the WebView shipped with the macOS
* version `tauri.conf.json` declares) and that `webCompat.js` does not fill
* in, so using one would break Monterey exactly the way #1245 did.
*
* To use one of these: either fill it in inside `webCompat.js` and delete the
* entry, or guard the call site with a runtime check and a fallback.
*
* WHAT THIS DOES NOT COVER deliberately stated so it is not over-trusted:
*
* - **Syntax.** A post-floor *grammar* feature (RegExp lookbehind, Safari
* 16.4) is a parse-time SyntaxError that kills its whole chunk before a
* line runs, and no polyfill can help. Text patterns here cannot see that.
* - **Dependencies.** Only `frontend/src` is scanned. A bundled library using
* a post-floor API or grammar is invisible here and ships anyway.
* - **CSS.** Tailwind v4's own floor is Safari 16.4, and `index.css` uses
* `color-mix()` (16.2) throughout, so the *rendering* floor is already
* above macOS 12 regardless of what JavaScript does. See #1268.
* - **Computed access.** `AbortSignal["timeout"]` or a destructured
* reference evades every pattern below.
*
* This guard closes the class it can see first-party source. The rest is
* tracked in #1268 rather than pretended away.
*/
const POST_FLOOR_APIS = [
// Safari 18.0
{ pattern: /\bURL\s*\.\s*parse\s*\(/, name: 'URL.parse', since: 'Safari 18.0' },
// Safari 17.4
{ pattern: /\bAbortSignal\s*\.\s*any\b/, name: 'AbortSignal.any', since: 'Safari 17.4' },
{ pattern: /\bObject\s*\.\s*groupBy\b/, name: 'Object.groupBy', since: 'Safari 17.4' },
{ pattern: /\bMap\s*\.\s*groupBy\b/, name: 'Map.groupBy', since: 'Safari 17.4' },
{
pattern: /\bPromise\s*\.\s*withResolvers\b/,
name: 'Promise.withResolvers',
since: 'Safari 17.4',
},
{ pattern: /\.checkVisibility\s*\(/, name: 'Element#checkVisibility', since: 'Safari 17.4' },
// Safari 17.0
{ pattern: /\bURL\s*\.\s*canParse\b/, name: 'URL.canParse', since: 'Safari 17.0' },
// Safari 16.4
{ pattern: /\.isWellFormed\s*\(/, name: 'String#isWellFormed', since: 'Safari 16.4' },
{ pattern: /\.toWellFormed\s*\(/, name: 'String#toWellFormed', since: 'Safari 16.4' },
{ pattern: /\bArray\s*\.\s*fromAsync\b/, name: 'Array.fromAsync', since: 'Safari 16.4' },
// Safari 16.0 — the change-by-copy family. `with` is the one most likely to
// be reached for in React state code (`arr.with(i, v)`), and it was missing
// from this list until review caught it.
{ pattern: /\.toSorted\s*\(/, name: 'Array#toSorted', since: 'Safari 16.0' },
{ pattern: /\.toReversed\s*\(/, name: 'Array#toReversed', since: 'Safari 16.0' },
{ pattern: /\.toSpliced\s*\(/, name: 'Array#toSpliced', since: 'Safari 16.0' },
// `arr.with(i, v)` is the idiomatic immutable-state form in React code, so
// it is the sibling most likely to be reached for. It was missing from this
// list until review caught it.
{ pattern: /\.with\s*\(/, name: 'Array#with', since: 'Safari 16.0' },
{ pattern: /\bAbortSignal\s*\.\s*timeout\b/, name: 'AbortSignal.timeout', since: 'Safari 16.0' },
];
/** Entries `webCompat.js` fills in, so app code may use them freely. */
const POLYFILLED = new Set(['AbortSignal.timeout']);
const walk = (dir, out = []) => {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
if (entry.name === 'test' || entry.name === 'node_modules') continue;
walk(full, out);
} else if (/\.(js|jsx|ts|tsx)$/.test(entry.name)) {
out.push(full);
}
}
return out;
};
describe('no app code depends on an API newer than the macOS floor', () => {
it('is written against the macOS version tauri.conf.json declares', () => {
const conf = JSON.parse(
fs.readFileSync(path.resolve(SRC, '..', 'src-tauri', 'tauri.conf.json'), 'utf8'),
);
// The list above is derived from the WebView that ships with this macOS
// version (12.0 → WKWebView 15.6). If the floor moves, re-derive it — the
// test is only as correct as the version it is written against.
//
// Note this asserts what we DECLARE, not what we deliver: the CSS layer
// (Tailwind v4, color-mix) needs Safari 16.4, so 12.0 is currently a
// promise the frontend stack does not keep. Tracked in #1268; asserted
// here so raising the floor forces this list to be revisited.
expect(conf.bundle?.macOS?.minimumSystemVersion).toBe('12.0');
});
it('uses no unfilled post-Safari-15.6 API', () => {
const offenders = [];
for (const file of walk(SRC)) {
if (file.endsWith(path.join('utils', 'webCompat.js'))) continue;
const src = fs.readFileSync(file, 'utf8');
for (const api of POST_FLOOR_APIS) {
if (POLYFILLED.has(api.name)) continue;
if (api.pattern.test(src)) {
offenders.push(`${path.relative(SRC, file)} uses ${api.name} (${api.since})`);
}
}
}
expect(offenders).toEqual([]);
});
it('still guards the API that broke Monterey', () => {
// Sanity: AbortSignal.timeout IS used by app code, so the exemption above
// is load-bearing — if the polyfill is deleted the exemption must go too.
const users = walk(SRC).filter(
(f) =>
!f.endsWith(path.join('utils', 'webCompat.js')) &&
/\bAbortSignal\s*\.\s*timeout\b/.test(fs.readFileSync(f, 'utf8')),
);
expect(users.length).toBeGreaterThan(0);
const compat = fs.readFileSync(path.join(SRC, 'utils', 'webCompat.js'), 'utf8');
expect(compat).toMatch(/AbortSignal\.timeout\s*=/);
});
});
+62
View File
@@ -0,0 +1,62 @@
/**
* "Is long-running work in flight?" the single answer, for anything that
* would destroy that work.
*
* Today the only caller that matters is the updater: installing an update
* relaunches the process, so anything mid-flight is lost. That check used to
* be written inline as `dubStep === 'generating'`, which covered exactly one
* of the app's long operations and silently permitted a relaunch during a dub
* upload, a transcription, a translation, a standalone TTS synth, or an
* export. It was also duplicated in two places, so the two could drift.
*
* The signals unioned here are the ones the store actually knows about:
*
* - `dubStep` the dub workflow's own state machine
* - `stage` the floating status pill, which every long background
* operation already pushes to (ASR model load, dictation
* capture, transcribe, translate, export, refine)
* - `ttsInflight` a COUNT of synth requests in flight, maintained by
* `api/generate.ts` around the one `/generate` call every
* synth path shares (Generate tab, voice previews, the
* compare modal, the stories editor, profile previews).
* A count because those overlap: a boolean would be
* cleared by whichever finished first while the rest were
* still running.
*
* Deliberately NOT busy:
*
* - `dubStep === 'editing'` the transcript is open and waiting on the
* user, which is not an operation. Counting it would block updates for as
* long as a tab stays open, which is its own bug.
* - `'done'` / `'error'` terminal; the work already survived or didn't.
*/
/** Dub steps where work is actually running (see `DubStep` in dubSlice). */
const BUSY_DUB_STEPS = new Set(['uploading', 'transcribing', 'generating', 'stopping']);
/** Pill stages that represent an operation in progress (see `PillStage`). */
const BUSY_PILL_STAGES = new Set([
'loading-model',
'recording',
'transcribing',
'translating',
'generating',
'exporting',
'refining',
]);
/**
* True when interrupting the app right now would lose work the user cares
* about. Takes a store snapshot (`useAppStore.getState()`) rather than reading
* the store itself, so it stays usable from event handlers and from tests.
*/
export function isAppBusy(state) {
if (!state) return false;
return (
BUSY_DUB_STEPS.has(state.dubStep) ||
BUSY_PILL_STAGES.has(state.stage) ||
(state.ttsInflight ?? 0) > 0
);
}
export default isAppBusy;
+12
View File
@@ -196,6 +196,18 @@ export function describeCrashExit(
* the dominant cause for real GPU aborts.
*/
export function crashCauseHint(marker: Pick<BackendCrashMarker, 'exit_code' | 'signal'>): string {
// #1223: the backend exits 78 (EX_CONFIG) when it could not bind its port.
// That is not a crash and has nothing to do with memory — the old message
// sent a user whose real problem was a leftover process off to shrink their
// ASR model. Keep in sync with _EXIT_PORT_IN_USE in backend/main.py.
if (marker.exit_code === 78) {
return (
'The backend could not start because port 3900 is already in use — another copy of ' +
'OmniVoice (or an app that claimed that port) is holding it. Quit the other instance and ' +
'relaunch; if nothing is visibly running, an orphaned backend from a previous session is ' +
'still holding the port.'
);
}
if (marker.signal === 9) {
return (
'It was force-killed (signal 9), which usually means the operating system ran out of ' +
+13 -2
View File
@@ -14,10 +14,21 @@ import { buildBugReportUrl } from './bugReport';
// #1188: backend errors that carry a machine-readable "[code]" marker are
// user-fixable input problems, not bugs show localized guidance (what
// happened + the concrete fix) instead of the raw English detail, and skip
// the "Report" action. Markers are emitted by the backend (the
// [clone_ref_unusable] one in omnivoice/utils/audio.py) keep in sync.
// the "Report" action.
//
// #1276 adds [shutting_down]: the backend is on its way out, so nothing
// failed and there is nothing to report.
//
// Matched on the MARKER, never on the bare 503 status. 503 is also how a real
// engine-load timeout and an unavailable engine are reported (#1246, #1260,
// #1277) those are genuine bugs users need to file, and keying off the
// status alone would silence exactly that class.
//
// Markers are emitted by the backend ([clone_ref_unusable] in
// omnivoice/utils/audio.py, [shutting_down] in main.py) keep in sync.
const USER_FIXABLE_MARKERS = {
'[clone_ref_unusable]': 'tts_errors.ref_audio_unusable',
'[shutting_down]': 'errors.backend_shutting_down',
};
export function toastErrorWithReport(message, error) {
+19 -2
View File
@@ -50,8 +50,25 @@ export async function checkForUpdate(store) {
const { invoke } = await import('@tauri-apps/api/core');
const channel = await currentChannel();
const update = await invoke('check_update', { channel });
if (update) store.setUpdateAvailable(update.version, update.notes || null);
else store.setUpdateIdle();
if (update) {
store.setUpdateAvailable(update.version, update.notes || null);
// Announce it where the user is looking. The footer's version dot stays
// as the persistent, non-intrusive marker; this is the one-time nudge.
// Lazy so the toast never loads in a browser/dev build that can't update.
//
// Caught separately: a failed chunk load must not fall through to the
// outer catch, which calls setUpdateIdle() and would erase the update we
// just found. The announcement is the optional part — the available
// state (footer dot, Settings → Updates) is what has to survive.
try {
const { showUpdateToast } = await import('../components/UpdateToast');
showUpdateToast(update.version);
} catch (e) {
console.debug('Update toast failed to load (non-fatal):', e);
}
} else {
store.setUpdateIdle();
}
} catch (e) {
// Endpoint 404s until the first signed release on a channel — non-fatal.
console.debug('Update check failed (non-fatal):', e);
+55
View File
@@ -0,0 +1,55 @@
/**
* Web-platform gap fills for the OLDEST WebView we claim to support.
*
* `tauri.conf.json` declares `minimumSystemVersion: "12.0"` and the install
* docs promise macOS 12 (Monterey) which ships Safari/WKWebView **15.6**.
* Anything newer than that is not present at runtime on a supported machine,
* and because our entry chunk touches these during the first React render, a
* single missing method is not a degraded feature: it throws mid-render, the
* tree unmounts, and the user gets a dead window with no backend ever started
* (#1245).
*
* This module must be imported for side effects as the FIRST thing in
* `main.jsx`, before any app chunk loads. `test/webCompat.test.js` keeps the
* list honest: it fails CI if app code reaches for a post-15.6 API that is not
* filled in here.
*
* Windows (evergreen WebView2) and Linux (WebKitGTK 2.44 on Ubuntu 24.04+)
* both clear the floor comfortably macOS 12 is the binding constraint.
*/
/**
* `AbortSignal.timeout(ms)` Safari 16.0. Used by the backend health poll on
* the very first render, so its absence took the whole app down on Monterey.
*/
export function installAbortSignalTimeout() {
if (typeof AbortSignal === 'undefined' || typeof AbortController === 'undefined') return;
if (typeof AbortSignal.timeout === 'function') return;
AbortSignal.timeout = function timeout(ms) {
const controller = new AbortController();
setTimeout(() => {
// The spec aborts with a DOMException named TimeoutError, which is how
// callers tell "we gave up" apart from "the user cancelled". Pass it:
// `abort(reason)` has been supported since Safari 15.4, so it IS
// honoured on our 15.6 floor. Do not "simplify" this to a bare
// `controller.abort()` — that would silently turn every TimeoutError
// into an AbortError and break exactly the distinction it exists for.
let reason;
try {
reason = new DOMException('signal timed out', 'TimeoutError');
} catch {
reason = new Error('signal timed out');
reason.name = 'TimeoutError';
}
controller.abort(reason);
}, ms);
return controller.signal;
};
}
export function installWebCompat() {
installAbortSignalTimeout();
}
installWebCompat();
+16 -5
View File
@@ -154,12 +154,23 @@
"os.makedirs(os.path.join(REPO_DIR, \".venv\"), exist_ok=True)\n",
"run([sys.executable, os.path.join(REPO_DIR, \"scripts\", \"setup.py\")], what=\"cuDNN 8 compat setup\")\n",
"\n",
"# 2g. Sanity check in a fresh interpreter (this kernel may hold a stale torch)\n",
"# 2g. Sanity check in a fresh interpreter (this kernel may hold a stale torch).\n",
"# Imports the backend's own model stack, not just torch — Colab's system\n",
"# Python mixes preinstalled and freshly-resolved wheels, and a torchaudio or\n",
"# transformers that can't load together only shows up when the model module is\n",
"# imported (#1229). Catching it here beats a 5-minute health timeout in cell 5.\n",
"run([sys.executable, \"-c\",\n",
" \"import torch, torchaudio, uvicorn, fastapi; \"\n",
" \"print(f'Install OK - torch {torch.__version__}, \"\n",
" \"CUDA available: {torch.cuda.is_available()}')\"],\n",
" what=\"import sanity check\")\n"
" # Versions FIRST: if the model-stack import below fails, the cell output\n",
" # still shows what was actually installed, which is the single most\n",
" # useful line for diagnosing a Colab environment (#1229).\n",
" \"import torch, torchaudio, uvicorn, fastapi, transformers; \"\n",
" \"print(f'torch {torch.__version__}, torchaudio {torchaudio.__version__}, \"\n",
" \"transformers {transformers.__version__}, \"\n",
" \"CUDA available: {torch.cuda.is_available()}'); \"\n",
" \"from omnivoice.models.omnivoice import OmniVoice; \"\n",
" \"from transformers import HiggsAudioV2TokenizerModel; \"\n",
" \"print('Install OK - backend model stack imports cleanly')\"],\n",
" cwd=REPO_DIR, what=\"import sanity check\")\n"
]
},
{
+27 -6
View File
@@ -19,10 +19,31 @@ try:
except PackageNotFoundError:
__version__ = "0.0.0"
from omnivoice.models.omnivoice import (
OmniVoice,
OmniVoiceConfig,
OmniVoiceGenerationConfig,
)
__all__ = ["OmniVoice", "OmniVoiceConfig", "OmniVoiceGenerationConfig"]
# The model exports are resolved lazily (PEP 562). Importing them here made
# `omnivoice` an all-or-nothing package: `backend/api/routers/profiles.py` asks
# only for two pure-stdlib helpers from `omnivoice.utils.voice_design`, and got
# torch + torchaudio + transformers + the full model definition as a side
# effect. Any breakage in that stack — a torchaudio transformers can't detect,
# a flex_attention symbol a torch version doesn't have — then killed the entire
# backend at import time, before FastAPI existed to classify the error: TTS,
# dubbing, ASR and Settings all dead, with only a uvicorn traceback to go on
# (#1229). Deferred, the same breakage surfaces inside the request that
# actually needs a model, where `core.failure.classify()` attaches a repair
# hint and everything else keeps working.
#
# `from omnivoice import OmniVoice` and `omnivoice.OmniVoice` behave exactly as
# before; only the *timing* of the heavy import changes.
def __getattr__(name):
if name in __all__:
from omnivoice.models import omnivoice as _m
return getattr(_m, name)
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
def __dir__():
return sorted([*globals(), *__all__])
+35 -2
View File
@@ -46,7 +46,6 @@ from transformers import (
AutoFeatureExtractor,
AutoModel,
AutoTokenizer,
HiggsAudioV2TokenizerModel,
PretrainedConfig,
PreTrainedModel,
)
@@ -183,6 +182,40 @@ class OmniVoiceConfig(PretrainedConfig):
self.audio_codebook_weights = audio_codebook_weights
def _audio_tokenizer_cls():
"""Resolve ``transformers.HiggsAudioV2TokenizerModel`` at the point of use.
transformers exposes this class through its lazy module and gates it on the
``torchaudio`` backend, so the *attribute access* not the `transformers`
import is what raises when torchaudio is missing, ABI-mismatched with
torch, or installed without discoverable distribution metadata (Colab's
system Python, an interrupted `uv pip install`). Resolving it at module
scope made that a fatal import error for the WHOLE backend: `backend/main.py`
imports the profiles router `omnivoice` this module, so one optional
audio tokenizer took down TTS, dubbing, ASR and Settings alike, before
FastAPI existed to classify it. The user saw only uvicorn's traceback and a
"Backend did not become healthy within 5 minutes" timeout (#1229).
Deferred here, the failure lands inside a request instead, where
``core.failure.classify()`` maps it to ``TRANSFORMERS_IMPORT`` and attaches
a repair hint and every feature that doesn't need this tokenizer keeps
working.
"""
try:
from transformers import HiggsAudioV2TokenizerModel
except Exception as e:
raise ImportError(
"Could not import module 'HiggsAudioV2TokenizerModel' — OmniVoice's "
"audio tokenizer. transformers gates it on torchaudio, so this is "
"almost always a torchaudio that is missing, broken, or mismatched "
"with the installed torch/transformers. Reinstall them together "
"(`uv pip install --reinstall torch torchaudio transformers`; add "
"--system on Colab), then restart the backend. Underlying error: "
f"{type(e).__name__}: {e}"
) from e
return HiggsAudioV2TokenizerModel
def _resolve_snapshot_dir(checkpoint) -> str:
"""Local snapshot directory for ``checkpoint`` (a local dir or a HF repo id).
@@ -318,7 +351,7 @@ class OmniVoice(PreTrainedModel):
tokenizer_device = (
"cpu" if str(model.device).startswith("mps") else model.device
)
model.audio_tokenizer = HiggsAudioV2TokenizerModel.from_pretrained(
model.audio_tokenizer = _audio_tokenizer_cls().from_pretrained(
audio_tokenizer_path, device_map=tokenizer_device
)
model.feature_extractor = AutoFeatureExtractor.from_pretrained(
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "omnivoice"
version = "0.4.0"
version = "0.4.2"
description = "OmniVoice: Towards Omnilingual Zero-Shot Text-to-Speech with Diffusion Language Models"
readme = "README.md"
# Free and open-source under the GNU Affero General Public License v3 (see
@@ -168,6 +168,11 @@ def test_list_backends_shape(registry_sandbox):
# True when services.sidecar_install can provision the engine in-app
# (the Settings Install button keys off this).
"one_click_install",
# Approximate VRAM (GB) the engine wants on a dedicated GPU, or None
# when it declares no measured floor (#1226). Advisory metadata: a host
# below the floor gets a caveat in `routing_reason` BEFORE it spends
# the full compute budget finding out its card is too small.
"min_vram_gb",
}
mlx_audio_extra = {"curated_models", "active_model_id"}
for entry in out:
+156
View File
@@ -0,0 +1,156 @@
"""#1262: a non-Latin voice-profile name 500'd every download endpoint.
500 Internal Server Error: 'latin-1' codec can't encode characters in
position 22-25: ordinal not in range(256)
``attachment; filename="`` is exactly 22 characters long, so positions 22-25
were the first four characters of the reporter's own profile name. HTTP header
values are latin-1 by definition; every download endpoint interpolated the
filename straight into the header.
The sanitisers in front of those f-strings looked like they covered it but
they all filtered with ``str.isalnum()``, which is ``True`` for *every*
alphabetic script. They stripped punctuation and let through exactly the
characters that break the header.
This was never one endpoint: the same ``isalnum()`` idiom was copy-pasted
across persona export, marketplace, stories, the OpenAI-compatible speech
route, and eight dub-export routes. All of them now go through one RFC 6266
builder, and a guard below keeps the next one from being written by hand.
"""
from __future__ import annotations
import pathlib
import re
import pytest
from core.http_headers import ascii_filename, content_disposition
REPO = pathlib.Path(__file__).resolve().parents[1]
# ── the header is always encodable ───────────────────────────────────────
@pytest.mark.parametrize(
"name",
[
"我的声音.ovsvoice", # Chinese — the reported shape
"私の声.ovsvoice", # Japanese
"내 목소리.ovsvoice", # Korean
"Моя речь.ovsvoice", # Cyrillic
"φωνή.ovsvoice", # Greek
"קול.ovsvoice", # Hebrew
"🎙️ voice.ovsvoice", # emoji
"Sébastiens voix.ovsvoice", # accented Latin + smart quote
],
)
def test_the_header_survives_any_script(name):
header = content_disposition(name)
# The actual failure: Starlette encodes header values as latin-1.
header.encode("latin-1")
def test_the_exact_reported_failure():
"""A four-character CJK name — the one that produced 'position 22-25'."""
header = content_disposition("我的声音.ovsvoice")
header.encode("latin-1")
assert 'filename="' in header
assert "filename*=UTF-8''" in header
def test_the_real_name_is_preserved_for_modern_clients():
header = content_disposition("我的声音.ovsvoice")
# RFC 5987 percent-encoded UTF-8 — browsers prefer this over `filename=`.
assert "%E6%88%91%E7%9A%84%E5%A3%B0%E9%9F%B3" in header
def test_accented_latin_is_folded_not_deleted():
assert ascii_filename("Sébastien.ovsvoice") == "Sebastien.ovsvoice"
def test_an_entirely_non_ascii_name_still_yields_a_usable_filename():
"""Stripping CJK leaves only ".ovsvoice", which is not a filename."""
safe = ascii_filename("我的声音.ovsvoice")
assert safe.endswith(".ovsvoice")
stem = safe[: -len(".ovsvoice")]
assert stem and stem.strip("_ "), f"no usable stem in {safe!r}"
def test_ascii_names_are_left_alone():
assert ascii_filename("dubbed_output_en.mp4") == "dubbed_output_en.mp4"
header = content_disposition("dubbed_output_en.mp4")
assert 'filename="dubbed_output_en.mp4"' in header
@pytest.mark.parametrize("hostile", ['a"b.mp4', "a\\b.mp4", "a\r\nX-Evil: 1.mp4", "a/b/c.mp4"])
def test_quoting_and_header_injection_are_neutralised(hostile):
"""A dub filename comes from a video title, i.e. from the internet. A bare
quote would end the quoted-string; a CRLF would split the header."""
header = content_disposition(hostile)
header.encode("latin-1")
assert "\r" not in header and "\n" not in header
# Exactly the two parameters we intend, no smuggled third.
assert header.count("filename=") == 1
assert header.count("filename*=") == 1
def test_inline_disposition_is_supported():
"""The OpenAI-compatible speech route streams inline, not as a download."""
assert content_disposition("speech.mp3", disposition="inline").startswith("inline;")
# ── and no endpoint builds the header by hand again ──────────────────────
def test_no_router_interpolates_a_filename_into_the_header():
"""The recurrence guard. This bug shipped in ten places because the header
was written by f-string ten times; the eleventh must not compile."""
offenders = []
pattern = re.compile(r'"Content-Disposition"\s*:\s*f[\'"]')
for path in (REPO / "backend").rglob("*.py"):
if "test" in path.parts:
continue
for i, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
if pattern.search(line):
offenders.append(f"{path.relative_to(REPO)}:{i}")
assert offenders == [], (
"build the value with core.http_headers.content_disposition() — an "
"f-string here 500s on any non-latin-1 filename (#1262)"
)
def test_every_download_endpoint_actually_uses_the_builder():
"""Complements the guard above: proves the call sites were converted, not
merely reworded into something the regex misses."""
routers = REPO / "backend" / "api" / "routers"
users = {
path.name
for path in routers.rglob("*.py")
if "content_disposition(" in path.read_text(encoding="utf-8")
}
for expected in (
"personas.py",
"marketplace.py",
"stories.py",
"dub_export.py",
"openai_compat.py",
):
assert expected in users, f"{expected} still builds the header itself"
def test_a_hostile_custom_fallback_cannot_reach_the_header():
"""Review finding (#1262): `fallback` landed in `filename=` verbatim
whenever the real name folded away entirely, so a non-ASCII or CRLF
fallback walked past every guard the real name goes through."""
header = content_disposition("我的声音.ovsvoice", fallback='ev"il\r\nX-Evil: 1')
header.encode("latin-1")
assert "\r" not in header and "\n" not in header
assert header.count("filename=") == 1
assert header.count("filename*=") == 1
# A fallback that is ENTIRELY non-ASCII must still leave a usable name.
header = content_disposition("我的声音.ovsvoice", fallback="声音")
header.encode("latin-1")
assert 'filename=""' not in header
+192
View File
@@ -0,0 +1,192 @@
"""#1225: `download: Unable to download video: [Errno 22] Invalid argument`.
A Windows user hit this on three consecutive URL ingests. Two things made it a
dead end:
* `classify()` matched the generic `"errno 22"` rule (#763, written for the
ASR 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.
* The message named neither the target nor the reason, so nothing in it could
distinguish a full drive from a read-only folder from an antivirus lock.
These tests pin the download-specific class, that the ASR path keeps its own,
and the destination facts now attached to the failure.
"""
from __future__ import annotations
import os
import pytest
from core.failure import _HINTS, build_failure, classify, describe_path_target
from services import dub_pipeline
class _ReachedYtDlp(Exception):
"""Raised in place of any real yt-dlp call — no test here may hit the
network, and a test that silently does is a test that stopped testing."""
def _block_network(monkeypatch):
import yt_dlp
def _explode(*_a, **_k):
raise _ReachedYtDlp("yt-dlp was invoked")
monkeypatch.setattr(dub_pipeline, "find_ffmpeg", lambda: None)
monkeypatch.setattr(yt_dlp, "YoutubeDL", _explode)
# ── classification ───────────────────────────────────────────────────────
def test_download_errno22_is_not_given_the_transcribe_hint():
reason = "Unable to download video: [Errno 22] Invalid argument"
assert classify(reason) == "VIDEO_DOWNLOAD_OS_ERROR"
fields = build_failure(OSError(reason), stage="download")
assert "TEMP" not in fields["hint"]
assert "data directory" in fields["hint"]
def test_transcribe_errno22_keeps_its_own_class():
"""#763's class must not be swallowed by the new download rule."""
assert classify("[Errno 22] Invalid argument") == "OS_INVALID_ARGUMENT"
assert "TEMP" in _HINTS["OS_INVALID_ARGUMENT"]
@pytest.mark.parametrize(
"reason",
[
"ERROR: unable to open for writing: [Errno 13] Permission denied",
"yt_dlp.utils.DownloadError: unable to rename file: [Errno 22] Invalid argument",
],
)
def test_other_download_write_failures_share_the_class(reason):
assert classify(reason) == "VIDEO_DOWNLOAD_OS_ERROR"
def test_a_network_download_failure_is_still_network():
"""The new rule must not steal transient failures — those DO retry."""
reason = "Unable to download video: Connection reset by peer"
assert classify(reason) == "VIDEO_DOWNLOAD_NETWORK"
assert dub_pipeline._is_transient_download_error(OSError(reason))
# ── destination diagnosis ────────────────────────────────────────────────
def test_describe_path_target_reports_free_space(tmp_path):
facts = describe_path_target(str(tmp_path / "original.mp4"))
assert "MB free" in facts
assert "does not exist" not in facts
def test_describe_path_target_flags_a_missing_folder(tmp_path):
facts = describe_path_target(str(tmp_path / "gone" / "original.mp4"))
assert "does not exist" in facts
def test_describe_path_target_never_raises():
assert isinstance(describe_path_target("\x00not-a-path"), str)
# ── the failure carries the destination ─────────────────────────────────
def test_os_error_gains_the_destination_facts(tmp_path):
exc = OSError("Unable to download video: [Errno 22] Invalid argument")
described = dub_pipeline._with_target_facts(exc, str(tmp_path))
msg = str(described)
assert str(tmp_path) in msg
assert "MB free" in msg
assert "retrying the same link won't help" in msg
# Still classifies as the download class — the yt-dlp wording is preserved.
assert classify(msg) == "VIDEO_DOWNLOAD_OS_ERROR"
def test_network_failures_are_left_alone(tmp_path):
exc = OSError("Unable to download video: Connection reset by peer")
assert dub_pipeline._with_target_facts(exc, str(tmp_path)) is exc
def test_exception_types_that_reject_a_message_still_get_the_text(tmp_path):
import soundfile as sf
described = dub_pipeline._with_target_facts(
sf.LibsndfileError(1), str(tmp_path)
)
# LibsndfileError takes an int code — the helper must not build one whose
# str() raises. Either it declined to enrich, or it fell back to a type
# that works; both are fine, an unprintable exception is not.
assert isinstance(str(described), str)
def test_enoent_download_failure_also_gets_the_destination_facts(tmp_path):
"""Review finding (#1225): classify() covered ENOENT but the enrichment
gate did not, so a job folder that vanished after preflight produced a
disk-classified error that never named the folder the one fact that
makes it actionable. Both now read the same signature list."""
exc = OSError("Unable to download video: [Errno 2] No such file or directory")
described = dub_pipeline._with_target_facts(exc, str(tmp_path))
assert str(tmp_path) in str(described)
assert classify(str(described)) == "VIDEO_DOWNLOAD_OS_ERROR"
def test_the_two_consumers_share_one_signature_list():
"""A drift between "is this a disk problem?" (classify) and "should we name
the folder?" (_with_target_facts) is what produced the finding above."""
from core.failure import is_os_write_refusal
for reason in (
"Unable to download video: [Errno 2] No such file or directory",
"Unable to download video: [Errno 22] Invalid argument",
"ERROR: unable to open for writing: [Errno 13] Permission denied",
"Unable to download video: [Errno 28] No space left on device",
):
assert is_os_write_refusal(reason), reason
assert classify(reason) == "VIDEO_DOWNLOAD_OS_ERROR", reason
assert not is_os_write_refusal("Unable to download video: Connection reset by peer")
def test_unwritable_destination_fails_before_yt_dlp_runs(tmp_path, monkeypatch):
"""The preflight: don't start a download into a folder we can already see
won't take the file."""
job_dir = tmp_path / "job"
job_dir.mkdir()
# Patch the module object dub_pipeline actually holds — a string-path
# patch of "core.failure…" misses it if anything in the suite reloaded
# the module, and the test then falls through to a REAL network call.
monkeypatch.setattr(
dub_pipeline.failure, "describe_path_target",
lambda _p: "the folder is not writable",
)
_block_network(monkeypatch)
with pytest.raises(OSError) as excinfo:
dub_pipeline.yt_download_sync("https://example.com/v", str(job_dir))
msg = str(excinfo.value)
assert str(job_dir) in msg
assert "not writable" in msg
# Review finding (#1225): the preflight message classified as NOTHING, so
# the user got no hint at all — the very failure mode this PR fixes. It
# must carry both an OS-refusal signature and download context.
assert classify(msg) == "VIDEO_DOWNLOAD_OS_ERROR"
assert "data directory" in build_failure(excinfo.value, stage="download")["hint"]
def test_preflight_lets_a_healthy_folder_through(tmp_path, monkeypatch):
"""A writable folder must not be blocked — the preflight only rejects what
it can positively see is broken."""
job_dir = tmp_path / "job"
job_dir.mkdir()
_block_network(monkeypatch)
# Reaching yt-dlp IS the pass condition: the preflight let it through.
with pytest.raises(_ReachedYtDlp):
dub_pipeline.yt_download_sync("https://example.com/v", str(job_dir))
assert os.path.isdir(job_dir)
+540
View File
@@ -0,0 +1,540 @@
"""#1252/#1253: a dub ingest failed with the toast ``ingest: 'mgw39lx3'``.
Two reports, same reporter, same session, four `dub:upload` actions in a row.
The entire user-facing error was eight characters of their own job id:
ingest: 'mgw39lx3'
Error: ingest: 'mgw39lx3'
That is ``str(KeyError("mgw39lx3"))`` the repr of a dict key. ``KeyError``
does not put "a lookup failed" in its message, so `build_failure` faithfully
reported a value with no explanation attached to it.
The lookup that failed: ``ingest_pipeline`` finished with a bare
``_dub_jobs[job_id].update(...)``. Everything before it demucs, scene
detection, thumbnailing takes minutes, and ``DELETE /dub/history/{id}`` pops
the entry. Deleting an in-flight dub therefore raised ``KeyError`` from a
pipeline that was, by then, doing exactly what it was told.
Both halves are covered: the crash no longer happens, and no exception whose
``str()`` is a bare value can present itself to a user that way again.
"""
from __future__ import annotations
import pytest
import time
from core.failure import build_failure, describe_exception
from services import dub_pipeline
@pytest.fixture(autouse=True)
def _clean_jobs():
dub_pipeline._dub_jobs.clear()
yield
dub_pipeline._dub_jobs.clear()
# ── the crash ────────────────────────────────────────────────────────────
def test_merging_into_a_live_job_updates_it():
dub_pipeline.put_job("job1", {"filename": "a.mp4", "scene_cuts": []})
assert dub_pipeline.merge_job("job1", {"scene_cuts": [1.0, 2.0]}) is True
assert dub_pipeline._dub_jobs["job1"]["scene_cuts"] == [1.0, 2.0]
assert dub_pipeline._dub_jobs["job1"]["filename"] == "a.mp4", "a merge, not a replace"
def test_merging_into_a_deleted_job_reports_it_instead_of_raising():
"""The #1252 moment: the user deleted the dub while it was still ingesting."""
dub_pipeline.put_job("mgw39lx3", {"filename": "a.mp4"})
dub_pipeline._dub_jobs.pop("mgw39lx3") # DELETE /dub/history/{id}
assert dub_pipeline.merge_job("mgw39lx3", {"scene_cuts": []}) is False
def test_a_deleted_job_is_not_resurrected():
"""`False` must mean "stop", not "insert it back" — the user deleted this
on purpose, and re-adding it would put a phantom row back in history."""
assert dub_pipeline.merge_job("gone", {"scene_cuts": []}) is False
assert "gone" not in dub_pipeline._dub_jobs
def test_the_ingest_pipeline_no_longer_blind_subscripts_the_job():
"""The call site itself. A direct `_dub_jobs[job_id].update(` anywhere in
the pipeline reintroduces exactly this KeyError."""
import inspect
src = inspect.getsource(dub_pipeline.ingest_pipeline)
assert "_dub_jobs[job_id].update(" not in src
assert "merge_and_save_job(" in src
# ── the message ──────────────────────────────────────────────────────────
def test_a_keyerror_no_longer_presents_as_a_bare_key():
"""The exact reason string the reporter saw."""
failure = build_failure(KeyError("mgw39lx3"), stage="ingest")
assert failure["reason"] != "'mgw39lx3'"
assert failure["error_class"] == "KeyError"
assert "KeyError" in failure["reason"], "name what happened, not just the value"
assert "mgw39lx3" in failure["reason"], "but keep the value — it is the only clue"
def test_an_exception_with_no_message_at_all_still_names_itself():
assert describe_exception(RuntimeError()) == "RuntimeError"
assert describe_exception(ValueError(" ")) == "ValueError"
def test_a_real_message_is_left_exactly_alone():
"""The fix must not prefix class names onto errors that already read fine —
every existing hint and classification matches on message text."""
assert describe_exception(RuntimeError("CUDA out of memory")) == "CUDA out of memory"
assert describe_exception(
OSError("[Errno 28] No space left on device")
) == "[Errno 28] No space left on device"
def test_classification_still_works_through_the_wrapper():
"""`build_failure` classifies on the raw text; adding a class prefix must
not break the hint lookup for messages that do classify."""
failure = build_failure(RuntimeError("No module named 'omnivoice'"), stage="startup")
assert failure["docs_topic"] == "BROKEN_VENV"
assert failure["hint"]
# ── the delete race the first fix left open ──────────────────────────────
def test_merge_and_save_are_one_step(monkeypatch):
"""Review finding (#1252): merging and persisting as two steps leaves a
window where the user deletes the dub in between and the pending save
then UPSERTs the row straight back, so a dub they deleted reappears."""
saved = []
monkeypatch.setattr(
dub_pipeline, "save_job",
lambda job_id, job, *a, **kw: saved.append(job_id),
)
dub_pipeline.put_job("job1", {"filename": "a.mp4"})
assert dub_pipeline.merge_and_save_job("job1", {"scene_cuts": [1.0]}) is True
assert saved == ["job1"]
assert dub_pipeline._dub_jobs["job1"]["scene_cuts"] == [1.0]
def test_a_deleted_job_is_never_persisted(monkeypatch):
saved = []
monkeypatch.setattr(
dub_pipeline, "save_job",
lambda job_id, job, *a, **kw: saved.append(job_id),
)
assert dub_pipeline.merge_and_save_job("gone", {"scene_cuts": []}) is False
assert saved == [], "a withdrawn job must not reach the database"
def test_a_delete_landing_mid_save_cannot_be_overtaken(monkeypatch):
"""The race itself — asserted on ORDER, which is the only thing that
distinguishes it.
Two earlier versions of this test were not tests. The first deleted the job
before calling `merge_and_save_job`, so it merely re-checked the absent-job
case. The second interleaved a real thread but asserted only *what*
happened, not *when* so splitting merge from save (the exact bug) still
passed, because a save landing AFTER the delete looks identical to one
landing before if you only check that both occurred.
The resurrection is precisely `save` completing after `delete`. So record
the order and assert on it: with merge+save atomic under the lock, the
purge cannot start until the save has finished, and the sequence is always
save-then-delete. Split them and the purge is free to run first.
"""
import threading
order = []
order_lock = threading.Lock()
entered_save = threading.Event()
purge_attempted = threading.Event()
def _slow_save(job_id, job, *a, **kw):
entered_save.set()
# Wait until the purge thread has actually reached its purge call, so
# the two are genuinely in flight together, then hold a moment.
purge_attempted.wait(timeout=2.0)
time.sleep(0.05)
with order_lock:
order.append("save")
monkeypatch.setattr(dub_pipeline, "save_job", _slow_save)
dub_pipeline.put_job("job1", {"filename": "a.mp4"})
def _delete_rows():
with order_lock:
order.append("delete")
def _purge():
entered_save.wait(timeout=2.0)
purge_attempted.set()
dub_pipeline.purge_jobs(["job1"], delete_rows=_delete_rows)
purger = threading.Thread(target=_purge)
purger.start()
merged = dub_pipeline.merge_and_save_job("job1", {"scene_cuts": [1.0]})
purge_attempted.set() # release the save if the purge never got that far
purger.join(timeout=5.0)
assert merged is True, "the save started first, so it must complete"
assert order == ["save", "delete"], (
f"the write must never land after the delete — got {order}. "
"'delete' first means the row was resurrected."
)
assert "job1" not in dub_pipeline._dub_jobs
def test_a_purge_that_wins_the_race_stops_the_save_entirely(monkeypatch):
"""The other ordering: purge first, then the pipeline reaches its save."""
saved = []
monkeypatch.setattr(
dub_pipeline, "save_job", lambda job_id, job, *a, **kw: saved.append(job_id),
)
dub_pipeline.put_job("job1", {"filename": "a.mp4"})
dub_pipeline.purge_jobs(["job1"], delete_rows=lambda: None)
assert dub_pipeline.merge_and_save_job("job1", {"scene_cuts": []}) is False
assert saved == [], "a withdrawn job must never reach the database"
def test_the_create_checkpoints_are_atomic_too(monkeypatch):
"""Review finding (#1252): 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."""
import inspect
src = inspect.getsource(dub_pipeline.ingest_pipeline)
assert "put_and_save_job(" in src
assert "put_job(job_id," not in src, "the unlocked pair is the race"
saved = []
monkeypatch.setattr(
dub_pipeline, "save_job", lambda job_id, job, *a, **kw: saved.append(job_id),
)
dub_pipeline.put_and_save_job("job2", {"filename": "b.mp4"})
assert saved == ["job2"]
assert dub_pipeline._dub_jobs["job2"]["filename"] == "b.mp4"
def test_clear_history_also_evicts_in_memory_jobs(monkeypatch):
"""`DELETE /dub/history` deleted every row but evicted nothing from memory,
so an in-flight job survived "clear history" outright and re-saved itself
on completion."""
import inspect
from api.routers import dub_core
src = inspect.getsource(dub_core.clear_dub_history)
assert "purge_jobs" in src, "clear-all must evict memory too, not just rows"
def test_the_ingest_pipeline_persists_atomically():
import inspect
src = inspect.getsource(dub_pipeline.ingest_pipeline)
assert "merge_and_save_job(" in src
# The two-step form is what the race lived in.
assert "save_job(job_id, get_job(" not in src
# ── withdrawal survives a job's FIRST write ──────────────────────────────
@pytest.fixture(autouse=True)
def _clean_tombstones():
dub_pipeline._inflight_jobs.clear()
dub_pipeline._withdrawn_jobs.clear()
yield
dub_pipeline._inflight_jobs.clear()
dub_pipeline._withdrawn_jobs.clear()
def test_clearing_history_mid_ingest_is_not_undone_by_the_next_checkpoint(monkeypatch):
"""Review finding (#1252): dict membership can't express "withdrawn".
An ingest's FIRST persistence CREATES the entry, so an absent key means
"not written yet" for a new job and "deleted" for an established one two
opposite instructions from one signal. A clear-history landing before that
first checkpoint was therefore silently undone by the checkpoint recreating
the row, and the run went on to persist its result into history the user
had just cleared.
"""
saved = []
monkeypatch.setattr(
dub_pipeline, "save_job", lambda job_id, job, *a, **kw: saved.append(job_id),
)
dub_pipeline.begin_ingest("job1")
# Clear history BEFORE the job has ever been written. It appears in no row,
# so `job_ids` is empty — only the in-flight sweep can catch it.
dub_pipeline.purge_jobs([], delete_rows=lambda: None, include_inflight=True)
assert dub_pipeline.put_and_save_job("job1", {"filename": "a.mp4"}) is False
assert saved == [], "the checkpoint must not recreate a cleared job"
# ...and the terminal write stays refused too.
assert dub_pipeline.merge_and_save_job("job1", {"scene_cuts": []}) is False
def test_a_single_delete_also_withdraws_an_inflight_job(monkeypatch):
saved = []
monkeypatch.setattr(
dub_pipeline, "save_job", lambda job_id, job, *a, **kw: saved.append(job_id),
)
dub_pipeline.begin_ingest("job1")
dub_pipeline.put_and_save_job("job1", {"filename": "a.mp4"})
saved.clear()
dub_pipeline.purge_jobs(["job1"], delete_rows=lambda: None)
assert dub_pipeline.put_and_save_job("job1", {"filename": "a.mp4"}) is False
assert saved == []
def test_a_normal_ingest_is_unaffected(monkeypatch):
"""The gate must be invisible when nobody deletes anything — this runs on
every import."""
saved = []
monkeypatch.setattr(
dub_pipeline, "save_job", lambda job_id, job, *a, **kw: saved.append(job_id),
)
dub_pipeline.begin_ingest("job1")
assert dub_pipeline.put_and_save_job("job1", {"filename": "a.mp4"}) is True
assert dub_pipeline.merge_and_save_job("job1", {"scene_cuts": [1.0]}) is True
assert saved == ["job1", "job1"]
def test_a_delete_during_a_RENDER_is_honoured(monkeypatch):
"""The case Greptile actually reported, and the one an earlier version of
this fix did not close.
A dub is imported ONCE and rendered many times, so the realistic delete
lands during a render long after its ingest ended. Scoping the tombstone
to in-flight ingests looked right and protected almost nothing: the render's
own `save_job` wrote the row straight back.
"""
written = []
monkeypatch.setattr(
dub_pipeline, "_persist_job", lambda *a, **kw: written.append(a[0]),
)
# An ordinary saved dub whose import finished long ago.
dub_pipeline.begin_ingest("old1")
dub_pipeline.put_and_save_job("old1", {"filename": "a.mp4"})
dub_pipeline.end_ingest("old1")
written.clear()
# Deleted from history while a render is still running.
dub_pipeline.purge_jobs(["old1"], delete_rows=lambda: None)
# The render completes and persists, exactly as dub_generate.py does.
dub_pipeline.save_job("old1", {"filename": "a.mp4", "dubbed_tracks": {"en": {}}})
assert written == [], "a render finishing after the delete must not revive it"
def test_the_ingest_ending_is_not_an_un_delete(monkeypatch):
"""`end_ingest` used to clear the tombstone, which is what opened the gap
above the ingest finishing is not the user un-deleting anything."""
monkeypatch.setattr(dub_pipeline, "_persist_job", lambda *a, **kw: None)
dub_pipeline.begin_ingest("job1")
dub_pipeline.purge_jobs(["job1"], delete_rows=lambda: None)
dub_pipeline.end_ingest("job1")
assert "job1" in dub_pipeline._withdrawn_jobs
assert dub_pipeline._inflight_jobs == set()
def test_re_importing_the_same_id_revives_it(monkeypatch):
"""The one thing that legitimately un-deletes a job."""
written = []
monkeypatch.setattr(
dub_pipeline, "_persist_job", lambda *a, **kw: written.append(a[0]),
)
dub_pipeline.purge_jobs(["job1"], delete_rows=lambda: None)
assert dub_pipeline.save_job("job1", {"filename": "a.mp4"}) is None
assert written == []
dub_pipeline.begin_ingest("job1") # a deliberate re-import
assert dub_pipeline.put_and_save_job("job1", {"filename": "a.mp4"}) is True
assert written == ["job1"]
def test_clearing_a_large_history_mid_render_does_not_evict_the_running_job(
monkeypatch,
):
"""Review finding (#1252, Greptile P1): a count-bounded LRU is evictable by
ordinary use.
`DELETE /dub/history` selects every row with no limit, so a user with a
large history clearing it while a render is running would push that very
job's marker out — and the render would then write it straight back. Age is
the honest policy: what matters is how long ago the delete happened, not how
many others happened to follow it.
"""
written = []
monkeypatch.setattr(
dub_pipeline, "_persist_job", lambda *a, **kw: written.append(a[0]),
)
# One dub is mid-render; the user clears a history far larger than any
# count cap would keep.
# Deliberately larger than the size cap, so the cap is forced to choose —
# which is exactly the situation that evicted a live marker before.
everything = ["rendering"] + [
f"old{i}" for i in range(dub_pipeline._WITHDRAWN_MAX + 500)
]
dub_pipeline.purge_jobs(everything, delete_rows=lambda: None)
dub_pipeline.save_job("rendering", {"filename": "a.mp4"})
assert written == [], "the running job's withdrawal must survive the sweep"
def test_markers_expire_by_age(monkeypatch):
"""They are held for the life of the process otherwise, so something has to
let them go but it must be time, not volume."""
import time as _time
base = _time.monotonic()
monkeypatch.setattr(dub_pipeline.time, "monotonic", lambda: base)
dub_pipeline.purge_jobs(["old"], delete_rows=lambda: None)
assert "old" in dub_pipeline._withdrawn_jobs
# Well past the TTL, a later delete sweeps the stale one out.
monkeypatch.setattr(
dub_pipeline.time, "monotonic",
lambda: base + dub_pipeline._WITHDRAWN_TTL_S + 1,
)
dub_pipeline.purge_jobs(["fresh"], delete_rows=lambda: None)
assert "old" not in dub_pipeline._withdrawn_jobs
assert "fresh" in dub_pipeline._withdrawn_jobs
def test_the_marker_map_cannot_grow_without_bound():
"""The count cap is a memory backstop, not the policy."""
for i in range(dub_pipeline._WITHDRAWN_MAX + 100):
dub_pipeline.purge_jobs([f"job{i}"], delete_rows=lambda: None)
assert len(dub_pipeline._withdrawn_jobs) <= dub_pipeline._WITHDRAWN_MAX
def test_the_pipeline_registers_and_releases_its_run():
import inspect
src = inspect.getsource(dub_pipeline.ingest_pipeline)
assert "begin_ingest(job_id)" in src
assert "end_ingest(job_id)" in src, "a leaked tombstone would block a later run"
# Released in `finally`, so a crash or cancel can't leak it.
finally_block = src[src.rindex("finally:"):]
assert "end_ingest(job_id)" in finally_block
def test_clear_history_sweeps_inflight_jobs():
import inspect
from api.routers import dub_core
src = inspect.getsource(dub_core.clear_dub_history)
assert "include_inflight=True" in src, (
"an ingest with no row yet appears in no id list — only the in-flight "
"sweep can clear it"
)
# ── the gate is at the choke point, not in the callers ───────────────────
def test_every_direct_save_path_honours_a_withdrawal(monkeypatch):
"""Review finding (#1252, Greptile P1): gating only the ingest helpers left
eight direct `save_job` call sites across dub generate, translate, export
and core able to resurrect a dub the user deleted *mid-render*. Deleting
during generation is at least as likely as deleting during import.
The gate now lives in `save_job` itself, so every caller inherits it and
the ninth one cannot forget."""
written = []
monkeypatch.setattr(
dub_pipeline, "_persist_job", lambda *a, **kw: written.append(a[0]),
)
dub_pipeline.begin_ingest("job1")
dub_pipeline.purge_jobs(["job1"], delete_rows=lambda: None)
# The shape every router uses: a bare save_job, no lock, no helper.
dub_pipeline.save_job("job1", {"filename": "a.mp4"})
assert written == [], "a post-ingest save must not resurrect a deleted dub"
def test_a_normal_save_still_writes(monkeypatch):
written = []
monkeypatch.setattr(
dub_pipeline, "_persist_job", lambda *a, **kw: written.append(a[0]),
)
dub_pipeline.save_job("job1", {"filename": "a.mp4"})
assert written == ["job1"]
def test_the_lock_is_reentrant():
"""`save_job` acquires the lock, and the atomic helpers call it while
already holding it a plain Lock would deadlock the backend here."""
import threading
assert isinstance(dub_pipeline._dub_jobs_lock, type(threading.RLock()))
def test_the_atomic_helpers_still_work_through_the_reentrant_path(monkeypatch):
"""Guards the deadlock directly: if this hangs, the RLock regressed."""
written = []
monkeypatch.setattr(
dub_pipeline, "_persist_job", lambda *a, **kw: written.append(a[0]),
)
dub_pipeline.begin_ingest("job1")
assert dub_pipeline.put_and_save_job("job1", {"filename": "a.mp4"}) is True
assert dub_pipeline.merge_and_save_job("job1", {"scene_cuts": [1.0]}) is True
assert written == ["job1", "job1"]
def test_eviction_order_does_not_depend_on_hash_seed():
"""A `set` of target ids made eviction order depend on PYTHONHASHSEED — so
which markers survived a cap-forced trim was luck. The test for the
behaviour above passed locally and reddened main for that reason alone.
Same input, same surviving markers, every time.
"""
ids = [f"j{i}" for i in range(50)]
dub_pipeline._withdrawn_jobs.clear()
dub_pipeline.purge_jobs(ids, delete_rows=lambda: None)
first = list(dub_pipeline._withdrawn_jobs)
dub_pipeline._withdrawn_jobs.clear()
dub_pipeline.purge_jobs(ids, delete_rows=lambda: None)
assert list(dub_pipeline._withdrawn_jobs) == first == ids
def test_a_purge_larger_than_the_cap_keeps_all_of_its_own_markers():
"""The cap must never discard a marker the current purge just recorded:
those are the newest and the likeliest to still be held. The bound is
therefore `cap + one purge`, which is the honest guarantee."""
dub_pipeline._withdrawn_jobs.clear()
oversized = [f"x{i}" for i in range(dub_pipeline._WITHDRAWN_MAX + 750)]
dub_pipeline.purge_jobs(oversized, delete_rows=lambda: None)
assert len(dub_pipeline._withdrawn_jobs) == len(oversized)
for job_id in oversized:
assert job_id in dub_pipeline._withdrawn_jobs

Some files were not shown because too many files have changed in this diff Show More