Compare commits

...
53 Commits
Author SHA1 Message Date
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
82 changed files with 5493 additions and 309 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/
+60 -1
View File
@@ -6,6 +6,65 @@ The format is loosely based on [Keep a Changelog](https://keepachangelog.com/).
Versions track the desktop app (`tauri.conf.json` + `frontend/src-tauri/Cargo.toml`).
The bundled TTS model package (`pyproject.toml`) is versioned independently.
## [0.4.1] — 2026-07-27
**Highlights**
- AMD GPUs are used again — every ROCm host was silently running on the CPU
- Two synth failures that used to say "an error OmniVoice doesn't recognize" now say what actually went wrong
- A dub URL ingest that fails on a disk problem now says which folder and why
- A broken audio dependency no longer takes the whole backend down at startup
- A GPU too small for the chosen engine now says so up front, not after a five-minute wait
- A port conflict now says so, instead of "Backend died (exit code 1)"
- A model download that dies at 90% now resumes instead of failing the install
- First run: Continue and the Hugging Face token box no longer sit under the status bar
- macOS 12 (Monterey): the app launches again instead of dying on startup
- Exporting a voice or a dub no longer fails when the name isn't spelled in Latin letters
- Two more failures that used to arrive as raw OS text now say what to do about them
- Unload works on every model the panel offers it for, and a language the active engine can't speak says so
- Deleting a dub no longer un-deletes itself when the job it belonged to finishes
### Changed
- First run: the status bar (Logs, version, Sponsors) appears once you reach the studio, instead of overlaying the setup steps (#1241)
### Added
- `OMNIVOICE_MCP_ALLOWED_HOSTS` — comma-separated host patterns (e.g. `host.containers.internal:*,192.168.1.5:*`) that extend the MCP SDK's DNS-rebinding allowlist, so AI agents running in Docker containers or on other machines can reach the `/mcp` endpoint. The SDK default is localhost-only; this env var is opt-in (#1249)
### Docs
- Docker: ROCm section explains that `torch.cuda.is_available() == True` isn't proof the app is on the GPU, and notes the `--group-add` needed for `/dev/kfd` on rootless hosts (#1228)
### Fixed
- Deleting a dub while it was still importing crashed the import with the toast `ingest: 'mgw39lx3'` — a dict key and nothing else — and the delete could then be undone by the job's own pending write, in history or mid-render; both are fixed, and no failure can present itself as a bare value again — thanks @dustmaker124-ui! (#1252, #1253)
- macOS 12 (Monterey): the app threw on startup and never started the backend — it called a Safari 16 method on the WebView that macOS ships. It launches and works now; some styling still needs a newer WebView (tracked in #1268) — thanks @singhrahat! (#1245)
- Settings → Engines: Unload failed with `400 Unknown model id: engine:kittentts` on any in-process engine — the panel offered the button for ids the backend never accepted; the warm dictation model had the same gap — thanks @JavaxmI! (#1247)
- Picking a language the active engine can't speak recited 23 codes without saying which engine refused or that switching engine was the fix — thanks @pulananave! (#1257)
- A YouTube import that failed as "DRM protected" and then worked on a manual retry now escalates the player client automatically, and a genuinely undownloadable video says so — thanks @gysahlgreene! (#1254)
- Exporting a voice profile, persona, dub, subtitle or stem whose name is Chinese, Japanese, Korean, Cyrillic, Greek, Hebrew or emoji failed with a `'latin-1' codec` 500 — every download endpoint now sends the name correctly, and browsers get the real one back — thanks @zvxzdx! (#1262)
- A synth that failed because ffmpeg/ffprobe wasn't on the system path said "an error OmniVoice doesn't recognize"; it now names the media engine and points at Settings → Audio tools, and the app's own copy is published on PATH so dependencies find it in the first place — thanks @Heuvelsma! (#1256)
- Windows "The paging file is too small" arrived as a bare 500; it now explains that this is a virtual-memory setting, not full RAM, and gives the steps to raise it — thanks @trankeny545-sudo! (#1251)
- AMD/ROCm: every ROCm host was silently force-routed to the CPU — the compatibility gate compared a CUDA `sm_` tag against a ROCm build's `gfx` list, which can never match — thanks @simmessa! (#1228)
- AMD/ROCm: `torch.compile` was disabled on all AMD hosts by the same mismatched comparison (#1228)
- AMD/ROCm: `HSA_OVERRIDE_GFX_VERSION` is auto-set only when your card genuinely needs it and the remap target exists in your build; gfx1150/gfx1151 (Strix Point/Halo) added to the map (#1228)
- Windows blocking an engine file (Smart App Control, WDAC, or AppLocker) is now named, with the fix for personal and managed PCs — thanks @AdityaHemantBhat! (#1227)
- A failed audio write (`LibsndfileError: System error.`) now names the target file, its folder's writability and the drive's free space — thanks @morozov28061995-boop! (#1221)
- Dub URL ingest: a disk error now names the job folder, its writability and the drive's free space, instead of pointing at the system TEMP folder it never used — thanks @dustmaker124-ui! (#1225)
- Dub URL ingest fails immediately when the job folder is missing or unwritable, instead of starting a download that can only fail (#1225)
- The backend no longer dies at startup when transformers can't resolve its audio tokenizer (a missing or mismatched torchaudio, common on Google Colab) — it starts, and the error arrives with a repair hint — thanks @Navdeep-Chauhan-777! (#1229)
- Importing `omnivoice.utils.*` no longer drags in torch, torchaudio, transformers and the full model definition — thanks @Navdeep-Chauhan-777! (#1229)
- Colab notebook: the install cell now catches a broken environment with the real error, instead of a 5-minute health timeout two cells later — thanks @Navdeep-Chauhan-777! (#1229)
- A GPU with less VRAM than the chosen engine needs is flagged in Settings → Engines before you generate, instead of showing a clean green "accelerated" until the job times out — thanks @AdityaHemantBhat and @beingavais! (#1226, #1222)
- A generation timeout now names your actual card and its VRAM and recommends a lighter engine (#1226, #1222)
- First run: Continue and the Hugging Face token box rendered underneath the status bar, off the bottom of the window — the wizard laid itself out against the viewport instead of its own frame (#1241)
- A busy port 3900 now reports a port conflict instead of "Backend died (exit code 1)", in every language — thanks @xipb14! (#1223)
- The app verifies it actually freed the port before starting the backend, rather than assuming the kill worked (#1223)
- A model download truncated near the end is now retried and resumed instead of aborting the whole install — thanks @Reaksa-Cambodia! (#1224)
- Engine first-use downloads (VoxCPM2, MOSS-TTS-Nano) retry transient network failures instead of failing the load outright (#1224)
- A backend killed by the OS mid-stream now leaves a low-memory trail in the crash report (#1224)
## [0.4.0] — 2026-07-21
**Highlights**
@@ -326,7 +385,6 @@ The quality release. Three long-standing frictions got structural fixes: **regen
The cold-start release. Three "why is this broken on my machine" mysteries got solved at their roots: **first generations stop dying at 300 seconds** (the timeout was counting the model download as generation time — @moduvoice measured it on a Tesla T4: 0% GPU for the full window), **updates stop deleting engines you installed yourself** (the updater's dependency sync removed anything not in the app's lockfile — including things our own UI told you to install), and **the "slower than v0.3.5" regression is found and fixed** (clone profiles without a transcript were silently re-running a full Whisper transcription on every single generate). Also: Clear History is back, auto-played audio is finally stoppable, @stronghamjji hardened the dub pipeline against wedged transcribes, and @shakib30's community Colab notebook is now the linked no-GPU path. Thank you all.
### Added
- **Agent Skills: `npx skills add debpalash/omnivoice-studio`.** Two installable [skills](https://skills.sh) now ship in the repo — `omnivoice` teaches any AI agent (Claude Code, Cursor, Codex, …) to speak and transcribe through your local install via the OpenAI-compatible API, including your cloned voices; `oss-maintainer` packages the maintainer methodology this project is run with.
@@ -675,6 +733,7 @@ across dub, generate, and design (a corrupt-binary failure no longer poses as
above Continue, framed around what it actually buys you — authenticated, faster,
more reliable downloads (higher rate limits, fewer stalls) — with a one-click
"get a free token" link. (#657, #669)
### Fixed
- **Bug reports redact more secrets and every Windows username casing.** The
+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",
]
+187 -2
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,6 +266,33 @@ 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 /
@@ -318,6 +364,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 +379,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 +483,119 @@ 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
)
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 +609,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.1"
def _fallback_version() -> str:
+75 -1
View File
@@ -460,6 +460,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:
@@ -1295,6 +1308,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 +1324,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(
+313 -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,150 @@ 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) -> None:
"""Drop withdrawal markers that are too old to still matter.
Caller must hold ``_dub_jobs_lock``. Age first that is the policy then
the count cap purely so the mapping cannot grow without bound.
"""
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)
while len(_withdrawn_jobs) > _WITHDRAWN_MAX:
_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()
targets = set(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 |= _inflight_jobs
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)
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 +392,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 +701,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 +755,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 +812,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 +918,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 +1049,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 +1220,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 +1248,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 +1345,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 +1388,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}")
+114 -41
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():
+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"])
+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.1",
"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.1"
dependencies = [
"arboard",
"dirs-next",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "omnivoice-studio"
version = "0.4.0"
version = "0.4.1"
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>
);
}
+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))
+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
@@ -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;/);
});
});
+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');
});
});
+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*=/);
});
});
+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 ' +
+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.1"
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)
+504
View File
@@ -0,0 +1,504 @@
"""#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.
everything = ["rendering"] + [f"old{i}" for i in range(5000)]
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"]
+8 -1
View File
@@ -195,7 +195,14 @@ class TestSubtitleSaveResponseShape:
assert res.status_code == 200
assert res.text.startswith("1\n")
disposition = res.headers.get("content-disposition", "")
assert disposition.endswith('.srt"')
# Asserted on content, not on position: since #1262 the header also
# carries an RFC 5987 `filename*=` so non-latin-1 names don't 500, and
# that parameter comes last. Both parameters are asserted — checking
# only `filename=` would pass against the pre-#1262 header too.
assert disposition.startswith("attachment;")
assert '.srt"' in disposition
assert "filename*=UTF-8''" in disposition
assert disposition.endswith(".srt")
def test_plain_srt_get_still_returns_text_body(self, client, translated_job):
job_id, _ = translated_job
+161
View File
@@ -0,0 +1,161 @@
"""#1257: `400 Bad Request: Invalid language code. Supported languages: ar
(Arabic), da (Danish), de (German), `
The reporter was on mlx-audio and got a bare recitation of 23 language codes.
Nothing in it says which engine is refusing, that OmniVoice offers 646
languages because a *different* engine supports them, or that switching engines
is the fix. Three generate attempts in their action log, all identical.
The cause is stated outright in `MLXAudioBackend.supported_languages`:
# Per-model; Kokoro supports 8, Qwen3 ~4, Kugel 24. Return "multi"
# so the language picker doesn't gate by engine — each engine
# silently ignores languages it doesn't know.
The last clause is not true. The engine's library raises, so the picker offers
languages the active engine will reject, and the rejection arrives with no
context. Enumerating every model's real language list would be a brittle map
that goes stale on each engine update; naming the engine and the way out is
both accurate and durable.
"""
from __future__ import annotations
import pytest
from api.routers.generation import _language_rejection_or
class _Engine:
id = "mlx-audio"
display_name = "MLX Audio"
REPORTED = (
"Invalid language code. Supported languages: ar (Arabic), da (Danish), "
"de (German), el (Greek), en (English), es (Spanish)"
)
def test_the_reported_error_names_the_engine_and_the_way_out():
rewritten = _language_rejection_or(ValueError(REPORTED), _Engine(), "bn")
assert isinstance(rewritten, ValueError)
message = str(rewritten)
assert "MLX Audio" in message, "the user must learn WHICH engine refused"
assert "'bn'" in message, "...and which language it refused"
assert "Settings → Engines" in message, "...and where to fix it"
# The engine's own list is still useful — keep it rather than hide it.
assert "ar (Arabic)" in message
@pytest.mark.parametrize(
"reason",
[
"Invalid language code. Supported languages: en, es",
"Unsupported language: bn",
"RuntimeError: language not supported by this checkpoint",
"ValueError: This language is not supported",
],
)
def test_every_wording_of_a_language_rejection_is_caught(reason):
"""Each engine multiplexes a different third-party library, so the class
and the wording both vary match on meaning."""
rewritten = _language_rejection_or(RuntimeError(reason), _Engine(), "bn")
assert "Settings → Engines" in str(rewritten)
def test_a_language_rejection_becomes_a_ValueError():
"""The route maps ValueError → 400 and everything else → 500. A user
picking an unsupported language is a validation problem, not a crash."""
rewritten = _language_rejection_or(RuntimeError(REPORTED), _Engine(), "bn")
assert isinstance(rewritten, ValueError)
def test_an_unrelated_failure_is_returned_untouched():
"""This wraps every exception on the path, so it must be inert for the
rest an OOM rewritten as a language problem would be far worse than the
bug being fixed."""
oom = RuntimeError("CUDA out of memory. Tried to allocate 2.00 GiB")
assert _language_rejection_or(oom, _Engine(), "en") is oom
disk = OSError("[Errno 28] No space left on device")
assert _language_rejection_or(disk, _Engine(), "en") is disk
def _run(backend, exc, language="bn"):
"""Drive the real `_run_backend_inference` with a backend that raises."""
from api.routers.generation import _run_backend_inference
class _Raiser:
id = getattr(backend, "id", "x")
display_name = getattr(backend, "display_name", None)
sample_rate = 24000
applies_own_mastering = True
def generate(self, *a, **kw):
raise exc
return _run_backend_inference(
_Raiser(), "hello", language, None, None, None, None,
None, None, 1.0, False, False, None,
)
def test_the_wiring_rewrites_a_language_rejection_end_to_end():
"""Exercised through the real call path, not by reading its source: an
assertion on source text passes even when the call is unreachable or its
result discarded (#1224 taught this lesson on this same codebase)."""
with pytest.raises(ValueError) as caught:
_run(_Engine(), RuntimeError(REPORTED))
assert "Settings → Engines" in str(caught.value)
assert "MLX Audio" in str(caught.value)
def test_an_oom_still_reaches_the_oom_path():
"""The rewrite wraps every failure on the path, so the OOM handling that
was already there must survive it."""
with pytest.raises(Exception) as caught:
_run(_Engine(), RuntimeError("CUDA out of memory. Tried to allocate 2.00 GiB"))
message = str(caught.value)
assert "Settings → Engines" not in message, "an OOM is not a language problem"
# _oom_friendly_reraise rewrites it into its own guidance.
assert "memory" in message.lower()
def test_a_nameless_backend_still_names_itself():
"""`or "engine" in message` would always pass — the production template
contains the word so it proved nothing about the fallback (#1257
review). Assert the class name the fallback actually resolves to."""
class _Bare:
pass
message = str(_language_rejection_or(ValueError(REPORTED), _Bare(), None))
assert "_Bare" in message
assert "Settings → Engines" in message
@pytest.mark.parametrize(
"reason",
[
"Unsupported language model configuration: gpt2-medium",
"unsupported language modeling head",
],
)
def test_a_model_failure_that_merely_says_unsupported_language_is_untouched(reason):
"""Review finding (#1257): a bare "unsupported language" prefix also
matches "Unsupported language model ...", which is a model/config problem
that would then be handed engine-switch advice it has no use for."""
exc = RuntimeError(reason)
assert _language_rejection_or(exc, _Engine(), "en") is exc
@pytest.mark.parametrize(
"reason",
["Unsupported language: bn", "Unsupported language 'bn'", "Unsupported language"],
)
def test_a_genuine_unsupported_language_still_matches(reason):
assert "Settings → Engines" in str(
_language_rejection_or(RuntimeError(reason), _Engine(), "bn")
)
+1 -1
View File
@@ -31,7 +31,7 @@ NEW_DISCORD = "discord.gg/bzQavDfVV9"
_LINK_FILES = [
"README.md",
"CONTRIBUTING.md",
".github/CONTRIBUTING.md",
"frontend/src/pages/EnterprisePage.jsx",
"frontend/src/components/LogsFooter.jsx",
]
+188
View File
@@ -0,0 +1,188 @@
"""#1226 / #1222: a 4 GB card was told it was under-provisioned only *after*
waiting out the full compute budget.
Two users GTX 1650 Ti (4 GB) and Quadro P2000 (4 GB) ran the `omnivoice`
engine and got:
TTS generate ran for more than 300s/372s of actual compute time and was
abandoned most often the GPU is VRAM-starved
Everything about that is technically true and useless. The 300-vs-372 spread is
just text length (the budget is `300 + (len-1200)/40`, so 372s ~4080 chars)
the two reports are one bug. And until the moment it failed, routing showed a
clean green "accelerated": family membership was the ONLY thing anything
checked, so a 4 GB card and a 24 GB card were indistinguishable.
These tests pin the declared VRAM floor, the advisory it produces before the
user waits, and the after-the-fact message naming the actual card.
"""
from __future__ import annotations
from core.device_caps import KERNEL_RISK_MARKER, HostCaps
from services import model_manager
from services.engine_routing import resolve_routing, routing_notice
from services.tts_backend import OmniVoiceBackend, TTSBackend
def _gpu(vram_gb: float, name: str = "NVIDIA GeForce GTX 1650 Ti", notes=()) -> HostCaps:
return HostCaps(
family="cuda",
available_families=("cuda", "cpu"),
device_name=name,
vram_gb=vram_gb,
notes=tuple(notes),
)
# ── the floor is declared ────────────────────────────────────────────────
def test_engines_declare_no_floor_by_default():
assert TTSBackend.min_vram_gb == 0.0
def test_the_reported_engine_declares_a_floor():
assert OmniVoiceBackend.min_vram_gb >= 6.0
# ── the user is warned BEFORE waiting ────────────────────────────────────
def test_a_4gb_card_gets_a_caveat_instead_of_a_clean_green():
r = resolve_routing(("cuda", "mps", "cpu"), _gpu(4.0), OmniVoiceBackend.min_vram_gb)
assert r["routing_status"] == "accelerated" # it DOES run — advisory, not blocking
reason = r["routing_reason"]
assert reason and "4.0 GB" in reason
assert "GTX 1650 Ti" in reason
assert "lighter engine" in reason
# routing_notice is what actually surfaces it to the user.
assert routing_notice(r) == ("accelerated", reason)
def test_a_large_card_stays_silent():
r = resolve_routing(("cuda", "mps", "cpu"), _gpu(24.0, "NVIDIA RTX 4090"),
OmniVoiceBackend.min_vram_gb)
assert r["routing_reason"] is None
assert routing_notice(r) is None
def test_an_engine_with_no_declared_floor_never_warns():
"""Only engines with a measured figure warn — inventing floors for the
rest would put confident numbers in the UI that nothing backs."""
r = resolve_routing(("cuda", "cpu"), _gpu(4.0))
assert r["routing_reason"] is None
def test_mps_is_not_judged_by_a_cuda_measured_floor():
"""On MPS, HostCaps.vram_gb is a heuristic (system RAM / 2) for a UNIFIED
memory pool. An 8 GB Mac therefore 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. Different memory model, unmeasured floor."""
caps = HostCaps(
family="mps",
available_families=("mps", "cpu"),
device_name="Apple Silicon (MPS)",
vram_gb=4.0, # an 8 GB Mac
)
r = resolve_routing(("cuda", "mps", "cpu"), caps, OmniVoiceBackend.min_vram_gb)
assert r["routing_status"] == "accelerated"
assert r["routing_reason"] is None
def test_mps_timeout_message_is_not_a_vram_verdict(monkeypatch):
caps = HostCaps(
family="mps", available_families=("mps", "cpu"),
device_name="Apple Silicon (MPS)", vram_gb=4.0,
)
msg = _guidance(monkeypatch, caps)
assert "Apple Silicon" not in msg
def test_a_failed_vram_probe_does_not_guess():
"""vram_gb == 0 means the probe failed, not that the card has no memory."""
r = resolve_routing(("cuda", "cpu"), _gpu(0.0), 6.0)
assert r["routing_reason"] is None
def test_kernel_risk_still_outranks_the_vram_caveat():
"""A card that may not launch kernels at all is the more severe finding."""
caps = _gpu(4.0, notes=(f"GPU (sm_120) not in this build's archs — {KERNEL_RISK_MARKER}",))
r = resolve_routing(("cuda", "cpu"), caps, 6.0)
assert KERNEL_RISK_MARKER in r["routing_reason"]
def test_the_matrix_payload_carries_the_floor():
from services.tts_backend import list_backends
entry = next(b for b in list_backends() if b["id"] == "omnivoice")
assert entry["min_vram_gb"] == OmniVoiceBackend.min_vram_gb
# Engines without a floor report null, not 0.0 — "unknown", not "none".
assert all(b.get("min_vram_gb") != 0.0 for b in list_backends())
# ── the after-the-fact message names the actual card ─────────────────────
def _guidance(monkeypatch, caps, min_vram_gb=OmniVoiceBackend.min_vram_gb,
what="TTS generate"):
monkeypatch.setattr("core.device_caps.detect_host_caps", lambda: caps)
return model_manager._timeout_guidance(what, 300.0, min_vram_gb)
def test_timeout_message_names_the_small_card(monkeypatch):
msg = _guidance(monkeypatch, _gpu(4.0))
assert "GTX 1650 Ti" in msg
assert "4.0 GB" in msg
# It must not read as transient contention the user can flush away.
assert "lighter engine" in msg
def test_timeout_message_unchanged_on_a_large_card(monkeypatch):
msg = _guidance(monkeypatch, _gpu(24.0, "NVIDIA RTX 4090"))
assert "VRAM-starved" in msg
assert "RTX 4090" not in msg
def test_a_job_with_no_declared_floor_is_never_diagnosed_as_under_provisioned(
monkeypatch,
):
"""`_timeout_guidance` serves EVERY job on the GPU pool — reference
transcribe, stream assemble, watermarking, dub steps, and CPU-only engines
running on a GPU host. Applying a VRAM verdict without knowing whose job it
is would confidently misdiagnose most of them, so the caller must opt in by
passing the engine's measured floor."""
msg = _guidance(monkeypatch, _gpu(4.0), min_vram_gb=0.0,
what="Reference transcribe")
assert "GTX 1650 Ti" not in msg
assert "VRAM-starved" in msg # the pre-existing generic GPU wording
def test_the_generate_call_sites_pass_the_engines_floor():
"""...and the TTS generate dispatches DO opt in — otherwise the branch
above is unreachable in production."""
import inspect
from api.routers import generation
src = inspect.getsource(generation)
assert src.count("min_vram_gb=_engine_min_vram_gb") == src.count(
'what="TTS generate",'
), "every TTS generate dispatch must pass the engine's floor"
def test_timeout_message_unchanged_on_cpu(monkeypatch):
"""#896: a CPU-only host must never be blamed on VRAM."""
caps = HostCaps(family="cpu", available_families=("cpu",))
msg = _guidance(monkeypatch, caps)
assert "VRAM" not in msg
assert "compute-bound" in msg
# ── the two reports really are one bug ───────────────────────────────────
def test_the_300_vs_372_second_spread_is_just_text_length():
"""#1226 saw 300s, #1222 saw 372s. If that difference were device-aware
they'd be separate bugs; it is purely the length-scaled budget."""
assert model_manager.generate_timeout_s("x" * 100) == 300.0
assert model_manager.generate_timeout_s("x" * 4080) == 372.0
+14
View File
@@ -103,3 +103,17 @@ def test_decode_ref_audio_rejects_garbage_without_raising():
def test_sniff_audio_ext_matches_magic_bytes(raw, ext):
from mcp_server import _sniff_audio_ext
assert _sniff_audio_ext(raw) == ext
def test_mcp_allowed_hosts_env_extends_allowlist(monkeypatch):
"""OMNIVOICE_MCP_ALLOWED_HOSTS must extend the transport-security allowlist."""
from mcp_server import create_mcp_server
monkeypatch.setenv("OMNIVOICE_MCP_ALLOWED_HOSTS", "host.containers.internal:*,10.0.0.1:*")
server = create_mcp_server()
allowed = server.settings.transport_security.allowed_hosts
assert "host.containers.internal:*" in allowed
assert "10.0.0.1:*" in allowed
origins = server.settings.transport_security.allowed_origins
assert "http://host.containers.internal:*" in origins
assert "https://host.containers.internal:*" in origins
+201
View File
@@ -0,0 +1,201 @@
"""#1256: a synth failed with a raw `FileNotFoundError: … 'ffprobe'`.
The reporter's toast, on a Mac with the app's own ffprobe sitting on disk:
Couldn't synthesize audio. … Underlying error: RuntimeError: TTS engine
stopped mid-generation with an error OmniVoice doesn't recognize. Retry
once; if it keeps failing, please report it with the full trace.
Underlying error: FileNotFoundError: [Errno 2] No such file or directory:
'ffprobe'
Two separate defects, fixed here as two halves:
1. **It happened at all.** Every OmniVoice call site resolves ffmpeg/ffprobe
explicitly (`find_ffprobe()`), which is why a bundled sidecar that was never
on ``PATH`` works for us. Our dependencies get no such courtesy one that
shells out to ``ffprobe`` by bare name finds nothing. Publishing the
resolved directories on ``PATH`` fixes every such dependency at once.
2. **It was illegible.** "an error OmniVoice doesn't recognize" is what the
generic engine wrapper says when `classify()` returns "" and it did,
because nothing matched a missing media binary. It now names the class and
points at Settings Audio tools.
"""
from __future__ import annotations
import os
import pytest
from core.failure import build_failure, classify
from services import ffmpeg_utils
# ── the failure is now classified ────────────────────────────────────────
def test_the_reporters_error_is_classified():
assert classify("[Errno 2] No such file or directory: 'ffprobe'") == "MEDIA_TOOL_MISSING"
@pytest.mark.parametrize(
"reason",
[
# As it arrives wrapped by the engine's generic handler.
"FileNotFoundError: [Errno 2] No such file or directory: 'ffprobe'",
"[Errno 2] No such file or directory: 'ffmpeg'",
# subprocess on Windows words it differently.
"[WinError 2] The system cannot find the file specified: 'ffmpeg'",
'[Errno 2] No such file or directory: "ffprobe"',
],
)
def test_every_wording_of_the_missing_binary_is_classified(reason):
assert classify(reason) == "MEDIA_TOOL_MISSING"
def test_the_hint_names_the_repair_and_is_actionable():
failure = build_failure(
FileNotFoundError(2, "No such file or directory", "ffprobe"),
stage="generate",
)
assert failure["error_class"] == "FileNotFoundError"
hint = failure.get("hint") or ""
assert "Audio tools" in hint, "the user needs the panel that fixes it"
assert "ffmpeg" in hint.lower()
def test_a_missing_INPUT_file_is_not_handed_the_media_engine_remedy():
"""The narrow half of the match. ffmpeg reporting that its *input* is
missing is an entirely different problem telling that user to reinstall
the media engine sends them the wrong way."""
assert classify(
"ffmpeg failed: [Errno 2] No such file or directory: '/tmp/chunk_7.wav'"
) != "MEDIA_TOOL_MISSING"
assert classify(
"No such file or directory: '/Users/x/Movies/my-ffmpeg-export.mp4'"
) != "MEDIA_TOOL_MISSING"
def test_unrelated_errno_2_failures_keep_their_own_class():
"""The rule runs before the generic errno-2 handling, so it must not
swallow the classes that were already correct."""
assert classify("No module named 'omnivoice'") == "BROKEN_VENV"
assert classify(
"does not appear to have a file named model.safetensors"
) == "MODEL_CACHE_CORRUPT"
# ── and it is prevented in the first place ───────────────────────────────
def test_the_resolved_binaries_are_published_on_PATH(monkeypatch, tmp_path):
"""The half that stops the failure happening: a dependency that shells out
by bare name must find what `find_ffprobe()` already resolved."""
bin_dir = tmp_path / "media"
bin_dir.mkdir()
ffmpeg = bin_dir / "ffmpeg"
ffprobe = bin_dir / "ffprobe"
ffmpeg.write_text("")
ffprobe.write_text("")
monkeypatch.setattr(ffmpeg_utils, "find_ffmpeg", lambda: str(ffmpeg))
monkeypatch.setattr(ffmpeg_utils, "find_ffprobe", lambda: str(ffprobe))
monkeypatch.setenv("PATH", "/usr/bin")
added = ffmpeg_utils.ensure_media_tools_on_path()
assert added == [str(bin_dir)]
assert os.environ["PATH"].split(os.pathsep)[0] == str(bin_dir), (
"must be prepended — the copy we validated should win over a broken "
"system one"
)
def test_publishing_is_idempotent(monkeypatch, tmp_path):
"""It runs at import time; a reload or a second call must not grow PATH
without bound."""
bin_dir = tmp_path / "media"
bin_dir.mkdir()
(bin_dir / "ffmpeg").write_text("")
monkeypatch.setattr(ffmpeg_utils, "find_ffmpeg", lambda: str(bin_dir / "ffmpeg"))
monkeypatch.setattr(ffmpeg_utils, "find_ffprobe", lambda: None)
monkeypatch.setenv("PATH", "/usr/bin")
ffmpeg_utils.ensure_media_tools_on_path()
first = os.environ["PATH"]
assert ffmpeg_utils.ensure_media_tools_on_path() == []
assert os.environ["PATH"] == first
def test_separate_directories_are_both_added(monkeypatch, tmp_path):
"""A system ffmpeg plus a bundled ffprobe is a real configuration — the
resolver already supports it, so PATH must reflect both."""
a, b = tmp_path / "a", tmp_path / "b"
a.mkdir()
b.mkdir()
(a / "ffmpeg").write_text("")
(b / "ffprobe").write_text("")
monkeypatch.setattr(ffmpeg_utils, "find_ffmpeg", lambda: str(a / "ffmpeg"))
monkeypatch.setattr(ffmpeg_utils, "find_ffprobe", lambda: str(b / "ffprobe"))
monkeypatch.setenv("PATH", "/usr/bin")
assert sorted(ffmpeg_utils.ensure_media_tools_on_path()) == sorted([str(a), str(b)])
def test_nothing_resolvable_is_a_no_op(monkeypatch):
"""A machine with no media engine at all must still boot — the classified
error above is what that user gets, not a startup crash."""
monkeypatch.setattr(ffmpeg_utils, "find_ffmpeg", lambda: None)
monkeypatch.setattr(ffmpeg_utils, "find_ffprobe", lambda: None)
monkeypatch.setenv("PATH", "/usr/bin")
assert ffmpeg_utils.ensure_media_tools_on_path() == []
assert os.environ["PATH"] == "/usr/bin"
def test_a_raising_resolver_never_breaks_startup(monkeypatch):
def boom():
raise RuntimeError("media_tools registry unreadable")
monkeypatch.setattr(ffmpeg_utils, "find_ffmpeg", boom)
monkeypatch.setattr(ffmpeg_utils, "find_ffprobe", boom)
monkeypatch.setenv("PATH", "/usr/bin")
assert ffmpeg_utils.ensure_media_tools_on_path() == []
def test_a_path_that_merely_ends_in_the_tool_name_is_not_a_missing_tool(tmp_path):
"""Review finding (#1256): the match accepted any message ending in
'ffmpeg'/'ffprobe', so a missing FILE whose name happens to be the tool's
was handed the "repair your media engine" remedy.
Paths are built from `tmp_path` rather than written as literals they are
only error-message data here, but a hardcoded /tmp trips Ruff S108."""
paths = [
str(tmp_path / "ffmpeg"),
str(tmp_path / "sub" / "ffprobe"),
"C:\\work\\ffmpeg",
]
for path in paths:
assert classify(f"[Errno 2] No such file or directory: {path}") != (
"MEDIA_TOOL_MISSING"
), path
def test_the_published_paths_are_not_logged(monkeypatch, tmp_path, caplog):
"""Review finding (#1256): a user-set FFMPEG_PATH resolves under their home
directory, and absolute home paths must not reach the log."""
import logging
bin_dir = tmp_path / "Users" / "alice" / "media"
bin_dir.mkdir(parents=True)
(bin_dir / "ffmpeg").write_text("")
monkeypatch.setattr(ffmpeg_utils, "find_ffmpeg", lambda: str(bin_dir / "ffmpeg"))
monkeypatch.setattr(ffmpeg_utils, "find_ffprobe", lambda: None)
monkeypatch.setenv("PATH", "/usr/bin")
with caplog.at_level(logging.INFO, logger="omnivoice.api"):
ffmpeg_utils.ensure_media_tools_on_path()
assert str(bin_dir) not in caplog.text
assert "alice" not in caplog.text
+1
View File
@@ -56,6 +56,7 @@ _ALLOWED_FILES = {
"backend/services/segmentation.py",
"backend/services/sentence_chunker.py", # streaming-TTS terminator tables (Patter port, Wave 1.4)
"backend/services/subtitle_segmenter.py",
"backend/core/http_headers.py", # docstring quotes the CJK filename that 500'd the header (#1262)
"frontend/src/components/DubSegmentRow.jsx",
"frontend/src/components/StoriesEditor.jsx",
"frontend/src/utils/voiceInstruct.js",
+114
View File
@@ -0,0 +1,114 @@
"""#1229: one optional transformers symbol killed the entire backend at import.
`backend/api/routers/profiles.py` imports two pure-stdlib helpers from
`omnivoice.utils.voice_design`. That import used to drag in `omnivoice/__init__`
`omnivoice.models.omnivoice` torch + torchaudio + transformers + a
top-level `from transformers import HiggsAudioV2TokenizerModel`. transformers
exposes that class lazily and gates it on the torchaudio backend, so on a host
where torchaudio is missing/ABI-mismatched/metadata-less (Colab's system
Python) the *attribute access* raised `ModuleNotFoundError: Could not import
module 'HiggsAudioV2TokenizerModel'` during `backend/main.py`'s module import,
before FastAPI existed. Every feature died and the user got a uvicorn traceback
plus "Backend did not become healthy within 5 minutes".
These tests pin: the package's heavy exports stay lazy, the backend's utils
imports never pull the model stack, and the deferred failure is actionable and
classified.
"""
from __future__ import annotations
import subprocess
import sys
import pytest
from core.failure import _HINTS, classify
def _in_subprocess(code: str) -> subprocess.CompletedProcess:
"""Run `code` in a clean interpreter — import side effects don't survive
into it, so `sys.modules` assertions actually mean something."""
return subprocess.run(
[sys.executable, "-c", code], capture_output=True, text=True
)
def test_utils_import_does_not_load_the_model_stack():
"""The import that #1229 died on. `profiles.py` needs two regex helpers;
it must not pay for torch/transformers nor die with them."""
proc = _in_subprocess(
"import sys\n"
"from omnivoice.utils.voice_design import heal_design_instruct, sanitize_instruct\n"
"heavy = [m for m in ('torch', 'transformers', 'torchaudio') if m in sys.modules]\n"
"assert not heavy, f'eagerly imported: {heavy}'\n"
"assert 'omnivoice.models.omnivoice' not in sys.modules\n"
"print('OK')\n"
)
assert proc.returncode == 0, proc.stderr
assert "OK" in proc.stdout
def test_importing_the_package_alone_stays_light():
proc = _in_subprocess(
"import sys, omnivoice\n"
"assert 'torch' not in sys.modules, 'omnivoice/__init__ still imports torch'\n"
"print('OK')\n"
)
assert proc.returncode == 0, proc.stderr
assert "OK" in proc.stdout
def test_lazy_exports_still_resolve():
"""`from omnivoice import OmniVoice` must behave exactly as before — only
the timing of the heavy import changed."""
proc = _in_subprocess(
"from omnivoice import OmniVoice, OmniVoiceConfig, OmniVoiceGenerationConfig\n"
"import omnivoice\n"
"assert omnivoice.OmniVoice is OmniVoice\n"
"assert 'OmniVoice' in dir(omnivoice)\n"
"print('OK')\n"
)
assert proc.returncode == 0, proc.stderr
assert "OK" in proc.stdout
def test_unknown_attribute_still_raises_attribute_error():
import omnivoice
with pytest.raises(AttributeError):
omnivoice.NoSuchThing
def test_audio_tokenizer_import_failure_is_actionable_and_classified(monkeypatch):
"""The deferred failure must name the real remedy and land in the class
that carries a repair hint instead of dying unclassified at startup."""
import types
from omnivoice.models import omnivoice as m
# Stand in a transformers whose lazy resolution of the symbol raises, the
# way it does when the torchaudio backend gate fails (the #1229 host).
class _Broken(types.ModuleType):
def __getattr__(self, name):
raise ModuleNotFoundError(
f"Could not import module '{name}'. "
"Are this object's requirements defined correctly?"
)
monkeypatch.setitem(sys.modules, "transformers", _Broken("transformers"))
with pytest.raises(ImportError) as excinfo:
m._audio_tokenizer_cls()
msg = str(excinfo.value)
assert "torchaudio" in msg
assert "--reinstall" in msg
assert classify(msg) == "TRANSFORMERS_IMPORT"
assert "torchaudio" in _HINTS["TRANSFORMERS_IMPORT"]
def test_audio_tokenizer_returns_the_class_when_importable():
from omnivoice.models import omnivoice as m
transformers = pytest.importorskip("transformers")
assert m._audio_tokenizer_cls() is transformers.HiggsAudioV2TokenizerModel
+174
View File
@@ -0,0 +1,174 @@
"""#1223: a port conflict must exit with a code the shell can recognise.
The reporter's backend died with `[Errno 10048] error while attempting to bind
on address ('127.0.0.1', 3900)` port already taken, almost certainly by an
orphan from a previous session. uvicorn re-raised the bare OSError, Python
exited 1, and the desktop shell reported "Backend died (exit code 1)" with no
cause: the Windows wording is OS-translated (the report was in Russian), so no
English phrase in the log could be matched.
The fix is to make the signal locale-independent a dedicated exit code that
`frontend/src-tauri/src/backend.rs` and `frontend/src/utils/backendCrash.ts`
both key off. This test pins the code and its cross-language agreement; the
matcher side is pinned in frontend/src/test/portInUseHint.test.js.
"""
from __future__ import annotations
import os
import re
import socket
import subprocess
import sys
import pytest
_REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
_EXPECTED_EXIT = 78 # EX_CONFIG
def _read(*parts: str) -> str:
with open(os.path.join(_REPO, *parts), encoding="utf-8") as fh:
return fh.read()
def test_backend_declares_the_exit_code():
src = _read("backend", "main.py")
assert f"_EXIT_PORT_IN_USE = {_EXPECTED_EXIT}" in src
def test_rust_shell_agrees_on_the_exit_code():
"""The Rust side reads this code to distinguish a conflict from a crash —
a silent divergence would restore the unexplained "exit code 1"."""
src = _read("frontend", "src-tauri", "src", "backend.rs")
match = re.search(r"pub const EXIT_PORT_IN_USE: i32 = (\d+);", src)
assert match, "EXIT_PORT_IN_USE missing from backend.rs"
assert int(match.group(1)) == _EXPECTED_EXIT
def test_frontend_crash_hint_agrees_on_the_exit_code():
src = _read("frontend", "src", "utils", "backendCrash.ts")
assert f"marker.exit_code === {_EXPECTED_EXIT}" in src
@pytest.mark.parametrize("errno", [48, 98, 10048])
def test_every_platforms_eaddrinuse_is_recognised(errno):
"""EADDRINUSE is 48 on macOS/BSD, 98 on Linux, 10048 on Windows. Matching
the errno rather than the message is the whole point the message is
translated by the OS."""
src = _read("backend", "main.py")
match = re.search(r"errno in \(([\d, ]+)\)", src)
assert match, "errno guard missing from main.py"
assert str(errno) in {p.strip() for p in match.group(1).split(",")}
def test_uvicorn_swallows_the_bind_error_into_systemexit(tmp_path):
"""The assumption the first version of this fix got wrong.
`except OSError` around `uvicorn.run()` looks obviously right and is
inert: uvicorn catches the bind failure inside its own startup, logs the
raw errno, and raises `SystemExit(1)`. Nothing propagates. This test
documents that behaviour against the real installed uvicorn, so a future
refactor back to the "obvious" shape fails here instead of silently
restoring "Backend died (exit code 1)".
"""
holder = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
holder.bind(("127.0.0.1", 0))
holder.listen(1)
port = holder.getsockname()[1]
try:
script = tmp_path / "naive.py"
script.write_text(
"import sys\n"
"import uvicorn\n"
"from fastapi import FastAPI\n"
"try:\n"
f" uvicorn.run(FastAPI(), host='127.0.0.1', port={port}, "
"log_level='critical')\n"
"except OSError:\n"
" print('OSERROR', file=sys.stderr); sys.exit(78)\n",
encoding="utf-8",
)
proc = subprocess.run(
[sys.executable, str(script)], capture_output=True, text=True
)
assert "OSERROR" not in proc.stderr, (
"uvicorn now propagates the bind OSError — the pre-probe in "
"main.py can be simplified, but verify before doing so"
)
assert proc.returncode == 1
finally:
holder.close()
def test_real_bind_conflict_exits_with_the_dedicated_code(tmp_path):
"""End-to-end against the REAL uvicorn: hold a port, run main.py's guard
shape against it, and confirm the process exits 78 with an actionable
message not uvicorn's bare exit 1.
Reproduces the guard rather than booting the whole backend (a real boot
downloads models), but drives genuine `uvicorn.run` so the swallowed-
SystemExit trap above cannot silently reappear.
"""
holder = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
holder.bind(("127.0.0.1", 0))
holder.listen(1)
port = holder.getsockname()[1]
try:
guard = _read("backend", "main.py")
start = guard.index(" def _port_taken(")
end = guard.index(" # #1223: uvicorn does NOT")
body = "\n".join(line[4:] for line in guard[start:end].splitlines())
script = tmp_path / "guarded.py"
script.write_text(
"import socket, sys\n"
"import uvicorn\n"
"from fastapi import FastAPI\n"
f"_EXIT_PORT_IN_USE = {_EXPECTED_EXIT}\n"
f"_port = {port}\n"
"app = FastAPI()\n"
+ body
+ "\n"
"if (_e := _port_taken('127.0.0.1', _port)) is not None:\n"
" _fail_port_in_use(_e)\n"
"try:\n"
" uvicorn.run(app, host='127.0.0.1', port=_port, log_level='critical')\n"
"except SystemExit:\n"
" if _port_taken('127.0.0.1', _port) is not None:\n"
" _fail_port_in_use(None)\n"
" raise\n",
encoding="utf-8",
)
proc = subprocess.run(
[sys.executable, str(script)], capture_output=True, text=True
)
assert proc.returncode == _EXPECTED_EXIT, (
f"expected exit {_EXPECTED_EXIT}, got {proc.returncode}\n{proc.stderr}"
)
assert "already in use" in proc.stderr
finally:
holder.close()
def test_the_probe_does_not_false_positive_on_a_free_port(tmp_path):
"""A free port must start normally. The probe uses uvicorn's own socket
options (SO_REUSEADDR off Windows) precisely so a TIME_WAIT socket uvicorn
could bind isn't reported as taken."""
guard = _read("backend", "main.py")
start = guard.index(" def _port_taken(")
end = guard.index(" def _fail_port_in_use(")
body = "\n".join(line[4:] for line in guard[start:end].splitlines())
free = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
free.bind(("127.0.0.1", 0))
port = free.getsockname()[1]
free.close() # now free (possibly TIME_WAIT)
script = tmp_path / "probe.py"
script.write_text(
"import socket, sys\n" + body + "\n"
f"print('TAKEN' if _port_taken('127.0.0.1', {port}) is not None else 'FREE')\n",
encoding="utf-8",
)
proc = subprocess.run([sys.executable, str(script)], capture_output=True, text=True)
assert proc.stdout.strip() == "FREE", proc.stderr
+289
View File
@@ -0,0 +1,289 @@
"""#1228: ROCm hosts were force-routed to CPU on *every* AMD GPU.
The SM-arch compatibility gate compared a CUDA-namespace tag (``sm_115``,
derived from ``get_device_capability()``) against ``torch.cuda.get_arch_list()``
which on a ROCm wheel returns **gfx names** (``gfx1100``, ``gfx1201``, ).
The two namespaces can never intersect, so ``check_device_compatibility()``
returned False for every ROCm build and ``get_best_device()`` silently returned
``"cpu"``. The reporter's Radeon 8060S (Strix Halo, gfx1151) was visible to
torch, reported by ``torch.cuda.is_available()``, and still ran on the CPU.
These tests pin the CUDA/ROCm-aware comparison (``core.device_caps
.arch_unsupported``), its three consumers, and the narrowed HSA override.
"""
from __future__ import annotations
import types
import pytest
from core import device_caps
from core.device_caps import KERNEL_RISK_MARKER
# A rocm7.2 wheel's real arch list shape; gfx1151 is natively supported there.
ROCM_ARCHS = ["gfx900", "gfx906", "gfx90a", "gfx942", "gfx1030",
"gfx1100", "gfx1101", "gfx1102", "gfx1151", "gfx1200"]
def _torch(*, hip=None, capability=(11, 5), gcn_arch="gfx1151:xnack-",
arch_list=None, device_name="Radeon 8060S Graphics",
cuda_available=True):
"""Minimal torch mock: CUDA build when ``hip`` is None, else a ROCm build."""
cuda = types.SimpleNamespace(
is_available=lambda: cuda_available,
device_count=lambda: 1,
get_device_name=lambda i=0: device_name,
mem_get_info=lambda: (4 * 1024 ** 3, 8 * 1024 ** 3),
get_device_capability=lambda i=0: capability,
get_device_properties=lambda i=0: types.SimpleNamespace(gcnArchName=gcn_arch),
get_arch_list=lambda: list(arch_list if arch_list is not None else []),
)
version = types.SimpleNamespace()
if hip is not None:
version.hip = hip
return types.SimpleNamespace(
cuda=cuda,
version=version,
backends=types.SimpleNamespace(
mps=types.SimpleNamespace(is_available=lambda: False)
),
)
@pytest.fixture(autouse=True)
def _no_hsa_override(monkeypatch):
# Bind the real cached probe up front — a test may monkeypatch the module
# attribute, and this fixture's teardown runs before monkeypatch's undo.
clear = device_caps.detect_host_caps.cache_clear
monkeypatch.delenv("HSA_OVERRIDE_GFX_VERSION", raising=False)
clear()
yield
clear()
# ── the comparison itself ────────────────────────────────────────────────
def test_rocm_gfx_in_build_is_supported():
"""The regression: sm_115 vs a gfx list used to read as a mismatch."""
torch = _torch(hip="7.2.4", arch_list=ROCM_ARCHS)
assert device_caps.arch_unsupported(torch) is None
def test_rocm_feature_suffixes_are_ignored():
"""``gfx90a:xnack+`` on either side must still match ``gfx90a``."""
torch = _torch(hip="6.2", capability=(9, 0), gcn_arch="gfx90a:sramecc+:xnack-",
arch_list=["gfx908", "gfx90a:xnack+"])
assert device_caps.arch_unsupported(torch) is None
def test_rocm_genuine_mismatch_still_detected():
"""A real ROCm arch mismatch must not be papered over by the fix."""
torch = _torch(hip="6.2", capability=(10, 1), gcn_arch="gfx1010",
arch_list=["gfx1030", "gfx1100"])
assert device_caps.arch_unsupported(torch) == ("gfx1010", ("gfx1030", "gfx1100"))
def test_rocm_gpu_we_can_remap_is_not_a_mismatch():
"""gfx1102 is absent from this build but _configure_rocm_if_needed() remaps
it to gfx1100, which this build DOES ship."""
torch = _torch(hip="6.2", capability=(11, 2), gcn_arch="gfx1102",
arch_list=["gfx1030", "gfx1100"])
assert device_caps.arch_unsupported(torch) is None
def test_a_remap_target_the_build_lacks_is_still_a_mismatch():
"""Review finding (#1230): being IN the override map was treated as proof
of compatibility. If the wheel ships neither the native arch nor the remap
target, the override only changes which kernel is missing the host must
still fall back to CPU rather than launch into a guaranteed failure."""
torch = _torch(hip="6.2", capability=(11, 5), gcn_arch="gfx1151",
arch_list=["gfx900", "gfx1030"]) # no gfx1100
assert device_caps.arch_unsupported(torch) == ("gfx1151", ("gfx900", "gfx1030"))
def test_hsa_override_string_is_derived_from_the_target():
assert device_caps.hsa_override_for("gfx1100") == "11.0.0"
assert device_caps.hsa_override_for("gfx1030") == "10.3.0"
assert device_caps.hsa_override_for("gfx906") == "9.0.6"
assert device_caps.hsa_override_for("gfx900") == "9.0.0"
def test_every_override_target_has_a_valid_hsa_form():
for source, target in device_caps.ROCM_GFX_OVERRIDES.items():
assert target.startswith("gfx"), source
device_caps.hsa_override_for(target) # must not raise
def test_rocm_user_hsa_override_is_trusted_when_the_build_has_the_target(monkeypatch):
monkeypatch.setenv("HSA_OVERRIDE_GFX_VERSION", "11.0.0")
torch = _torch(hip="6.2", capability=(10, 1), gcn_arch="gfx1010",
arch_list=["gfx1030", "gfx1100"])
assert device_caps.arch_unsupported(torch) is None
def test_a_stale_hsa_override_does_not_buy_a_free_pass(monkeypatch):
"""Review finding (#1228): ANY override was treated as proof of
compatibility. The reporter had HSA_OVERRIDE_GFX_VERSION=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 instead of falling back
to CPU."""
monkeypatch.setenv("HSA_OVERRIDE_GFX_VERSION", "11.0.0")
torch = _torch(hip="6.2", capability=(11, 5), gcn_arch="gfx1151",
arch_list=["gfx900", "gfx1030"]) # no gfx1100
result = device_caps.arch_unsupported(torch)
assert result is not None
assert "gfx1100" in result[0]
assert "HSA_OVERRIDE_GFX_VERSION=11.0.0" in result[0]
def test_an_unparseable_hsa_override_is_left_alone(monkeypatch):
"""The user asked for something we don't understand; guessing is worse
than trusting them."""
monkeypatch.setenv("HSA_OVERRIDE_GFX_VERSION", "something-custom")
torch = _torch(hip="6.2", capability=(11, 5), gcn_arch="gfx1151",
arch_list=["gfx900"])
assert device_caps.arch_unsupported(torch) is None
def test_hsa_override_parsing_round_trips():
for target in ("gfx1100", "gfx1030", "gfx906", "gfx900"):
assert device_caps.gfx_for_hsa_override(
device_caps.hsa_override_for(target)
) == target
for junk in ("garbage", "11.0", "", "11.0.0.0", "a.b.c"):
assert device_caps.gfx_for_hsa_override(junk) is None
def test_cuda_path_unchanged():
"""Blackwell sm_120 on a ≤sm_90 build is still reported unsupported (#756)."""
torch = _torch(capability=(12, 0), arch_list=["sm_80", "sm_86", "sm_90"])
assert device_caps.arch_unsupported(torch) == ("sm_120", ("sm_80", "sm_86", "sm_90"))
ok = _torch(capability=(8, 6), arch_list=["sm_80", "sm_86", "sm_90"])
assert device_caps.arch_unsupported(ok) is None
def test_compute_tag_still_matches():
torch = _torch(capability=(12, 0), arch_list=["sm_90", "compute_120"])
assert device_caps.arch_unsupported(torch) is None
def test_empty_arch_list_and_broken_metadata_fail_open():
assert device_caps.arch_unsupported(_torch(hip="6.2", arch_list=[])) is None
broken = _torch(hip="6.2", arch_list=ROCM_ARCHS)
broken.cuda.get_device_properties = lambda i=0: (_ for _ in ()).throw(
RuntimeError("HIP driver died")
)
assert device_caps.arch_unsupported(broken) is None
# ── consumer 1: the probe's kernel-risk note ─────────────────────────────
def test_probe_emits_no_kernel_risk_note_on_supported_rocm_host(monkeypatch):
monkeypatch.setitem(__import__("sys").modules, "torch",
_torch(hip="7.2.4", arch_list=ROCM_ARCHS))
caps = device_caps.refresh()
assert caps.family == "rocm"
assert not any(KERNEL_RISK_MARKER in n for n in caps.notes), caps.notes
# ── consumer 2: get_best_device() / check_device_compatibility() ─────────
def test_rocm_host_routes_to_gpu_not_cpu(monkeypatch):
"""End-to-end: the reporter's host must resolve to "cuda", not "cpu"."""
import services.model_manager as mm
torch = _torch(hip="7.2.4", arch_list=ROCM_ARCHS)
monkeypatch.setattr(mm, "_lazy_torch", lambda: torch)
monkeypatch.setattr(
"core.device_caps.detect_host_caps",
lambda: types.SimpleNamespace(family="rocm"),
)
monkeypatch.delenv("OMNIVOICE_FORCE_CUDA", raising=False)
assert mm.check_device_compatibility() == (True, None)
assert mm.get_best_device() == "cuda"
def test_rocm_warning_names_the_rocm_remedy(monkeypatch):
"""A genuine ROCm mismatch must not tell the user to install a CUDA wheel."""
import services.model_manager as mm
torch = _torch(hip="6.2", capability=(10, 1), gcn_arch="gfx1010",
arch_list=["gfx1030", "gfx1100"])
monkeypatch.setattr(mm, "_lazy_torch", lambda: torch)
compatible, warning = mm.check_device_compatibility()
assert compatible is False
assert "HSA_OVERRIDE_GFX_VERSION" in warning
assert "cu128" not in warning
# ── consumer 3: the HSA override is a fallback, not a rewrite ───────────
def test_native_support_skips_the_hsa_override(monkeypatch):
"""gfx1151 is native on ROCm 7.x — overriding it onto gfx1100 would force a
supported GPU onto foreign kernels."""
import services.model_manager as mm
mm._configure_rocm_if_needed(_torch(hip="7.2.4", arch_list=ROCM_ARCHS))
assert "HSA_OVERRIDE_GFX_VERSION" not in __import__("os").environ
def test_override_applied_when_build_lacks_the_arch(monkeypatch):
import os
import services.model_manager as mm
mm._configure_rocm_if_needed(
_torch(hip="6.2", capability=(11, 2), gcn_arch="gfx1102",
device_name="AMD Radeon RX 7600",
arch_list=["gfx1030", "gfx1100"])
)
assert os.environ.get("HSA_OVERRIDE_GFX_VERSION") == "11.0.0"
def test_no_override_when_the_target_is_also_missing():
"""Review finding (#1230): pointing HSA_OVERRIDE_GFX_VERSION at an arch the
build doesn't ship is not a fix. Leave it unset so the compatibility check
reports the real mismatch and the CPU fallback engages."""
import os
import services.model_manager as mm
mm._configure_rocm_if_needed(
_torch(hip="6.2", capability=(11, 5), gcn_arch="gfx1151",
device_name="AMD Radeon 8060S", arch_list=["gfx900", "gfx1030"])
)
assert "HSA_OVERRIDE_GFX_VERSION" not in os.environ
def test_unknown_arch_metadata_never_triggers_a_remap():
"""Review finding (#1230): an EMPTY arch list means the build's metadata is
unavailable, not that the GPU is unsupported. Treating unknown as a
confirmed mismatch would push a natively-supported gfx1151 onto foreign
gfx1100 kernels. Fail open."""
import os
import services.model_manager as mm
mm._configure_rocm_if_needed(
_torch(hip="7.2.4", capability=(11, 5), gcn_arch="gfx1151",
device_name="AMD Radeon 8060S", arch_list=[])
)
assert "HSA_OVERRIDE_GFX_VERSION" not in os.environ
def test_nvidia_never_gets_an_hsa_override():
import os
import services.model_manager as mm
mm._configure_rocm_if_needed(
_torch(capability=(8, 9), device_name="NVIDIA GeForce RTX 4090",
arch_list=["sm_89"])
)
assert "HSA_OVERRIDE_GFX_VERSION" not in os.environ
+191
View File
@@ -0,0 +1,191 @@
"""#1227 / #1221: two synth failures that dead-ended in the catch-all.
``_oom_friendly_reraise`` classifies every known way a generate can die and
re-raises with the real remedy; anything it doesn't recognize surfaces as
"TTS engine stopped mid-generation with an error OmniVoice doesn't recognize".
Two real reports landed there:
* #1227 — ``OSError: [WinError 4551] An Application Control policy has blocked
this file``. Windows Smart App Control refused to load an engine binary;
nothing about memory, and the Flush button can't help.
* #1221 — ``LibsndfileError: System error.``, libsndfile's bare wording for an
OS-level audio read/write failure. No path, no errno, no next step.
These tests pin both classes, the shared docs taxonomy, and the write-path
diagnosis that turns "System error." into a message naming the target file.
"""
from __future__ import annotations
import os
import pytest
from core.failure import _HINTS, classify
@pytest.fixture
def reraise(monkeypatch):
"""`_oom_friendly_reraise` with its cache-flush side effects stubbed out."""
from api.routers import generation as gen
import types
fake_torch = types.SimpleNamespace(
backends=types.SimpleNamespace(
mps=types.SimpleNamespace(is_available=lambda: False)
),
cuda=types.SimpleNamespace(is_available=lambda: False),
)
monkeypatch.setitem(__import__("sys").modules, "torch", fake_torch)
return gen._oom_friendly_reraise
# ── #1227: Windows Application Control ───────────────────────────────────
@pytest.mark.parametrize(
"raw",
[
"[WinError 4551] An Application Control policy has blocked this file",
"OSError: [WinError 1260] This program is blocked by group policy",
# Localised Windows: the message text is translated, the code is not.
"OSError: [WinError 4551] Politique de contrôle des applications",
],
)
def test_app_control_block_is_named_not_called_unrecognized(reraise, raw):
with pytest.raises(RuntimeError) as excinfo:
reraise(OSError(raw))
msg = str(excinfo.value)
assert "doesn't recognize" not in msg
assert "Smart App Control" in msg
assert "Flush button won't help" in msg
def test_app_control_block_has_a_docs_class_and_hint():
raw = "[WinError 4551] An Application Control policy has blocked this file"
assert classify(raw) == "WINDOWS_APP_CONTROL_BLOCKED"
assert "Smart App Control" in _HINTS["WINDOWS_APP_CONTROL_BLOCKED"]
def test_app_control_block_is_not_reported_as_out_of_memory(reraise):
"""The #880 class bug: an unknown error used to claim OOM. It must not
come back for this one."""
with pytest.raises(RuntimeError) as excinfo:
reraise(OSError("[WinError 4551] An Application Control policy has blocked this file"))
assert "out of memory" not in str(excinfo.value).lower()
# ── #1221: libsndfile ────────────────────────────────────────────────────
def test_libsndfile_failure_is_named_not_called_unrecognized(reraise):
with pytest.raises(RuntimeError) as excinfo:
reraise(RuntimeError("LibsndfileError: System error."))
msg = str(excinfo.value)
assert "doesn't recognize" not in msg
assert "not a memory one" in msg
assert "antivirus" in msg
def test_libsndfile_failure_has_a_docs_class_and_hint():
assert classify("LibsndfileError: System error.") == "AUDIO_IO_FAILED"
assert "writable" in _HINTS["AUDIO_IO_FAILED"]
# ── #1221: the write path names the target ───────────────────────────────
def test_write_failure_names_the_target_and_its_drive(tmp_path):
"""A bare libsndfile error must come back naming the file, the folder's
writability, and the free space the facts that identify the cause."""
import soundfile as sf
from services.audio_io import _describe_write_failure
target = tmp_path / "out" / "speech.wav"
err = sf.LibsndfileError(1) # takes an int code, not a message
described = _describe_write_failure(err, str(target))
assert isinstance(described, RuntimeError)
msg = str(described)
assert str(target) in msg
assert "LibsndfileError" in msg
assert "does not exist" in msg # tmp_path/out was never created
def test_write_failure_reports_free_space_for_a_real_folder(tmp_path):
from services.audio_io import _describe_write_failure
described = _describe_write_failure(OSError("System error."), str(tmp_path / "a.wav"))
assert "MB free" in str(described)
def test_write_failure_diagnosis_is_skipped_for_buffers_and_self_describing_errors(tmp_path):
import io
from services.audio_io import _describe_write_failure
err = OSError("System error.")
assert _describe_write_failure(err, io.BytesIO()) is err
path = str(tmp_path / "a.wav")
already = FileNotFoundError(2, "No such file", path)
assert _describe_write_failure(already, path) is already
def test_write_failure_diagnosis_never_replaces_the_real_error():
"""Best-effort: a broken path argument must not mask the failure."""
from services.audio_io import _describe_write_failure
err = OSError("System error.")
assert _describe_write_failure(err, os.devnull) is not None
def test_save_reraises_with_the_target_named(tmp_path, monkeypatch):
"""End-to-end through _safe_torchaudio_save, the #1221 code path."""
import torch
from services import audio_io
def _boom(*_a, **_k):
raise RuntimeError("Error opening 'x': System error.")
monkeypatch.setattr(audio_io.torchaudio, "save", _boom)
target = tmp_path / "speech.wav"
with pytest.raises(RuntimeError) as excinfo:
audio_io._safe_torchaudio_save(str(target), torch.zeros(1, 100), 24000)
assert str(target) in str(excinfo.value)
# Review finding (#1233): this used to allow "" as a pass, which hid the
# fact that the ENRICHED message no longer contains the word "libsndfile"
# and so classified as nothing — leaving bug reports and docs links
# unclassified for exactly the failure this PR is about.
assert classify(str(excinfo.value)) == "AUDIO_IO_FAILED"
def test_unrelated_open_failures_keep_their_own_guidance(reraise):
"""Review finding (#1233): matching the generic phrase "error opening"
handed the audio-file remedy to ANY failure that mentioned it a model,
archive or config file that won't open. Classification keys off a marker
audio_io emits, not on wording other subsystems share."""
raw = "Error opening model archive: /models/x/config.json is corrupt"
assert classify(raw) != "AUDIO_IO_FAILED"
with pytest.raises(RuntimeError) as excinfo:
reraise(RuntimeError(raw))
msg = str(excinfo.value)
assert "libsndfile" not in msg
assert "antivirus" not in msg
def test_the_marker_is_shared_not_duplicated():
"""core/ cannot import services/, so the marker lives in audio_io and
failure.py matches its lowercased text. Pinned through classify() itself,
not through getsource the literal could survive in a comment while the
classifier stopped using it (#1221 review)."""
from services.audio_io import AUDIO_WRITE_FAILED_MARKER
assert classify(AUDIO_WRITE_FAILED_MARKER) == "AUDIO_IO_FAILED"
assert classify(AUDIO_WRITE_FAILED_MARKER.upper()) == "AUDIO_IO_FAILED"
+340
View File
@@ -0,0 +1,340 @@
"""#1224: a truncated model download aborted the install instead of retrying.
The reporter's log tail, captured just before the backend was SIGKILLed:
httpx.RemoteProtocolError: peer closed connection without sending complete
message body (received 4084175097 bytes, expected 4580080592)
That is a 4.6 GB model dying at 4.0 GB the single most retry-worthy failure
in the whole download path, and it was retried nowhere:
* the installer's retry loop caught ``(HfHubHTTPError, LocalEntryNotFoundError,
OSError)``. ``httpx.RemoteProtocolError`` inherits ``Exception``, NOT
``OSError``, so it escaped all five attempts;
* ``is_hf_connectivity_error`` the single source of truth for "transient
download failure" — had no truncation signature, so even a widened catch
would have classified it as permanent;
* the engine load path (``VoxCPM.from_pretrained``) had no retry at all.
The HF cache is resumable (correctly-sized blobs are skipped by hash), so a
retry continues rather than restarting which is what makes retrying correct
here and not merely hopeful.
"""
from __future__ import annotations
import pytest
from core.failure import is_hf_connectivity_error
from services import tts_backend
# ── classification ───────────────────────────────────────────────────────
def test_the_reporters_error_is_recognised_as_transient():
assert is_hf_connectivity_error(
"peer closed connection without sending complete message body "
"(received 4084175097 bytes, expected 4580080592)"
)
@pytest.mark.parametrize(
"reason",
[
# urllib3 / http.client wording for the same truncation.
"IncompleteRead(4084175097 bytes read, 495905495 more expected)",
"ProtocolError('Connection broken: IncompleteRead(…)')",
"http.client.IncompleteRead: incomplete read",
"Response ended prematurely",
],
)
def test_other_truncation_wordings_are_recognised(reason):
assert is_hf_connectivity_error(reason)
def test_a_real_failure_is_still_permanent():
"""Widening the net must not make genuine errors retry forever."""
assert not is_hf_connectivity_error("401 Unauthorized: invalid token")
assert not is_hf_connectivity_error("No such file or directory: config.json")
assert not is_hf_connectivity_error("CUDA out of memory")
# ── the engine load path retries ─────────────────────────────────────────
@pytest.fixture(autouse=True)
def _no_backoff(monkeypatch):
monkeypatch.setenv("OMNIVOICE_MODEL_LOAD_BACKOFF_S", "0")
def test_truncated_download_is_retried_and_succeeds(monkeypatch):
calls = []
def loader():
calls.append(1)
if len(calls) < 3:
raise RuntimeError(
"peer closed connection without sending complete message body"
)
return "model"
assert tts_backend._retry_once_with_fresh_hf_client(loader, "VoxCPM2") == "model"
assert len(calls) == 3
def test_retries_are_bounded(monkeypatch):
monkeypatch.setenv("OMNIVOICE_MODEL_LOAD_RETRIES", "2")
calls = []
def loader():
calls.append(1)
raise RuntimeError("peer closed connection without sending complete message body")
with pytest.raises(RuntimeError):
tts_backend._retry_once_with_fresh_hf_client(loader, "VoxCPM2")
assert len(calls) == 2
def test_a_non_transient_failure_is_not_retried():
calls = []
def loader():
calls.append(1)
raise ValueError("checkpoint has no config.json")
with pytest.raises(ValueError):
tts_backend._retry_once_with_fresh_hf_client(loader, "VoxCPM2")
assert len(calls) == 1, "a permanent failure must fail fast, not retry"
def test_the_closed_client_path_still_resets_the_session(monkeypatch):
"""#880's behaviour must survive the widening."""
reset = []
import huggingface_hub.utils as hub_utils
monkeypatch.setattr(hub_utils, "close_session", lambda: reset.append(1), raising=False)
calls = []
def loader():
calls.append(1)
if len(calls) == 1:
raise RuntimeError("Cannot send a request, as the client has been closed.")
return "model"
assert tts_backend._retry_once_with_fresh_hf_client(loader, "VoxCPM2") == "model"
assert reset, "the HF session must be reset before retrying a closed client"
def test_the_closed_client_path_stays_single_shot(monkeypatch):
"""The two failure shapes get deliberately different budgets. A closed
client is 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. Only
the transient-download path gets the multi-attempt budget."""
monkeypatch.setenv("OMNIVOICE_MODEL_LOAD_RETRIES", "5")
calls = []
def loader():
calls.append(1)
raise RuntimeError("Cannot send a request, as the client has been closed.")
with pytest.raises(RuntimeError):
tts_backend._retry_once_with_fresh_hf_client(loader, "VoxCPM2")
assert len(calls) == 2, "closed-client must stay single-shot regardless of the budget"
def test_voxcpm2_load_actually_retries_a_truncated_download(monkeypatch):
"""The #1224 call site itself, exercised — not grepped.
An earlier version of this test asserted the wrapper's NAME appeared in the
method source, which would have passed even if the wrapper were called with
the wrong argument or its result discarded (#1224 review)."""
import sys
import types
calls = []
class _FakeVoxCPM:
@staticmethod
def from_pretrained(checkpoint, **kw):
calls.append(checkpoint)
if len(calls) < 3:
raise RuntimeError(
"peer closed connection without sending complete message body"
)
return f"model:{checkpoint}"
monkeypatch.setitem(
sys.modules, "voxcpm", types.SimpleNamespace(VoxCPM=_FakeVoxCPM)
)
monkeypatch.setenv("OMNIVOICE_VOXCPM_MODEL", "openbmb/VoxCPM2")
monkeypatch.setattr(
tts_backend.VoxCPM2Backend, "is_available", classmethod(lambda cls: (True, ""))
)
monkeypatch.setattr(tts_backend, "_voxcpm_upgrade_hint", lambda: None)
backend = tts_backend.VoxCPM2Backend()
backend._ensure_loaded()
assert backend._model == "model:openbmb/VoxCPM2"
assert len(calls) == 3, "the load must retry, not fail on the first truncation"
def test_voxcpm2_load_still_fails_fast_on_a_real_error(monkeypatch):
import sys
import types
calls = []
class _FakeVoxCPM:
@staticmethod
def from_pretrained(checkpoint, **kw):
calls.append(1)
raise ValueError("checkpoint has no config.json")
monkeypatch.setitem(
sys.modules, "voxcpm", types.SimpleNamespace(VoxCPM=_FakeVoxCPM)
)
monkeypatch.setattr(
tts_backend.VoxCPM2Backend, "is_available", classmethod(lambda cls: (True, ""))
)
monkeypatch.setattr(tts_backend, "_voxcpm_upgrade_hint", lambda: None)
with pytest.raises(ValueError):
tts_backend.VoxCPM2Backend()._ensure_loaded()
assert len(calls) == 1
# ── the installer's retry loop catches it ────────────────────────────────
def test_installer_retries_a_real_remoteprotocolerror():
"""The #1224 root cause, against a REAL httpx exception instance.
An earlier version asserted `"is_hf_connectivity_error" in getsource(...)`,
which passed merely because the module imports it it would not have
noticed the loop ignoring the classifier entirely (#1224 review)."""
import httpx
from api.routers.setup.download import _is_retryable_download_error
truncated = httpx.RemoteProtocolError(
"peer closed connection without sending complete message body "
"(received 4084175097 bytes, expected 4580080592)"
)
assert not isinstance(truncated, OSError), (
"if this ever becomes an OSError the original bug is gone, but the "
"classification path must still hold"
)
assert _is_retryable_download_error(truncated)
def test_installer_does_not_retry_a_cancel_or_a_real_error():
from api.routers.setup.download import (
_InstallCancelled,
_is_retryable_download_error,
)
assert not _is_retryable_download_error(_InstallCancelled())
assert not _is_retryable_download_error(ValueError("no such repo"))
assert not _is_retryable_download_error(RuntimeError("401 Unauthorized"))
@pytest.mark.parametrize("status", [401, 403, 404, 410])
def test_installer_does_not_retry_a_settled_hub_verdict(status):
"""Review finding (#1224): every HfHubHTTPError was retried, so a wrong
token or a gated repo burned all five attempts with backoff before showing
the user the same message. The verdict is settled fail fast."""
import httpx
from huggingface_hub.utils import HfHubHTTPError
from api.routers.setup.download import _is_retryable_download_error
response = httpx.Response(status, request=httpx.Request("GET", "https://hf.co/x"))
assert not _is_retryable_download_error(
HfHubHTTPError(f"{status} Client Error", response=response)
)
def test_installer_still_retries_a_server_side_hub_error():
"""A 5xx (or a rate-limit) is transient and must keep retrying."""
import httpx
from huggingface_hub.utils import HfHubHTTPError
from api.routers.setup.download import _is_retryable_download_error
for status in (429, 500, 503):
response = httpx.Response(
status, request=httpx.Request("GET", "https://hf.co/x")
)
assert _is_retryable_download_error(
HfHubHTTPError(f"{status} Error", response=response)
), status
def test_installer_still_retries_the_original_type_based_cases():
"""Widening to classification must not drop what the old tuple caught."""
from huggingface_hub.utils import LocalEntryNotFoundError
from api.routers.setup.download import _is_retryable_download_error
assert _is_retryable_download_error(OSError("connection reset by peer"))
assert _is_retryable_download_error(LocalEntryNotFoundError("offline"))
# ── the streaming path leaves an OOM breadcrumb ──────────────────────────
def test_stream_path_checks_memory_before_loading():
"""The reporter was SIGKILLed on a 16 GB Mac. /generate has logged a
low-memory advisory since the earlier reports of that class, but the
STREAMING path which the desktop UI tries first did not, so the load
most likely to tip the machine over left no trail in the captured stderr
tail a SIGKILL report has to go on.
Structural rather than behavioural: reaching the call needs a live
WebSocket session. Kept honest by also resolving the symbol it names, so a
rename or removal on either side fails here (#1224 review).
"""
import inspect
from api.routers import tts_stream
from services.memory_budget import log_if_low
assert callable(log_if_low)
src = inspect.getsource(tts_stream)
assert "from services.memory_budget import log_if_low" in src
assert "log_if_low(f\"TTS stream load" in src
def test_the_two_budgets_do_not_share_a_counter(monkeypatch):
"""Review finding (#1224): the closed-client reset incremented the same
counter as the download retries, so a session reset followed by transient
failures left a resumable multi-GB download one attempt short of its
configured budget."""
monkeypatch.setenv("OMNIVOICE_MODEL_LOAD_RETRIES", "3")
import huggingface_hub.utils as hub_utils
monkeypatch.setattr(hub_utils, "close_session", lambda: None, raising=False)
calls = []
def loader():
calls.append(1)
if len(calls) == 1:
raise RuntimeError("Cannot send a request, as the client has been closed.")
raise RuntimeError("peer closed connection without sending complete message body")
with pytest.raises(RuntimeError):
tts_backend._retry_once_with_fresh_hf_client(loader, "VoxCPM2")
# 1 closed-client + a full budget of 3 download attempts.
assert len(calls) == 4
@pytest.mark.parametrize("bad", ["inf", "-inf", "nan"])
def test_a_non_finite_backoff_falls_back_to_the_default(monkeypatch, bad):
"""Review finding (#1224): `float("inf")` parses fine and then makes
`sleep(inf)` raise OverflowError, replacing a retryable download failure
with an unrelated crash that hides the original error."""
monkeypatch.setenv("OMNIVOICE_MODEL_LOAD_BACKOFF_S", bad)
assert tts_backend._float_env("OMNIVOICE_MODEL_LOAD_BACKOFF_S", 2.0) == 2.0
+156
View File
@@ -0,0 +1,156 @@
"""#1247: `400 Bad Request: Unknown model id: engine:kittentts`.
Reported straight from `EngineCompatibilityMatrix.jsx` the user opened
Settings and pressed Unload on a resident engine.
`list_loaded()` enumerates in-process engines as `engine:<id>` and marks
each ``"unloadable": True``. `unload()` handled `tts`, `diarization`,
`sidecars` and `sidecar:<id>` and nothing else. The panel was offering a
button for an id the dispatcher rejected. The engines themselves have
implemented `unload()` the whole time; only the routing was missing.
The guard at the bottom is the point: the two functions are a *contract*, and
the bug was them disagreeing. Any future id the lister advertises as unloadable
must be one the dispatcher accepts.
"""
from __future__ import annotations
import pytest
from services import model_lifecycle
class _FakeEngine:
"""Stands in for an in-process backend (mlx-audio, kittentts, …)."""
id = "kittentts"
_MODEL_ATTRS = ("_model",)
def __init__(self, loaded=True):
self._model = object() if loaded else None
self.unload_calls = 0
def unload(self):
self.unload_calls += 1
self._model = None
@pytest.fixture
def fake_engines(monkeypatch):
import api.routers.engines as engines_router
instances = {}
monkeypatch.setattr(engines_router, "_ENGINE_INSTANCES", instances, raising=False)
return instances
# ── the reported failure ─────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_unloading_a_resident_engine_no_longer_400s(fake_engines):
engine = _FakeEngine(loaded=True)
fake_engines[type(engine)] = engine
result = await model_lifecycle.unload("engine:kittentts")
assert result["success"] is True
assert result["unloaded"] == "engine:kittentts"
assert engine.unload_calls == 1
assert engine._model is None, "the memory must actually be handed back"
@pytest.mark.asyncio
async def test_an_engine_that_holds_nothing_reports_not_loaded(fake_engines):
"""Same shape as the `tts` / `diarization` branches — a no-op, not an error."""
engine = _FakeEngine(loaded=False)
fake_engines[type(engine)] = engine
result = await model_lifecycle.unload("engine:kittentts")
assert result["success"] is False
assert result["reason"] == "not loaded"
assert engine.unload_calls == 0
@pytest.mark.asyncio
async def test_an_engine_that_is_not_instantiated_reports_not_loaded(fake_engines):
"""A stale panel row (the engine was already evicted) must not 400 either —
the user pressing a button that raced a background eviction did nothing
wrong."""
result = await model_lifecycle.unload("engine:neverloaded")
assert result["success"] is False
assert result["reason"] == "not loaded"
@pytest.mark.asyncio
async def test_the_warm_dictation_asr_can_be_unloaded(monkeypatch):
"""A SECOND instance of the same defect, found by the contract test below
rather than by a user: `capture-asr` was listed as unloadable and the
dispatcher had no branch for it either."""
import services.asr_backend as ab
released = []
monkeypatch.setattr(ab, "_capture_backend", object(), raising=False)
monkeypatch.setattr(
ab, "release_idle_capture_backend",
lambda idle_s, **kw: (released.append(idle_s), True)[1],
raising=False,
)
result = await model_lifecycle.unload("capture-asr")
assert result["success"] is True
assert released == [0.0], "an explicit Unload releases now, not after a timeout"
@pytest.mark.asyncio
async def test_dictation_in_progress_declines_rather_than_yanking_the_model(monkeypatch):
import services.asr_backend as ab
monkeypatch.setattr(ab, "_capture_backend", object(), raising=False)
monkeypatch.setattr(
ab, "release_idle_capture_backend", lambda idle_s, **kw: False, raising=False
)
result = await model_lifecycle.unload("capture-asr")
assert result["success"] is False
assert "dictation" in result["reason"]
@pytest.mark.asyncio
async def test_a_genuinely_unknown_id_still_raises(fake_engines):
"""The 400 is correct for an id nothing advertises — don't lose it."""
with pytest.raises(ValueError, match="Unknown model id"):
await model_lifecycle.unload("banana")
# ── the contract that was broken ─────────────────────────────────────────
@pytest.mark.asyncio
async def test_every_advertised_unloadable_id_is_accepted(fake_engines, monkeypatch):
"""The recurrence guard.
The panel renders an Unload button for every row with ``unloadable: True``.
Advertising an id the dispatcher rejects is precisely #1247, and it will
keep happening as new model kinds are added unless the two sides are
checked against each other.
"""
engine = _FakeEngine(loaded=True)
fake_engines[type(engine)] = engine
listing = model_lifecycle.list_loaded()
advertised = [m["id"] for m in listing["models"] if m.get("unloadable")]
assert "engine:kittentts" in advertised, "fixture engine should be listed"
for model_id in advertised:
try:
await model_lifecycle.unload(model_id)
except ValueError as e: # pragma: no cover — this is the failure mode
pytest.fail(
f"{model_id} is advertised as unloadable but the dispatcher "
f"rejects it: {e}"
)
+77
View File
@@ -0,0 +1,77 @@
"""#1251: "The paging file is too small for this operation to complete."
The reporter's toast was the raw OS string and nothing else:
500 Internal Server Error: The paging file is too small for this operation
to complete. (os error 1455)
`classify()` had no class for it, so `build_failure` attached no hint. The
generate path *did* already count the phrase as an out-of-memory condition
(`_OOM_MSG_SIGNATURES` in api/routers/generation.py), but that lumps it in with
"your RAM is full" and the resulting advice, close other apps and pick a
lighter engine, is the wrong remedy on a 32 GB machine. Windows is not short of
RAM here; it is refusing to back a large memory mapping because the paging file
is capped too low. The fix is a Windows setting, and the hint now says which.
Note the spelling: `os error 1455`, not `[WinError 1455]`. It reaches Python
through the Rust safetensors mmap, so matching only Python's wording missed it.
"""
from __future__ import annotations
import pytest
from core.failure import build_failure, classify
@pytest.mark.parametrize(
"reason",
[
# Exactly as reported — Rust/safetensors spelling.
"The paging file is too small for this operation to complete. (os error 1455)",
# Python's spelling of the same condition.
"[WinError 1455] The paging file is too small for this operation to complete",
# Localised Windows: the code survives, the sentence does not.
"OSError: [WinError 1455] Le fichier de pagination est insuffisant",
],
)
def test_the_paging_file_failure_is_classified(reason):
assert classify(reason) == "WINDOWS_PAGING_FILE_TOO_SMALL"
def test_the_hint_gives_the_windows_setting_that_actually_fixes_it():
failure = build_failure(
OSError("The paging file is too small for this operation to complete. (os error 1455)"),
stage="generate",
)
hint = failure["hint"]
assert hint, "an unclassified 500 with no next step is the bug"
assert "Virtual memory" in hint
# The wrong remedy must not be the headline: this is not a RAM shortage.
assert "paging file" in hint.lower()
def test_a_genuine_out_of_memory_is_not_relabelled():
"""The two conditions want different remedies — don't collapse them."""
assert classify("CUDA out of memory. Tried to allocate 2.00 GiB") != (
"WINDOWS_PAGING_FILE_TOO_SMALL"
)
assert classify("DefaultCPUAllocator: not enough memory") != (
"WINDOWS_PAGING_FILE_TOO_SMALL"
)
def test_an_unrelated_number_1455_does_not_match():
"""The numeric match is deliberately paired with an OS-error marker so a
model dimension, a sample offset or a byte count can't trip it."""
assert classify("Expected 1455 tokens, got 1200") == ""
assert classify("wrote 1455 bytes") == ""
def test_the_generate_path_still_treats_it_as_resource_exhaustion():
"""It IS a resource failure — the retry/queue behaviour keyed off
`_is_oom_failure` must keep working; only the user-facing advice changed."""
from api.routers.generation import _is_oom_failure
assert _is_oom_failure(
OSError("The paging file is too small for this operation to complete. (os error 1455)")
)
+94
View File
@@ -0,0 +1,94 @@
"""#1254: `download: ERROR: [youtube] DZpjig7qxM8: This video is DRM protected`
The reporter's own words:
Importing a YouTube URL intermittently failed with a DRM protection error,
even though the same URL had previously worked and succeeded when retried
shortly afterwards. The failure appears to be transient rather than
consistently reproducible.
A genuinely DRM-protected video does not become downloadable thirty seconds
later. What varies is the **player client**: YouTube serves a DRM-only format
set to some clients for videos that are not actually protected. OmniVoice
already had machinery for exactly this shape `_is_forbidden_download_error`
escalates through `_YT_PLAYER_CLIENTS` on a 403, because "extraction worked but
this client can't have the media" is the same situation. DRM simply wasn't
routed into it, so the user's only recovery was to retry by hand and hope the
next attempt drew a different client.
If every client still reports DRM, the video really is unfetchable and that
now arrives classified, instead of as a raw yt-dlp line.
"""
from __future__ import annotations
import pytest
from core.failure import build_failure, classify
from services.dub_pipeline import (
_is_forbidden_download_error,
_is_transient_download_error,
)
REPORTED = "ERROR: [youtube] DZpjig7qxM8: This video is DRM protected"
# ── it escalates the player client ───────────────────────────────────────
def test_the_reported_error_triggers_client_escalation():
assert _is_forbidden_download_error(RuntimeError(REPORTED))
@pytest.mark.parametrize(
"reason",
[
"This video is DRM protected",
"Requested format is DRM-protected",
"ERROR: [youtube] abc: This video is DRM protected",
],
)
def test_every_drm_wording_escalates(reason):
assert _is_forbidden_download_error(RuntimeError(reason))
def test_the_403_case_it_shares_the_path_with_still_works():
"""#625's behaviour must survive the widening."""
assert _is_forbidden_download_error(RuntimeError("HTTP Error 403: Forbidden"))
def test_drm_does_not_burn_the_transient_retry_budget():
"""Escalation and blind retry are different budgets. Counting DRM as
transient would spend the retries on the same client that just refused
which is what the reporter was doing by hand."""
assert not _is_transient_download_error(RuntimeError(REPORTED))
def test_a_real_network_drop_is_still_transient_and_not_escalated():
"""The two paths must not bleed into each other."""
broken = RuntimeError("Unable to download video: Broken pipe")
assert _is_transient_download_error(broken)
assert not _is_forbidden_download_error(broken)
# ── and if every client fails, the user is told why ──────────────────────
def test_an_exhausted_drm_failure_is_classified():
assert classify(REPORTED) == "VIDEO_DRM_PROTECTED"
def test_the_hint_gives_a_way_forward_rather_than_another_retry():
failure = build_failure(RuntimeError(REPORTED), stage="download")
hint = failure["hint"]
assert hint, "a raw yt-dlp line with no next step is the bug"
# The two things the user can actually do.
assert "drop the file" in hint.lower()
assert "retried" in hint.lower(), "say that retrying was already tried"
def test_an_unrelated_download_failure_keeps_its_own_class():
assert classify("Unable to download video: Broken pipe") == "VIDEO_DOWNLOAD_NETWORK"
assert classify("ERROR: Unsupported URL: https://example.com/profile") == (
"UNSUPPORTED_VIDEO_URL"
)
Generated
+1 -1
View File
@@ -3233,7 +3233,7 @@ wheels = [
[[package]]
name = "omnivoice"
version = "0.4.0"
version = "0.4.1"
source = { editable = "." }
dependencies = [
{ name = "accelerate" },