Compare commits

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Also from review:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Also from review:

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

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

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

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

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

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

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

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

---------

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

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

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

Tests now pin both: identical survivors across runs, and an oversized purge
keeping all of its own markers. Verified across six hash seeds; full suite green
under 12345, the seed that reddened main.
2026-07-27 12:22:18 -07:00
Palash Debnath 9736fd4859 release: v0.4.1 (#1239)
* release: v0.4.1

Seven user-reported issues fixed since v0.4.0 (#1221–#1229). Version bumped
across the single source of truth (frontend/package.json) and its three
toolchain mirrors; [Unreleased] renamed to the release section that
release.yml extracts verbatim as the GitHub Release body.

Docker tag examples in docs/install/docker.md and deploy/dockerhub-overview.md
updated to 0.4.1 (docs-sync rule).

* release: #1239 review — sync Cargo.lock to 0.4.1

Greptile: the manifest said 0.4.1 while Cargo.lock still recorded 0.4.0, so a
`cargo build --locked` (and the Tauri bundler's own locked build) would fail
on the mismatch. Regenerating locally updated it but it was never staged.

* release: re-sync [0.4.1] after the fix merges, date it 2026-07-27

Picks up everything merged since the section was first written: the first-run
wizard chrome (#1241), the MCP host allowlist (#1249), the macOS 12 startup
crash (#1245), and the six error-message fixes (#1247, #1251, #1254, #1256,
#1257, #1262).

Deliberately NOT included:
  - the dub delete-resurrection fix (#1252, #1253) — split to #1270 after it
    needed six rounds of correction, the last two finding that the fix did not
    close the reported case and that its own bound reintroduced it;
  - the Linux AppImage WebKit fix (#1258, #1244) — held on #1265 pending
    confirmation on a Mesa 26.1 host, which nobody has run.
2026-07-26 14:48:05 -07:00
Palash Debnath b66b09ceaa fix(dub): deleting a dub no longer resurrects it (#1252, #1253) (#1270)
* fix(dub): deleting a dub no longer resurrects it (#1252, #1253)

Split out of #1264. The other six fixes there are independent error-message
changes that needed no corrections; this one is a concurrency change that
needed five rounds, each finding something real in work that looked finished
and tested:

  1. review: merge and save were split, so a delete between them had the row
     written straight back;
  2. review: dict membership cannot express 'withdrawn' — an absent key means
     'not written yet' for a new job and 'deleted' for an established one;
  3. fail-before check: the race test was not testing the race (it asserted
     WHAT happened, never WHEN, and 'save after delete' IS the resurrection);
  4. review: the gate sat in two ingest helpers while eight direct save_job
     call sites bypassed it;
  5. direct check of the reported scenario: the tombstone covered in-flight
     ingests only, so a delete during a RENDER — the common case — still
     resurrected the job.

Riding a release on that record is not a good trade, so it ships on its own.

What it does now: the withdrawal is recorded when a job is DELETED, held in a
bounded LRU (there is no moment at which a delete stops mattering), and checked
inside save_job so every caller inherits it. Re-importing an id is the only
thing that revives it. The lock is re-entrant because the atomic helpers call
save_job while holding it.

Carries #1252's message half too: str(KeyError) is the repr of the key, which
is how the user's own job id became the entire error text.

* fix(dub): expire withdrawal markers by age, not by count

Greptile P1 — the sixth real finding on this fix, and reachable by ordinary
use. DELETE /dub/history selects every row with no limit, so a user with a
large history clearing it mid-render pushed that very job's marker out of a
count-bounded LRU, and the render then wrote it straight back.

Age is the honest policy: what matters is how long ago the delete happened, not
how many others followed it. Six hours outlives any realistic render or
transcribe. The count cap stays only as a memory backstop, raised far above any
real history and documented as such.

Verified fail-before: restoring count-based eviction fails both new tests.

* docs(changelog): the dub delete-resurrection fix (#1252, #1253)
2026-07-26 14:25:32 -07:00
Palash Debnath 73ebd6518d fix(errors): six failures that reached users as raw text (#1262, #1256, #1251, #1247, #1257, #1254) (#1264)
* fix(errors): four failures that reached users as raw OS text (#1262, #1256, #1251, #1252)

#1262 — a voice profile named in any non-latin-1 script 500'd every download
endpoint with "'latin-1' codec can't encode characters in position 22-25".
`attachment; filename="` is exactly 22 characters, so those were the first
four characters of the user's own name. The sanitisers in front of the header
filtered with str.isalnum(), which is True for every alphabetic script — they
stripped punctuation and passed exactly what breaks the header. Ten sites, one
RFC 6266 builder, plus a guard so an eleventh can't be hand-written.

#1256 — a synth died on FileNotFoundError: 'ffprobe' and was reported as "an
error OmniVoice doesn't recognize", on a Mac where the app's own ffprobe was
resolvable the whole time. Our call sites pass explicit paths; a dependency
shelling out by bare name does not. The resolved directories are now published
on PATH, and the failure is classified either way.

#1251 — "The paging file is too small" reached the user as a bare 500. It was
already counted as an OOM, but that remedy (close apps, lighter engine) is
wrong on a 32 GB machine — the fix is a Windows setting, and the hint now says
which. Matched on the code in both the Python and Rust spellings.

#1252/#1253 — deleting a dub mid-import crashed it with `ingest: 'mgw39lx3'`:
str(KeyError) is the repr of the key. The pipeline blind-subscripted a job that
DELETE /dub/history/{id} had popped minutes earlier. It now stops quietly, and
no exception whose str() is a bare value can present itself that way again.

* fix(engines): Unload 400'd, a wrong language said nothing, DRM was retried by hand (#1247, #1257, #1254)

#1247 — list_loaded() advertises in-process engines as `engine:<id>` with
"unloadable": true, but unload() only ever handled tts/diarization/sidecars.
The panel was rendering a button for ids the dispatcher rejected. The engines
already implement unload(); only the routing was missing. The contract test
written for it immediately found a second instance — `capture-asr`, listed the
same way with no branch either — which is why it enumerates the listing rather
than hard-coding ids.

#1257 — MLXAudioBackend.supported_languages() returns ["multi"] on the stated
assumption that "each engine silently ignores languages it doesn't know". It
doesn't; the library raises. So the picker offers all 646 languages and the
rejection arrived as a bare list of 23 codes, naming neither the engine nor the
way out. Enumerating each model's real language set would be a brittle map that
goes stale every engine update — name the engine and the fix instead.

#1254 — reported as intermittent: the same URL failed as DRM-protected, then
succeeded on retry. Real DRM doesn't lapse; the player client varies. That is
the same shape as the 403 case which already escalates through
_YT_PLAYER_CLIENTS, so DRM now routes into it. If every client still refuses,
the failure is classified instead of arriving as a raw yt-dlp line.

* fix(review): close the delete race, narrow the tool match, sanitize the fallback

Greptile P1 + CodeRabbit Major — verified real, and mine: splitting merge from
save left a window where a delete lands between them, so the pending save
UPSERTs the row straight back and a dub the user deleted reappears. Now one
atomic step under _dub_jobs_lock, with both delete endpoints purging rows and
memory under that same lock. That also fixed DELETE /dub/history, which
deleted every row but evicted nothing — an in-flight job survived 'clear
history' outright and re-saved itself on completion.

CodeRabbit Minor (#1256): the media-tool match accepted any message ending in
'ffmpeg'/'ffprobe', so a missing FILE at /tmp/ffmpeg got the 'repair your
media engine' remedy. Now requires the name unquoted-and-unqualified.

CodeRabbit Major (#1262): `fallback` reached the header verbatim whenever the
real name folded away entirely, walking past every guard the name goes
through. Folded like the name.

CodeRabbit Major (#1256): the PATH log printed resolved directories, and a
user-set FFMPEG_PATH sits under their home. Logs a count now.

CodeRabbit Minor (#1262): the subtitle-route assertion also passed against the
pre-fix header; it now asserts filename*= too.

Skipped: 'Highlights bullets must end with (#N)'. CLAUDE.md scopes that to the
### subsections; none of the seven pre-existing highlights carry refs, and
tests/test_changelog_style.py encodes the rule already.

* fix(review): the remaining unlocked save paths, an over-broad signature, two weak tests

Greptile P1 — the mid-pipeline put_job + save_job pairs were still unlocked, so
a clear-history landing between them left a ghost row behind the purge. Both go
through put_and_save_job now; only the final completion gate decides whether a
withdrawn job's work is kept.

CodeRabbit Major (#1257) — 'unsupported language' as a bare prefix also matches
'Unsupported language model configuration', handing a model/config failure
engine-switch advice it has no use for. The loose wordings now require the
rejected thing to be a code or to end there.

CodeRabbit Major (#1257) — the OOM test asserted on SOURCE TEXT, which passes
even if the call is unreachable or its result discarded; #1224 taught this same
lesson on this codebase. Both it and the language rewrite now drive the real
_run_backend_inference with a raising backend.

CodeRabbit Minor (#1257) — 'or "engine" in message' always passed, since the
production template contains the word. Asserts the resolved class name now.

CodeRabbit Major (#1252) — the delete-race test deleted the job BEFORE the
merge, which only re-tested the absent case and would pass with the two steps
still split. It now interleaves a real second thread against a slow save.

CodeRabbit Major (#1256) — a hardcoded /tmp literal trips Ruff S108; built from
tmp_path instead.

* fix(review): a withdrawal must survive the job's first write

CodeRabbit Major — the concern is real, though its suggested fix (gate the
checkpoint on the job already existing) would break creation: an ingest's FIRST
persistence is what creates the entry, so that gate would never pass.

The actual defect is that dict membership cannot express 'withdrawn'. An absent
key means 'not written yet' for a new job and 'deleted' for an established one
— two opposite instructions from one signal. So a clear-history arriving before
the first checkpoint was silently undone by that checkpoint recreating the row,
and the run then persisted its result into history the user had just cleared.

Tombstone it explicitly: the ingest declares itself in flight, a purge marks any
in-flight id withdrawn, and both write paths refuse a withdrawn id. Released in
, so it's bounded by concurrent ingests and can't poison a later run
that reuses the id.

That also fixed clear-history properly: a job with no row yet appears in no id
list, so only an in-flight sweep can catch it.

CodeRabbit Minor — my race test waited on an event that could not be set while
the save held the lock, so it burned its full 2s timeout every run and
synchronised nothing. It now waits for the purge thread to REACH the purge.

* test(dub): the race test was not testing the race

Caught by verifying fail-before rather than trusting the test: splitting merge
from save — the exact resurrection bug — passed all 22 tests.

The assertions checked WHAT happened (the save ran, the row was deleted, the
job left memory) but never WHEN. A save landing after the delete is
indistinguishable from one landing before if you only assert that both
occurred — and 'after' is precisely the resurrection.

Now recorded and asserted as an order. With merge+save atomic the purge cannot
start until the save finishes, so the sequence is always save-then-delete;
split them and it fails with ['delete', 'save'].

That is the second time this test needed rewriting: v1 deleted the job before
the merge and only re-checked the absent case, v2 interleaved a real thread but
asserted the wrong thing. Both looked like tests.

Also documents why the DB write sits inside the lock (atomicity beats a rare
5 s sqlite busy-timeout stall) and that no locked region calls another, so the
non-reentrant lock cannot deadlock — verified by walking every locked region.

* fix(dub): gate the withdrawal at save_job, not at its callers

Greptile P1 — and the same class I'd already fixed, unfixed elsewhere. The
withdrawal check sat in the two ingest helpers, but eight direct save_job call
sites across dub generate / translate / export / core bypass those entirely.
Deleting a dub mid-RENDER therefore still resurrected it, which is at least as
likely as deleting mid-import.

Moved the gate into save_job itself: one choke point, every caller inherits it,
and the ninth cannot forget. That needs a re-entrant lock, since the atomic
helpers call save_job while already holding it — a plain Lock would deadlock
the backend, so a test pins the lock type and another exercises the nested path.

Verified fail-before: removing the gate fails the new test.

* fix(dub): the withdrawal only covered ingests, so it covered almost nothing

Caught by testing the reported scenario directly instead of trusting a green
suite: CI passed, 26 tests passed, and a dub deleted during a RENDER was still
resurrected.

The tombstone was scoped to in-flight ingests. But a dub is imported once and
rendered many times, so the realistic delete lands during a render — long after
its ingest ended — and end_ingest was CLEARING the tombstone at exactly that
point. The rare case was protected and the common one left open.

Now scoped to deletions, not ingests. Kept in a bounded LRU rather than cleared
on completion, because there is no moment at which a delete stops mattering:
any operation still holding that job can persist it. Re-importing an id is the
only thing that legitimately revives it.

Verified fail-before: the previous scoping fails three of the new tests.

* refactor: move the dub delete-resurrection fix to its own PR (#1270)

The six fixes left here are independent error-message changes that needed no
corrections. The dub concurrency change needed five rounds, each finding
something real in work that was already reviewed, tested and CI-green — the
last of them being that the fix did not fix the reported case at all.

Riding a release on that record is a bad trade, so it ships separately as
#1270. This branch keeps #1262, #1256, #1251, #1247, #1257 and #1254; the
KeyError message half goes with the dub PR, since it is that issue's other
half.
2026-07-26 12:39:29 -07:00
Palash Debnath e3fa40a952 fix(macos): the app was dead on arrival on macOS 12 (Monterey) (#1245) (#1263)
* fix(macos): the app was dead on arrival on macOS 12 (Monterey) (#1245)

`useRealtimeEvents` polls backend health with `AbortSignal.timeout(2000)` on
mount. That method landed in Safari 16.0, but tauri.conf.json declares
`minimumSystemVersion: "12.0"` and docs/install/macos.md promises macOS 12 —
which ships WKWebView 15.6. So on the floor we advertise, the first React
render threw, the tree unmounted, and the backend was never started: the
reporter's whole session was one `view:launchpad` and "no backend response
this session".

Fixed as a class, not a call site. `utils/webCompat.js` fills the gap once,
imported first in main.jsx before any app chunk; a deterministic test fails CI
if app code reaches for any other post-15.6 API that nothing fills in.

* fix(review): correct the version facts, widen the guard, stop overclaiming

Standing review found three factual errors and one overclaim. Verified each
against caniuse/WebKit before changing anything — two of my labels were wrong
and one of the reviewer's corrections would itself have been wrong to apply
blindly.

- Array#toSorted/toReversed/toSpliced are Safari 16.0, not 16.4 (caniuse).
- String#isWellFormed is 16.4, not 17.0 (WebKit 16.4 release notes).
- abort(reason) shipped in Safari 15.4, so it IS honoured on our 15.6 floor —
  my comment claimed the opposite, which invited 'simplifying' the polyfill to
  a bare abort() and silently turning every TimeoutError into an AbortError.
- Array#with was missing from the denylist. It is the change-by-copy sibling
  most likely to be reached for (arr.with(i, v) in React state), so it was the
  most likely next instance of the exact bug this guards. Added, with
  Array.fromAsync, String#toWellFormed, Element#checkVisibility and URL.parse.

The overclaim: the changelog said the app 'opens again, instead of a dead
window'. It launches — but Tailwind v4's own floor is Safari 16.4, vite's
default target is 16.4, and index.css uses color-mix() (16.2) 64 times, so
Monterey renders it wrong. A bundled dependency also ships a RegExp lookbehind
literal, which is a parse-time SyntaxError no polyfill can reach. Changelog now
claims only what is true; the floor question is #1268.

The guard's blind spots — syntax, dependencies, CSS, computed access — are now
stated in the test file rather than left implied.
2026-07-26 11:01:06 -07:00
Palash Debnath 1bfcedab43 chore(agents): a standing reviewer carrying the project's own standards (#1267)
Encodes what CLAUDE.md already requires — root-cause not symptom, whole class
not one instance, fail-before/pass-after tests, cross-platform parity, keep
main green — as a reviewer that attacks a change before it lands.

Deliberately a critic, not an approver: it can judge that work meets the bar,
it cannot authorise publishing, and it is told to say so when the remaining
decision is the owner's. A reviewer that agrees with the author is worth
nothing — two bugs this week were caught only because a reviewer went looking
for a blind spot, including a Linux fix that turned out to be completely inert.

Tracked rather than left in local state so the standards travel with the code.
2026-07-26 11:00:58 -07:00
Paolo Antinori 67d1f6a43d feat(mcp): OMNIVOICE_MCP_ALLOWED_HOSTS — configurable host allowlist for MCP transport security (#1249) (#1250)
* feat(mcp): OMNIVOICE_MCP_ALLOWED_HOSTS env var for transport-security allowlist (#1249)

Agents running in Docker containers (or on other machines) connect via a
hostname like host.containers.internal, which the MCP SDK's DNS-rebinding
guard rejects with 421. Add OMNIVOICE_MCP_ALLOWED_HOSTS (comma-separated
host patterns) that extends both allowed_hosts and allowed_origins in
create_mcp_server(). Default empty → no behavior change.

Test: assert the env var extends the allowlist + origins. Docs: mcp.md
notes the env var for Docker/LAN agents.

* fix(changelog): move MCP_ALLOWED_HOSTS entry after Highlights per quiet style

* fix(mcp): add https:// origins for HTTPS reverse proxy clients (greptile P1)

* docs(mcp): add security note for remote agent connections (coderabbit)
2026-07-26 02:02:23 -07:00
Palash DebnathandClaude Opus 4.8 e6ca31d7a6 fix(first-run): keep Continue + the HF token box on screen; no status bar yet (#1241)
* fix(first-run): keep Continue + the HF token box on screen; no status bar yet

Reported with a screenshot: on Models & engines the "Set token" card was
clipped at the window edge and the Continue button was off-screen entirely —
unreachable without resizing.

Root cause is one bug, and it explains both halves of the report. App.jsx sizes
`.app-wizard-wrap` to stop above the fixed LogsFooter, but SetupWizard's own
root was `fixed inset-0` — so it laid itself out against the VIEWPORT, escaped
that box, and put its pinned footer row underneath the status bar. The row was
already correctly pinned (shrink-0, outside the scroller); it was simply
painted over.

- SetupWizard's root is `absolute inset-0`, filling the frame it is given, plus
  `pb-4` so the pinned row clears the window edge.
- The wizard and the pre-wizard splash render no LogsFooter at all: it is
  studio chrome, and the rule is that it appears once you land on home.
- `.app-wizard-wrap` reserves nothing below it any more, so no dead 28px gap
  is left where the footer used to be.

Verified by driving the real frontend in Chromium against a stub backend, not
only in jsdom: before, `.logs-footer` is present and overlaps the action row;
after, it is absent and every button sits within the viewport.

Regression test pins both halves — either one alone reintroduces the clip.

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

* style(test): oxfmt; changelog refs the wizard PR (#1241), not issue #1240

* test(first-run): pin the bottom clearance and check each pre-studio branch

CodeRabbit (#1241): the root-class test passed with `pb-4` removed, and the
LogsFooter count-of-1 would still pass if the mount MOVED from the studio into
the splash. Both now assert the thing they mean.

* style(test): oxfmt the SetupWizardChrome assertions

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-26 01:59:37 -07:00
Palash Debnath b722ca7ad1 Merge pull request #1236 from debpalash/fix/download-resume-oom-1224
fix(download): retry a truncated model download instead of aborting (#1224)
2026-07-22 17:47:38 -07:00
debpalash 854306fef8 docs(changelog): rebuild [Unreleased] after the main merge 2026-07-23 06:02:58 +05:30
Palash Debnath fe3efe3633 Merge pull request #1235 from debpalash/fix/port-in-use-1223
fix(startup): report a port conflict as a port conflict (#1223)
2026-07-22 17:32:51 -07:00
debpalash 3e2b089698 docs(changelog): rebuild [Unreleased] after the main merge 2026-07-23 05:43:24 +05:30
debpalash 8c77a82727 docs(changelog): rebuild [Unreleased] after the main merge 2026-07-23 05:43:22 +05:30
Palash Debnath dbc87dbfe2 Merge pull request #1238 from debpalash/fix/changelog-structure
docs(changelog): repair the [Unreleased] structure
2026-07-22 17:12:59 -07:00
debpalash f67fde919c docs(changelog): repair the [Unreleased] structure mangled by a merge resolution
Resolving the repeated [Unreleased] conflicts (every PR appends to the same
block) with a line-level union dropped the blank lines around each ###
header and moved #1237's Highlights bullet into ### Fixed. The style linter
doesn't check blank lines, so it went unnoticed until the next merge — where
the misplaced bullet failed the ref/credit rule and made main red.

No content change: same entries, correct structure.
2026-07-23 05:28:32 +05:30
Palash Debnath 00da2d3c94 Merge pull request #1237 from debpalash/fix/low-vram-preflight-1226
fix(routing): warn about an under-provisioned GPU before the job, not after (#1226, #1222)
2026-07-22 16:56:32 -07:00
debpalash 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
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 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 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 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
89 changed files with 4889 additions and 223 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.
+5 -1
View File
@@ -45,10 +45,14 @@ 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 -3
View File
@@ -6,7 +6,30 @@ 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.
## [Unreleased]
## [0.4.2] — 2026-07-28
**Highlights**
- The update prompt is a small toast with buttons, not a screenful of release notes
- Installing an update no longer throws away work that is still running
- Quitting the app mid-generate stops reporting itself as a crash
- A half-downloaded model repairs itself instead of dead-ending
- "Dismiss" no longer reads as "terminate an employee" in five languages
### Changed
- An available update now announces itself as a toast with **Install and restart**, **What's new** and **Later**, instead of only a dot beside the version number. The release notes stay in Settings → Updates, where there is room for them — a version's notes are the whole changelog section, and rendering them inline is what made the old prompt fill the screen (#1272)
### Fixed
- Installing an update no longer relaunches the app while work is running. The check only knew about dub synthesis, so a restart could silently discard an upload, a transcription, a translation, an export or a standalone synth — and two overlapping synths used to cancel each other's protection. Install is now greyed out while anything is in flight (#1272)
- A half-downloaded model now repairs itself instead of failing with a raw 500. The automatic repair recognised only one of the two ways the loader reports missing weights, so an interrupted download whose subfolder failed to load got neither the repair nor a hint about what to do (#1273)
- Quitting the app with a generate queued reported "500 Internal Server Error: model load skipped: backend shutting down" and offered to file a bug for it. A shutdown is not a fault: the backend now answers 503 with what to do, and no bug report is offered for it (#1276)
- Dub history: clearing a large history while a render was running could still resurrect the deleted job — which markers survived depended on the process hash seed, and an oversized purge could discard a live one (#1252)
- German, Japanese, Russian and both Chinese locales rendered "Dismiss" as the employment sense — "terminate an employee" — on close buttons (#1272)
- The "wait for the current job to finish" message named dubbing specifically, though it now covers uploads, transcription, translation, exports and synthesis; reworded across all 21 languages (#1272)
## [0.4.1] — 2026-07-27
**Highlights**
@@ -14,9 +37,38 @@ The bundled TTS model package (`pyproject.toml`) is versioned independently.
- 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)
@@ -27,9 +79,14 @@ The bundled TTS model package (`pyproject.toml`) is versioned independently.
- 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 too small for the chosen engine now says so up front, not after a five-minute wait
- 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
@@ -351,7 +408,6 @@ The quality release. Three long-standing frictions got structural fixes: **regen
The cold-start release. Three "why is this broken on my machine" mysteries got solved at their roots: **first generations stop dying at 300 seconds** (the timeout was counting the model download as generation time — @moduvoice measured it on a Tesla T4: 0% GPU for the full window), **updates stop deleting engines you installed yourself** (the updater's dependency sync removed anything not in the app's lockfile — including things our own UI told you to install), and **the "slower than v0.3.5" regression is found and fixed** (clone profiles without a transcript were silently re-running a full Whisper transcription on every single generate). Also: Clear History is back, auto-played audio is finally stoppable, @stronghamjji hardened the dub pipeline against wedged transcribes, and @shakib30's community Colab notebook is now the linked no-GPU path. Thank you all.
### Added
- **Agent Skills: `npx skills add debpalash/omnivoice-studio`.** Two installable [skills](https://skills.sh) now ship in the repo — `omnivoice` teaches any AI agent (Claude Code, Cursor, Codex, …) to speak and transcribe through your local install via the OpenAI-compatible API, including your cloned voices; `oss-maintainer` packages the maintainer methodology this project is run with.
@@ -700,6 +756,7 @@ across dub, generate, and design (a corrupt-binary failure no longer poses as
above Continue, framed around what it actually buys you — authenticated, faster,
more reliable downloads (higher rate limits, fewer stalls) — with a one-click
"get a free token" link. (#657, #669)
### Fixed
- **Bug reports redact more secrets and every Windows username casing.** The
+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")},
)
+56 -1
View File
@@ -634,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.
+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),
},
)
+2 -1
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")
@@ -466,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):
+14
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()
+140 -10
View File
@@ -42,12 +42,15 @@ _HINTS: dict[str, str] = {
"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, 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.",
@@ -88,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
@@ -260,18 +275,34 @@ def classify(reason: str) -> str:
marker in low for marker in _DOWNLOAD_CONTEXT_MARKERS
):
return "VIDEO_DOWNLOAD_OS_ERROR"
# #1256: a third-party library shelled out to `ffprobe`/`ffmpeg` BY NAME and
# the OS had nothing to run. OmniVoice's own code always resolves the
# bundled sidecar explicitly, so this only ever comes from a dependency —
# which meant it arrived with no class at all and the user was told the
# engine "stopped with an error OmniVoice doesn't recognize". Checked
# before the generic errno-2 rules, which would otherwise claim it.
if _is_missing_media_tool(low):
return "MEDIA_TOOL_MISSING"
# #1251: Windows refused to map the model because the PAGING FILE is too
# small. The generate path already counted this as an OOM, but that hint
# ("close other apps, use a lighter engine") is the wrong remedy — the
# machine had 32 GB of RAM. Matched on the numeric code too, since the OS
# translates the message text, and in both the Python (`[WinError 1455]`)
# and Rust (`os error 1455`, from the safetensors mmap) spellings.
if "1455" in low and ("winerror" in low or "os error" in low):
return "WINDOWS_PAGING_FILE_TOO_SMALL"
if "paging file is too small" in low:
return "WINDOWS_PAGING_FILE_TOO_SMALL"
if "errno 22" in low:
return "OS_INVALID_ARGUMENT"
# An HF cache whose snapshot entries don't resolve (dangling symlinks /
# zero-byte stand-ins): transformers reports the weights missing ("does
# not appear to have a file named pytorch_model.bin or model.safetensors")
# even though the blobs are fully on disk. model_manager self-heals this
# (delete broken entries → snapshot_download → retry once); the class here
# covers both the raw transformers wording (any load surface can leak it)
# and OmniVoice's own repair messages, so the user-facing error and the
# auto bug report name the class and its automatic repair.
if ("does not appear to have a file named" in low
or "broken file link" in low):
# zero-byte stand-ins) or which is simply missing its weight shard.
# model_manager self-heals this (delete broken entries → snapshot_download
# → retry once); the class here covers the raw transformers wordings (any
# load surface can leak them) and OmniVoice's own repair messages, so the
# user-facing error and the auto bug report name the class and its
# automatic repair.
if is_incomplete_cache_message(low) or "broken file link" in low:
return "MODEL_CACHE_CORRUPT"
if (
"could not import module" in low
@@ -331,6 +362,13 @@ def classify(reason: str) -> str:
# Broken pipe" still classifies as a network blip.
if "unsupported url" in low or "no video formats" in low or "is not a valid url" in low:
return "UNSUPPORTED_VIDEO_URL"
# #1254: reported as intermittent — the same URL failed, then succeeded on
# a retry. That is a per-player-client format set, not real DRM, so the
# download path now escalates the client the way it does for a 403. If
# every client still says DRM, the video genuinely can't be fetched and the
# user needs to hear that rather than retry a fourth time.
if "drm protected" in low or "drm-protected" in low:
return "VIDEO_DRM_PROTECTED"
if (
"broken pipe" in low
or "connection reset" in low
@@ -468,6 +506,71 @@ _DOWNLOAD_CONTEXT_MARKERS = (
)
#: The media binaries a dependency may shell out to by bare name.
_MEDIA_TOOLS = ("ffprobe", "ffmpeg")
def _is_missing_media_tool(low: str) -> bool:
"""True when the failure is "the OS could not find ffprobe/ffmpeg" (#1256).
Deliberately narrow: it must name the binary *as the thing that could not
be found*, so a perfectly ordinary "ffmpeg failed: no such file or
directory: /path/to/input.wav" — a missing INPUT, an entirely different
problem is not handed the "install the media engine" remedy.
"""
if "no such file or directory" not in low and "[winerror 2]" not in low:
return False
# The name must appear UNQUALIFIED — quoted with no directory part, which
# is how a bare-name spawn fails. A path that merely ends in the tool's
# name ('/tmp/ffmpeg', '~/Movies/my-ffmpeg-export.mp4') is a missing FILE,
# an entirely different problem that must not get the "repair your media
# engine" remedy (#1256 review).
return any(
f"'{tool}'" in low or f'"{tool}"' in low
for tool in _MEDIA_TOOLS
)
#: transformers' two ways of saying "this snapshot has no weight shard".
#:
#: They come from different code paths and share no wording, so matching only
#: the first — which both the classifier and model_manager's self-heal used to
#: do — silently missed half the class (#1273):
#:
#: hub load: "<repo> does not appear to have a file named
#: pytorch_model.bin or model.safetensors"
#: local dir: "Error no file named model.safetensors, or pytorch_model.bin,
#: found in directory <path>"
#:
#: The second is what a load of a *subfolder* inside a cached snapshot raises
#: (e.g. `audio_tokenizer/`), which is exactly where an interrupted download
#: leaves a repo half-written. Both mean the same thing and both are repaired
#: the same way, so both must classify and both must trigger the heal.
_INCOMPLETE_CACHE_PHRASES = (
"does not appear to have a file named",
# Matched as two fragments: the file list between them varies with the
# transformers version and the requested weight variant.
("error no file named", "found in directory"),
)
def is_incomplete_cache_message(text: str) -> bool:
"""True when *text* is transformers reporting a snapshot with no weights.
Takes an already-lowercased string. Shared by :func:`classify` and
model_manager's cache self-heal so the healer and the error message can
never disagree about what an interrupted download looks like.
"""
low = str(text).lower()
for phrase in _INCOMPLETE_CACHE_PHRASES:
if isinstance(phrase, tuple):
if all(part in low for part in phrase):
return True
elif phrase in low:
return True
return False
def is_os_write_refusal(reason: Optional[str]) -> bool:
"""True when *reason* looks like the OS refusing a file operation (a full
or removed drive, a read-only folder, an antivirus/cloud-sync lock) rather
@@ -504,6 +607,33 @@ def describe_path_target(path: str) -> str:
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,
*,
@@ -517,7 +647,7 @@ def build_failure(
"""
if isinstance(exc_or_msg, BaseException):
error_class = type(exc_or_msg).__name__
raw = str(exc_or_msg).strip() or error_class
raw = describe_exception(exc_or_msg)
else:
error_class = "Error"
raw = str(exc_or_msg).strip() or "Unknown failure"
+90
View File
@@ -0,0 +1,90 @@
"""RFC 6266 ``Content-Disposition`` construction.
#1262: exporting a voice profile whose name isn't spelled in Latin letters
returned a 500:
'latin-1' codec can't encode characters in position 22-25:
ordinal not in range(256)
``attachment; filename="`` is exactly 22 characters, so positions 22-25 were
the first four characters of the user's own profile name. HTTP header values
are latin-1 by definition, and every download endpoint built the header by
f-string interpolation, so any name outside latin-1 Chinese, Japanese,
Korean, Greek, Cyrillic, Hebrew, emoji crashed the request.
The sanitisers in front of those f-strings did not catch it because they all
filtered with ``str.isalnum()``, which is **True for every alphabetic script**,
not just ASCII. ``"我的声音".isalnum()`` is ``True``. They were removing
punctuation and passing the exact characters that break the header.
`content_disposition` is the one construction site: an ASCII-safe
``filename=`` that any client can read, plus the RFC 5987 ``filename*=`` that
gives modern browsers the user's real name back, correctly encoded.
"""
from __future__ import annotations
import re
import unicodedata
from urllib.parse import quote
__all__ = ["ascii_filename", "content_disposition"]
#: Characters Windows forbids in a filename, plus the quoting/injection risks
#: (`"` and `\` end the quoted-string; CR/LF would split the header).
_UNSAFE = re.compile(r'[\\/:*?"<>|\r\n\t]')
def _fold(text: str) -> str:
"""One filename part, reduced to safe ASCII."""
folded = unicodedata.normalize("NFKD", text)
# Drop the combining marks NFKD split off, keeping the base letters.
folded = "".join(c for c in folded if not unicodedata.combining(c))
folded = folded.encode("ascii", "ignore").decode("ascii")
return _UNSAFE.sub("_", folded).strip()
def ascii_filename(filename: str, fallback: str = "download") -> str:
"""A latin-1-safe rendering of *filename* for the legacy ``filename=``.
Accented Latin is folded to its base letters (``Sébastien``
``Sebastien``) rather than deleted, since that stays readable. Scripts with
no ASCII form (CJK, Cyrillic, Hebrew, emoji) have no meaningful fold, so
they drop out and *fallback* carries the name the ``filename*`` parameter
is what actually preserves those, and every browser released this decade
prefers it.
"""
raw = filename or ""
# Split the extension off FIRST: folding runs per-part so a name that is
# entirely non-ASCII loses its stem without also losing ".ovsvoice", which
# is what tells the OS (and the user) what the file actually is.
stem, dot, suffix = raw.rpartition(".")
if not dot:
stem, suffix = raw, ""
stem, suffix = _fold(stem), _fold(suffix)
if not stem.strip("_ ."):
stem = fallback
return f"{stem}.{suffix}" if suffix else stem
def content_disposition(
filename: str,
*,
disposition: str = "attachment",
fallback: str = "download",
) -> str:
"""A ``Content-Disposition`` value that is safe for ANY filename (#1262).
Emits both forms per RFC 6266 §4.3: ``filename=`` for the lowest common
denominator and ``filename*=UTF-8''`` for the real name. Clients that
understand the extended form ignore the plain one, so the user gets
``我的声音.ovsvoice`` while nothing anywhere has to encode it as latin-1.
"""
# The fallback is a caller-supplied string that lands in the header
# verbatim whenever the real name folds away entirely, so it gets the same
# treatment as the name itself — otherwise a non-ASCII or quote/CRLF
# fallback walks straight past every guard here (#1262 review).
safe_fallback = _fold(fallback) or "download"
safe = ascii_filename(filename, fallback=safe_fallback)
encoded = quote(_UNSAFE.sub("_", filename or safe_fallback), safe="")
return f"{disposition}; filename=\"{safe}\"; filename*=UTF-8''{encoded}"
+1 -1
View File
@@ -24,7 +24,7 @@ from pathlib import Path
# tests/test_app_version.py::test_all_version_files_in_lockstep and bumped by
# release.yml's version-bump job, so it stays equal to
# pyproject/tauri.conf/Cargo/package.json.
_FALLBACK_VERSION = "0.4.0"
_FALLBACK_VERSION = "0.4.2"
def _fallback_version() -> str:
+136 -10
View File
@@ -383,6 +383,7 @@ from core.config import OUTPUTS_DIR, VOICES_DIR, CRASH_LOG_PATH
from core.tasks import task_manager
from core import job_store
from services.model_manager import (
ModelLoadInterruptedByShutdown,
begin_shutdown as model_loads_begin_shutdown,
idle_worker,
preload_model,
@@ -460,6 +461,19 @@ except Exception:
pass
# #1256: our own ffmpeg/ffprobe call sites pass an explicit path, so a bundled
# sidecar that isn't on PATH works for us — but a dependency that shells out to
# `ffprobe` by bare name dies with FileNotFoundError, mid-synthesis, on a
# machine where the app's own copy was resolvable the whole time. Publish the
# resolved directories once here, after prefs have restored any FFMPEG_PATH
# override and before any engine loads.
try:
from services.ffmpeg_utils import ensure_media_tools_on_path
ensure_media_tools_on_path()
except Exception:
pass # best-effort: find_ffprobe() still resolves it for our own callers
def _env_flag(name: str, default: bool = False) -> bool:
value = os.environ.get(name)
if value is None:
@@ -874,6 +888,25 @@ async def scalar_docs():
)
def _cors_headers_for(request: Request) -> "dict[str, str]":
"""Allowed-origin headers for a hand-built error response.
CORSMiddleware doesn't always get a shot at `exception_handler`-created
responses, which leaves the browser reporting the error as a bare CORS
failure instead of surfacing the real `detail`. Every error response this
module builds must go through here a 503 whose actionable message the
browser discards is no better than the 500 it replaced.
"""
origin = request.headers.get("origin", "")
if origin and (origin in _allowed or "*" in _allowed):
return {
"Access-Control-Allow-Origin": origin,
"Access-Control-Allow-Credentials": "true",
"Vary": "Origin",
}
return {}
@app.exception_handler(Exception)
async def global_exception_handler(request: Request, exc: Exception):
# Client disconnected mid-stream (browser canceled a <video>/range fetch).
@@ -886,6 +919,46 @@ async def global_exception_handler(request: Request, exc: Exception):
) or "Content-Length" in str(exc):
logger.info("Client disconnect during %s (%s)", request.url, exc_name)
return Response(status_code=499)
# The backend is on its way out and a request asked for a model load
# (#1276). #1174 already made this benign for the *background preload*,
# but a user-initiated request fell through to the generic 500 path below
# — crash log, ERROR traceback, journal entry — so quitting the app while
# a generate was queued surfaced "500 Internal Server Error: model load
# skipped: backend shutting down" and offered to file a bug for it.
#
# Nothing failed: the process is exiting. 503 + Retry-After is what a
# shutting-down server owes a client, and it keeps this out of the
# crash/bug-report pipeline entirely.
#
# Matched by isinstance OR class name: `services.model_manager` can be
# imported under two module names (`main`/`backend.main` on different
# sys.path roots, and the frozen build's own layout), which makes two
# distinct class objects and breaks a bare isinstance. The name check is
# the durable half — don't "simplify" it away.
if isinstance(exc, ModelLoadInterruptedByShutdown) or exc_name == (
"ModelLoadInterruptedByShutdown"
):
# `.path`, not the full URL — a query string can carry tokens and
# newlines, and neither belongs in a log line.
logger.info(
"Model load skipped during shutdown for %s — benign.", request.url.path
)
return JSONResponse(
status_code=503,
content={
# The [shutting_down] marker is what the UI keys off to skip the
# "Report" action (the same convention as [clone_ref_unusable]).
# NOT the bare 503 status: 503 is also how a real engine-load
# timeout and an unavailable engine are reported, and those are
# genuinely reportable bugs — suppressing the report button for
# every 503 would silence exactly the class users need to file.
"detail": (
"[shutting_down] OmniVoice is shutting down, so it didn't "
"start loading the model. Reopen the app and try again."
)
},
headers={"Retry-After": "5", **_cors_headers_for(request)},
)
try:
# Serialize writes so concurrent unhandled exceptions don't interleave frames.
with _crash_log_lock, open(CRASH_LOG_PATH, "a", encoding="utf-8", errors="backslashreplace") as f:
@@ -902,15 +975,7 @@ async def global_exception_handler(request: Request, exc: Exception):
_entry = error_journal.record(
exc, route=str(request.url.path), trace=traceback.format_exc()
)
# CORSMiddleware doesn't always get a shot at `exception_handler`-created
# responses, which leaves the browser reporting every 500 as a bare CORS
# error. Attach the headers manually so the real `detail` bubbles up.
origin = request.headers.get("origin", "")
headers: dict[str, str] = {}
if origin and (origin in _allowed or "*" in _allowed):
headers["Access-Control-Allow-Origin"] = origin
headers["Access-Control-Allow-Credentials"] = "true"
headers["Vary"] = "Origin"
headers: dict[str, str] = _cors_headers_for(request)
# #874: a model download that failed because the CONFIGURED Hugging Face
# mirror (HF_ENDPOINT) is unreachable used to leak the raw transformers
# message ("We couldn't connect to 'https://hf-mirror.com' …") as the 500
@@ -1295,6 +1360,12 @@ if __name__ == "__main__":
)
sys.exit(1)
# Distinct exit code for "the port was already taken" (#1223), so the
# desktop shell can tell that apart from a crash without parsing an
# OS-translated error string. Kept out of the 0-2 range the interpreter
# itself uses, and mirrored in frontend/src-tauri/src/backend.rs.
_EXIT_PORT_IN_USE = 78 # EX_CONFIG, sysexits.h
# Port 3900 picked to dodge common 8000 conflicts (Django/Rails/Jupyter).
# Rust sidecar launcher in lib.rs::BACKEND_PORT must stay in sync.
#
@@ -1305,4 +1376,59 @@ if __name__ == "__main__":
# set OMNIVOICE_BIND_HOST=0.0.0.0 explicitly (see deploy/docker-compose.yml)
# — the host-side port mapping is what enforces 127.0.0.1-only there.
_bind_host = os.environ.get("OMNIVOICE_BIND_HOST", "127.0.0.1")
uvicorn.run(app, host=_bind_host, port=_port)
def _port_taken(host: str, port: int) -> "OSError | None":
"""The EADDRINUSE error a bind would raise, or None if the port is free.
Mirrors uvicorn's own socket options — notably SO_REUSEADDR off
Windows so this can't report "taken" for a TIME_WAIT socket uvicorn
would happily bind. Any non-EADDRINUSE failure returns None: this is a
diagnostic, and uvicorn must remain the authority on whether the real
bind succeeds.
"""
import socket
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe:
if sys.platform != "win32":
probe.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
try:
probe.bind((host, port))
except OSError as exc:
in_use = exc.errno in (48, 98, 10048) or getattr(
exc, "winerror", None
) == 10048
return exc if in_use else None
return None
def _fail_port_in_use(exc: "OSError | None") -> None:
print(
f"FATAL: port {_port} is already in use — another OmniVoice "
f"backend (or another app) is listening on it. Quit the other "
f"instance and relaunch; if nothing is visibly running, an "
f"orphaned backend from a previous session is still holding the "
f"port." + (f" Underlying error: {exc}" if exc else ""),
file=sys.stderr,
flush=True,
)
sys.exit(_EXIT_PORT_IN_USE)
# #1223: uvicorn does NOT let a bind failure reach the caller — it logs the
# raw errno and raises SystemExit(1) from inside its startup, so an
# `except OSError` around uvicorn.run() never fires (verified, not assumed).
# And the message it logs is useless to match on: the Windows wording
# ("only one usage of each socket address is normally permitted") is
# OS-translated into the user's locale. So probe the port ourselves first —
# errno is locale-independent (EADDRINUSE = 48 macOS/BSD, 98 Linux, 10048
# Windows) — and exit with a code the shell can recognise.
if (_bind_err := _port_taken(_bind_host, _port)) is not None:
_fail_port_in_use(_bind_err)
try:
uvicorn.run(app, host=_bind_host, port=_port)
except SystemExit:
# Lost the race between the probe above and uvicorn's own bind (a
# competing process grabbed the port in between). Re-probe: if the port
# is taken now, that is what killed us, whatever exit code uvicorn
# chose.
if _port_taken(_bind_host, _port) is not None:
_fail_port_in_use(None)
raise
+18
View File
@@ -114,6 +114,24 @@ def create_mcp_server():
except Exception:
pass
# Extend the MCP SDK's DNS-rebinding allowlist so agents on non-localhost
# hosts (Docker's host.containers.internal, a LAN IP, a reverse proxy) can
# reach the /mcp endpoint. The SDK default is localhost-only.
_mcp_hosts = os.environ.get("OMNIVOICE_MCP_ALLOWED_HOSTS", "")
if _mcp_hosts.strip():
hosts = [h.strip() for h in _mcp_hosts.split(",") if h.strip()]
try:
mcp.settings.transport_security.allowed_hosts.extend(hosts)
# Also extend origins for both http and https (browser-based MCP
# clients behind a proxy send an Origin header — agent clients
# typically don't, but a reverse proxy may use either scheme).
origins = [
f"{scheme}://{h}" for h in hosts for scheme in ("http", "https")
]
mcp.settings.transport_security.allowed_origins.extend(origins)
except Exception as e:
logger.warning("OMNIVOICE_MCP_ALLOWED_HOSTS not applied (%s)", e)
# ── Helpers ─────────────────────────────────────────────────────────
def _api_base() -> str:
+268 -15
View File
@@ -37,6 +37,7 @@ import subprocess
import sys
import threading
import time
from collections import OrderedDict
from typing import AsyncIterator, Optional
import soundfile as sf
@@ -67,7 +68,45 @@ logger = logging.getLogger("omnivoice.dub_pipeline")
# backward compat during the transition.
_dub_jobs: dict[str, dict] = {}
_dub_jobs_lock = threading.Lock()
# Re-entrant: `save_job` takes this lock itself (see below), and the atomic
# helpers call it while already holding it.
_dub_jobs_lock = threading.RLock()
#: Ingests currently running. Used only so "clear history" can also sweep a job
#: that has no row yet — it would appear in no id list otherwise.
_inflight_jobs: set[str] = set()
#: Recently-deleted job ids, most-recent last. Guarded by ``_dub_jobs_lock``.
#:
#: Dict membership alone cannot express "the user withdrew this job" (#1252
#: review): a job's FIRST persistence creates the entry, so an absent key means
#: "not written yet" for a new job and "deleted" for an established one — two
#: opposite instructions from one signal.
#:
#: Scoped to DELETED, not to in-flight ingests. Scoping it to ingests looked
#: right and closed nothing that mattered: a dub is imported once and rendered
#: many times, so the realistic delete lands during a RENDER, long after its
#: ingest ended — and a render's save would then write the row straight back.
#:
#: Retained rather than cleared on completion, because there is no moment at
#: which a delete stops mattering: any operation still holding that job can
#: persist it. ``begin_ingest`` drops an id explicitly, since re-importing is a
#: deliberate revival.
#:
#: Expired by AGE, not by count. A count-bounded LRU is evictable by ordinary
#: use: ``DELETE /dub/history`` purges every row with no limit, so a user
#: clearing a large history mid-render would push the rendering job's own
#: marker out and the render would then write it back (#1252 review). Age
#: cannot be gamed that way — what matters is how long ago the delete happened,
#: not how many others followed it.
#:
#: The count cap is a memory backstop only, set far above any real history:
#: ~4096 short ids is a few hundred KB. Reaching it needs 4096 deletions inside
#: one TTL window, at which point the oldest markers are the least likely to
#: still be held.
_WITHDRAWN_TTL_S = 6 * 3600 # outlives any realistic render or transcribe
_WITHDRAWN_MAX = 4096 # memory backstop, not the eviction policy
_withdrawn_jobs: "OrderedDict[str, float]" = OrderedDict()
_DUB_DIR_REAL = os.path.realpath(DUB_DIR)
_HASH_BUF_SIZE = 1 << 18 # 256 KB chunks for hashing
@@ -197,6 +236,162 @@ def put_job(job_id: str, job: dict) -> None:
_dub_jobs[job_id] = job
def merge_job(job_id: str, updates: dict) -> bool:
"""Merge *updates* into an existing in-memory job. Does NOT persist.
Returns ``False`` when the job is gone which is a real, reachable state,
not a defensive nicety: ingest runs for minutes (demucs, scene detection,
thumbnailing) and ``DELETE /dub/history/{id}`` pops the entry out from
under it. The pipeline used to finish with a bare
``_dub_jobs[job_id].update(...)``, so deleting an in-flight dub surfaced as
the toast ``ingest: 'mgw39lx3'`` ``str(KeyError)`` is the repr of the
key, nothing more (#1252/#1253). Callers treat ``False`` as "the user
withdrew this job" and stop, rather than resurrecting a record that was
deliberately deleted.
"""
with _dub_jobs_lock:
job = _dub_jobs.get(job_id)
if job is None:
return False
job.update(updates)
return True
def _expire_withdrawn(now: float, protected: int = 0) -> None:
"""Drop withdrawal markers that are too old to still matter.
Caller must hold ``_dub_jobs_lock``. Age first that is the policy then
a size cap purely so the mapping cannot grow without bound.
``protected`` is how many markers the current purge just recorded. Those sit
at the end (newest) and are never evicted by the size cap: they are the most
likely to still be held by a running job, and dropping one is exactly the
resurrection this whole mechanism exists to prevent. A single "clear
history" larger than the cap would otherwise force us to discard live
markers which is what broke CI. The bound therefore is
``cap + one purge``, not ``cap``.
"""
cutoff = now - _WITHDRAWN_TTL_S
while _withdrawn_jobs:
_, deleted_at = next(iter(_withdrawn_jobs.items()))
if deleted_at >= cutoff:
break
_withdrawn_jobs.popitem(last=False)
floor = max(_WITHDRAWN_MAX, protected)
while len(_withdrawn_jobs) > floor:
_withdrawn_jobs.popitem(last=False)
def begin_ingest(job_id: str) -> None:
"""Mark an ingest as running.
Re-importing an id is a deliberate revival, so this clears any tombstone
the only thing that legitimately un-deletes a job.
"""
with _dub_jobs_lock:
_inflight_jobs.add(job_id)
_withdrawn_jobs.pop(job_id, None)
def end_ingest(job_id: str) -> None:
"""Mark an ingest as finished, however it ended.
Deliberately does NOT clear the tombstone: the ingest ending is not the
user un-deleting anything, and a render started before the delete can still
be holding that job.
"""
with _dub_jobs_lock:
_inflight_jobs.discard(job_id)
def put_and_save_job(
job_id: str,
job: dict,
*,
filename: str = "",
duration: float = 0.0,
content_hash: str = "",
) -> bool:
""":func:`put_job` and :func:`save_job` as ONE atomic step.
Returns ``False`` when the job was withdrawn the user deleted or cleared
its history while this ingest was running in which case nothing is
written. Gating on the tombstone rather than on dict membership is what
makes this correct for a job's FIRST write, where an absent key is normal
(#1252 review).
"""
with _dub_jobs_lock:
if job_id in _withdrawn_jobs:
return False
_dub_jobs[job_id] = job
save_job(job_id, job, filename, duration, content_hash)
return True
def merge_and_save_job(
job_id: str,
updates: dict,
*,
filename: str = "",
duration: float = 0.0,
content_hash: str = "",
) -> bool:
""":func:`merge_job` and :func:`save_job` as ONE atomic step.
Splitting them leaves a window that resurrects deleted work (#1252 review):
merge succeeds, the user deletes the dub removing the row *and* the
in-memory entry and the pending ``save_job`` then UPSERTs the row straight
back, so a dub the user deleted reappears in history. The delete endpoints
take this same lock around their own row-delete + evict, so the two
sequences cannot interleave at all.
Returns ``False`` when the job is already gone; the caller stops there.
The ``save_job`` write happens INSIDE the lock deliberately. That serialises
dub job-state access against one SQLite UPSERT normally microseconds under
WAL, but up to sqlite3's 5 s default busy timeout if another writer is
holding the write lock. The alternative releasing the lock before the
write is the resurrection race this exists to close, so a rare latency
blip is the better trade. No locked region here calls another locked
function, so the plain (non-reentrant) ``_dub_jobs_lock`` cannot deadlock.
"""
with _dub_jobs_lock:
job = _dub_jobs.get(job_id)
if job is None or job_id in _withdrawn_jobs:
return False
job.update(updates)
save_job(job_id, job, filename, duration, content_hash)
return True
def purge_jobs(job_ids, *, delete_rows, include_inflight: bool = False) -> None:
"""Delete history rows and evict the in-memory records as ONE atomic step.
``delete_rows`` is called with the lock held, so a concurrent
:func:`merge_and_save_job` cannot slip between the row-delete and the
evict and write the job straight back (#1252 review). Both delete
endpoints go through here; ``DELETE /dub/history`` previously never evicted
from memory at all, so an in-flight job survived "clear history" entirely
and re-saved itself on completion.
"""
with _dub_jobs_lock:
delete_rows()
# Deterministic order, de-duplicated. A `set` here made which markers
# the size cap evicts depend on PYTHONHASHSEED — the test for this very
# behaviour passed locally and failed in CI for that reason alone.
targets = list(dict.fromkeys(job_ids))
if include_inflight:
# "Clear history" means everything, including a job whose first row
# hasn't been written yet — it wouldn't appear in `job_ids` at all.
targets += [j for j in sorted(_inflight_jobs) if j not in set(targets)]
now = time.monotonic()
for job_id in targets:
_dub_jobs.pop(job_id, None)
_withdrawn_jobs.pop(job_id, None)
_withdrawn_jobs[job_id] = now # most-recent last
_expire_withdrawn(now, protected=len(targets))
def save_job(job_id: str, job: dict, filename: str = "", duration: float = 0.0, content_hash: str = "") -> None:
"""Persist dub job state to SQLite so it survives restarts. Uses UPSERT
on `id` so repeated saves in a session keep the latest snapshot.
@@ -209,6 +404,22 @@ def save_job(job_id: str, job: dict, filename: str = "", duration: float = 0.0,
keys history restore off language_code, so a frozen "" hid finished
tracks until the user re-picked a language.
"""
with _dub_jobs_lock:
# The withdrawal gate lives HERE, not in the callers (#1252 review).
# Eight call sites across generate / translate / export / core persist
# jobs directly, so gating only the ingest helpers left every
# post-ingest save able to resurrect a dub the user deleted mid-render.
# One choke point closes the class and the ninth caller inherits it.
if job_id in _withdrawn_jobs:
logger.info(
"Dub job %s was deleted while it was still running — not persisting", job_id,
)
return
_persist_job(job_id, job, filename, duration, content_hash)
def _persist_job(job_id: str, job: dict, filename: str, duration: float, content_hash: str) -> None:
"""The actual write. Callers go through :func:`save_job`, which gates it."""
try:
segments = job.get("segments") or []
tracks = list((job.get("dubbed_tracks") or {}).keys())
@@ -556,10 +767,23 @@ _YT_PLAYER_CLIENTS = ["tv", "android", "web_safari"]
def _is_forbidden_download_error(exc: BaseException) -> bool:
"""True for an HTTP 403 — not transient (the same client keeps 403ing), but
often fixable by switching the YouTube player client."""
"""True for a failure the CURRENT player client can't get past, but another
one commonly can.
A 403 is the original case (#625): extraction worked, the media fetch was
refused, and the same client keeps refusing. "This video is DRM protected"
(#1254) behaves identically and belongs here for the same reason — YouTube
serves a DRM-only format set to *some* player clients for videos that are
not actually DRM'd. The reporter saw it fail and then succeed on a plain
retry of the same URL, which is exactly what a per-client format set looks
like from outside. Escalating the client is the fix; a bare retry only
works when the next attempt happens to draw a different one.
"""
s = str(exc)
return "403" in s or "Forbidden" in s
if "403" in s or "Forbidden" in s:
return True
low = s.lower()
return "drm protected" in low or "drm-protected" in low
def _cleanup_partial_download(job_dir: str) -> None:
@@ -837,6 +1061,9 @@ async def ingest_pipeline(
# Audio-only jobs (#119) skip scene detection + thumbnailing below; the
# transcribe → translate → TTS core is identical.
input_type = (source.get("input_type") or "video").lower()
# Declare the run so a "clear history" arriving before this job's first
# persistence can still withdraw it (#1252 review).
begin_ingest(job_id)
try:
if source.get("kind") == "url":
url = source["url"]
@@ -1005,8 +1232,12 @@ async def ingest_pipeline(
"youtube_subs": youtube_subs_by_lang or None,
"input_type": input_type,
}
put_job(job_id, full_job)
save_job(job_id, full_job, filename, dur, content_hash)
if not put_and_save_job(
job_id, full_job, filename=filename, duration=dur, content_hash=content_hash,
):
logger.info("Dub job %s was deleted during ingest — discarding its result", job_id)
yield prep_event("cancelled")
return
yield prep_event("extract_done", job_id=job_id, duration=round(dur, 2), filename=filename)
yield prep_event("cached",
has_bg=bool(no_vocals_path and os.path.exists(no_vocals_path)),
@@ -1029,8 +1260,12 @@ async def ingest_pipeline(
"youtube_subs": youtube_subs_by_lang or None,
"input_type": input_type,
}
put_job(job_id, partial)
save_job(job_id, partial, filename, dur, content_hash)
if not put_and_save_job(
job_id, partial, filename=filename, duration=dur, content_hash=content_hash,
):
logger.info("Dub job %s was deleted during ingest — discarding its result", job_id)
yield prep_event("cancelled")
return
yield prep_event("extract_done", job_id=job_id, duration=round(dur, 2), filename=filename)
vocals_path = os.path.join(job_dir, "vocals.wav")
@@ -1122,13 +1357,30 @@ async def ingest_pipeline(
logger.warning("Thumbnail extraction failed for %s: %s", job_id, e)
yield prep_event("warning", **failure.build_failure(e, stage="thumbnail", include_diagnostic=False))
_dub_jobs[job_id].update({
"vocals_path": vocals_path,
"no_vocals_path": no_vocals_path,
"thumb_path": thumb_path if (thumb_path and os.path.exists(thumb_path)) else None,
"scene_cuts": scene_cuts,
})
save_job(job_id, _dub_jobs[job_id], filename, dur, content_hash)
# The job can legitimately be gone by now — everything above takes
# minutes and `DELETE /dub/history/{id}` pops the record. Deleting
# an in-flight dub used to raise KeyError here and surface as the
# toast `ingest: 'mgw39lx3'` (#1252/#1253). A withdrawn job is not
# an error: stop quietly rather than re-persisting what the user
# just deleted.
# Merge and persist as one step: a delete landing BETWEEN them
# would remove the row and then have it written straight back, so
# the dub the user deleted reappears in history (#1252 review).
if not merge_and_save_job(
job_id,
{
"vocals_path": vocals_path,
"no_vocals_path": no_vocals_path,
"thumb_path": thumb_path if (thumb_path and os.path.exists(thumb_path)) else None,
"scene_cuts": scene_cuts,
},
filename=filename,
duration=dur,
content_hash=content_hash,
):
logger.info("Dub job %s was deleted during ingest — discarding its result", job_id)
yield prep_event("cancelled")
return
yield prep_event("ready", job_id=job_id, duration=round(dur, 2), filename=filename)
except asyncio.CancelledError:
@@ -1148,5 +1400,6 @@ async def ingest_pipeline(
yield prep_event("error", **failure.build_failure(e, stage="ingest"))
return
finally:
end_ingest(job_id)
with _active_procs_lock:
_active_procs.pop(job_id, None)
+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}")
+16 -7
View File
@@ -1024,14 +1024,23 @@ def should_preload_tts_asr() -> bool:
def _is_incomplete_cache_error(exc: BaseException) -> bool:
"""True when `exc` is the truncated-HF-cache class (#352 / #581).
"""True when `exc` is the truncated-HF-cache class (#352 / #581 / #1273).
transformers raises an OSError whose message contains "does not appear to
have a file named " when the on-disk snapshot has config/tokenizer files
but no weight shard the signature of an interrupted download. We match on
that phrase (stable across transformers 4.x/5.x) rather than the error type,
since the same OSError type covers unrelated I/O failures."""
return "does not appear to have a file named" in str(exc)
transformers raises an OSError when the on-disk snapshot has config and
tokenizer files but no weight shard the signature of an interrupted
download. We match on the message (stable across transformers 4.x/5.x)
rather than the error type, since the same OSError type covers unrelated
I/O failures.
There are TWO wordings, and this used to match only the first, so a
half-written repo whose *subfolder* failed to load (#1273:
"Error no file named model.safetensors, … found in directory
/snapshots/<rev>/audio_tokenizer") got neither the automatic repair nor
an actionable message just a raw 500. `core.failure` owns the phrase
list so the heal and the error text can't drift apart."""
from core.failure import is_incomplete_cache_message
return is_incomplete_cache_message(str(exc))
def _hf_offline() -> bool:
+88 -20
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 ────────────────────────────────────────────────────────────────
@@ -774,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()
@@ -909,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()
+109
View File
@@ -331,3 +331,112 @@ def test_lifespan_shutdown_mid_load_is_clean_and_clears_sentinel(
finally:
release.set()
run_sentinel._reset_for_tests()
def test_request_during_shutdown_gets_503_not_a_crash_shaped_500():
"""A user-initiated request that triggers a load while the backend is
shutting down must answer 503, not 500 (#1276).
#1174 made this benign for the background preload, but a request took the
generic unhandled-exception path: crash log, ERROR traceback, and an
error-journal entry that feeds the bug-report pipeline. Quitting the app
with a generate queued therefore surfaced "500 Internal Server Error:
model load skipped: backend shutting down" and offered to file a bug for
a normal teardown.
"""
from fastapi import FastAPI
from fastapi.testclient import TestClient
import main as main_mod
app = FastAPI()
@app.get("/boom")
async def _boom():
raise ModelLoadInterruptedByShutdown("model load skipped: backend shutting down")
app.add_exception_handler(Exception, main_mod.global_exception_handler)
with TestClient(app, raise_server_exceptions=False) as client:
resp = client.get("/boom")
assert resp.status_code == 503, resp.status_code
# Answer a shutting-down server owes a client, so the UI can retry rather
# than treat it as a fault.
assert resp.headers.get("Retry-After") == "5"
detail = resp.json()["detail"]
# Cross-layer contract: the UI keys off this marker to drop the "Report"
# action (utils/errorToast.jsx). It must NOT key off the 503 status alone —
# a real engine-load timeout and an unavailable engine are 503 too, and
# those are reportable bugs. Renaming this marker breaks that; keep in sync.
assert "[shutting_down]" in detail
# Actionable, and free of the internal phrasing that read as a crash.
assert "shutting down" in detail
assert "Reopen the app" in detail
assert "Internal Server Error" not in detail
def test_shutdown_request_is_not_written_to_the_crash_log_or_journal(tmp_path, monkeypatch):
"""The same teardown must leave no crash-log entry and no journal record —
those are what the auto bug reporter reads (#1276)."""
from fastapi import FastAPI
from fastapi.testclient import TestClient
import main as main_mod
from core import error_journal
crash_log = tmp_path / "crash.log"
monkeypatch.setattr(main_mod, "CRASH_LOG_PATH", str(crash_log))
recorded = []
monkeypatch.setattr(
error_journal, "record", lambda *a, **k: recorded.append(a) or {}
)
app = FastAPI()
@app.get("/boom")
async def _boom():
raise ModelLoadInterruptedByShutdown("model load skipped: backend shutting down")
app.add_exception_handler(Exception, main_mod.global_exception_handler)
with TestClient(app, raise_server_exceptions=False) as client:
assert client.get("/boom").status_code == 503
assert not crash_log.exists(), crash_log.read_text()
assert recorded == []
def test_shutdown_class_is_matched_across_duplicate_imports():
"""The handler must recognise the shutdown class even when it arrives from
a second copy of the module.
``services.model_manager`` gets imported under more than one module name
depending on which sys.path root is active (and in the frozen build), so
``ModelLoadInterruptedByShutdown`` can exist as two distinct class objects.
A bare ``isinstance`` silently fails there and the user is back to a 500
which is exactly what happened when both test suites ran in one session.
"""
from fastapi import FastAPI
from fastapi.testclient import TestClient
import main as main_mod
# A same-named class from a *different* module object — what a duplicate
# import produces.
Impostor = type(
"ModelLoadInterruptedByShutdown", (RuntimeError,), {"__module__": "other.copy"}
)
assert not isinstance(Impostor("x"), ModelLoadInterruptedByShutdown)
app = FastAPI()
@app.get("/boom")
async def _boom():
raise Impostor("model load skipped: backend shutting down")
app.add_exception_handler(Exception, main_mod.global_exception_handler)
with TestClient(app, raise_server_exceptions=False) as client:
assert client.get("/boom").status_code == 503
+2 -2
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.4.0` | Exact release version |
| `: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.4.0-rocm`, `:0.4-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
+3 -3
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.4.0` | Exact release version |
> | `: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.4.0-rocm`, `:0.4-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,7 +89,7 @@ PublishPort=127.0.0.1:3900:3900
Volume=omnivoice-data:/app/omnivoice_data
```
Release pins exist too: `:stable-rocm`, `:0.4.0-rocm`, `:0.4-rocm` mirror
Release pins exist too: `:stable-rocm`, `:0.4.1-rocm`, `:0.4-rocm` mirror
the CUDA tags exactly.
> **Consumer cards and APUs (RX 6000/7000, Strix Point/Halo):** the backend
+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
@@ -1,6 +1,6 @@
{
"name": "omnivoice-studio",
"version": "0.4.0",
"version": "0.4.2",
"private": true,
"license": "AGPL-3.0-only",
"type": "module",
+1 -1
View File
@@ -2941,7 +2941,7 @@ dependencies = [
[[package]]
name = "omnivoice-studio"
version = "0.4.0"
version = "0.4.2"
dependencies = [
"arboard",
"dirs-next",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "omnivoice-studio"
version = "0.4.0"
version = "0.4.2"
description = "OmniVoice Studio AI voice cloning & dubbing desktop app"
authors = ["Debpalash"]
license = "AGPL-3.0-only"
+33
View File
@@ -149,6 +149,39 @@ fn raw_http_get(url: &str, timeout: Duration) -> Result<String, String> {
Ok(buf)
}
/// Exit code `backend/main.py` uses when it could not bind the port (#1223).
/// Keep in sync with `_EXIT_PORT_IN_USE` there.
pub const EXIT_PORT_IN_USE: i32 = 78;
/// Kill whoever holds `port`, then confirm it actually came free.
///
/// #1223: every caller used to kill-then-sleep-then-spawn unconditionally, so
/// a holder we cannot kill — a different user's process, a `taskkill` blocked
/// by policy, a socket sitting in TIME_WAIT that the Windows `netstat`
/// LISTENING filter can't even see — was indistinguishable from success. The
/// backend then died on the bind with a raw errno and the user got "Backend
/// died (exit code 1)".
///
/// Returns true when the port is free afterwards. Polls rather than sleeping a
/// flat interval: the common case (our own orphan) frees in well under 500ms,
/// and the uncommon case deserves longer than one guess.
pub fn free_port_or_report(port: u16) -> bool {
kill_orphan_on_port(port);
for _ in 0..20 {
if !port_in_use(port) {
return true;
}
std::thread::sleep(Duration::from_millis(100));
}
log::error!(
"Port {} is still held after attempting to kill its owner — the \
backend cannot bind it. Another application (or a process owned by a \
different user) is using the port.",
port
);
false
}
/// Kill whatever process owns the port.
#[cfg(unix)]
pub fn kill_orphan_on_port(port: u16) {
+61 -7
View File
@@ -260,8 +260,24 @@ pub fn respawn_backend(
if crate::backend::port_in_use(backend_port()) {
log::warn!("Port {} in use — taking ownership", backend_port());
set_backend_kill_intended(true); // deliberate kill, not a crash (#941)
crate::backend::kill_orphan_on_port(backend_port());
std::thread::sleep(Duration::from_millis(500));
// #1223: verify the port actually came free. Spawning into a port
// we failed to reclaim just moves the failure into the backend,
// where it surfaced as an unexplained "exit code 1".
if !crate::backend::free_port_or_report(backend_port()) {
set_stage(
&stage_handle,
BootstrapStage::Failed {
message: format!(
"Port {} is already in use by another application, \
and OmniVoice could not free it. Quit whatever is \
using that port (another copy of OmniVoice, or an \
app that claimed it) and try again.",
backend_port()
),
},
);
return;
}
}
spawn_backend_and_wait(&app, &stage_handle);
});
@@ -399,7 +415,24 @@ pub fn spawn_backend_and_wait(app: &tauri::AppHandle, stage_handle: &Arc<Mutex<B
);
return;
}
let msg = if err_tail.is_empty() {
// #1223: the backend exits EXIT_PORT_IN_USE when it could not
// bind its port. That is a conflict, not a crash — say what to
// do instead of dumping a traceback whose one meaningful line
// is an OS-translated errno.
let msg = if real_exit
.as_ref()
.and_then(|e| e.code)
.is_some_and(|c| c == crate::backend::EXIT_PORT_IN_USE)
{
format!(
"Port {} is already in use, so the backend could not \
start. Another copy of OmniVoice or an app that \
claimed that port is holding it. Quit it and try \
again; if nothing is visibly running, an orphaned \
backend from a previous session still has the port.",
backend_port()
)
} else if err_tail.is_empty() {
format!("Backend process exited ({}) — no error output captured", exit_info)
} else {
format!("Backend process exited ({}):\n{}", exit_info, err_tail)
@@ -578,10 +611,31 @@ fn supervise_backend(app: &tauri::AppHandle, stage_handle: &Arc<Mutex<BootstrapS
// poll has already stopped post-Ready, so the stage alone won't show).
let _ = app.emit("backend-restarting", exit_info.clone());
set_stage(stage_handle, BootstrapStage::StartingBackend);
// Clear any orphan still holding the port before the respawn.
if crate::backend::port_in_use(backend_port()) {
crate::backend::kill_orphan_on_port(backend_port());
std::thread::sleep(Duration::from_millis(300));
// Clear any orphan still holding the port before the respawn. #1223:
// if it can't be cleared, respawning just reproduces the bind failure
// — stop and say so rather than burning a restart attempt.
if crate::backend::port_in_use(backend_port())
&& !crate::backend::free_port_or_report(backend_port())
{
set_stage(
stage_handle,
BootstrapStage::Failed {
// Wording note: every one of these must contain a phrase
// `BootstrapSplash.detectHints` matches ("port … in use"),
// because that is what turns an English Rust message into
// the LOCALISED `bootstrap.hint_port` the user actually
// reads. Pinned in frontend/src/test/portInUseHint.test.js
// — an earlier draft of this one said "is held by" and
// silently lost the translated guidance.
message: format!(
"Port {} is still in use by another application and \
OmniVoice could not free it, so the backend can't \
restart. Quit whatever is using that port and relaunch.",
backend_port()
),
},
);
return;
}
let child = crate::backend::spawn_backend(app, Some(stage_handle));
track_backend_child(app, child);
-6
View File
@@ -1165,9 +1165,6 @@ function App() {
return (
<div style={{ zoom: uiScale }}>
<BootstrapSplash stage={bootstrapStage} message={bootstrapMessage} />
<Suspense fallback={null}>
<LogsFooter />
</Suspense>
</div>
);
}
@@ -1204,9 +1201,6 @@ function App() {
}}
/>
</Suspense>
<Suspense fallback={null}>
<LogsFooter />
</Suspense>
</div>
);
}
+19 -2
View File
@@ -1,11 +1,28 @@
import { API, apiUrl, apiFetch, apiJson } from './client';
import { useAppStore } from '../store';
export async function generateSpeech(
formData: FormData,
{ signal }: { signal?: AbortSignal } = {},
): Promise<Response> {
// Returns the full Response so callers can stream the WAV blob + read headers.
return apiFetch('/generate', { method: 'POST', body: formData, signal });
// Count this synth as in flight for as long as it runs. Installing an update
// relaunches the process, so the updater has to know a synth is running
// (utils/appBusy) — and the guard belongs HERE, at the one call every synth
// path shares: the Generate tab, voice previews, the compare modal, the
// stories editor and profile previews all reach /generate through this
// function. Tracking it in any single caller would leave the others able to
// be silently discarded, and a new caller would have to remember to opt in.
//
// A count rather than a flag because these overlap: a boolean would be
// cleared by whichever request settled first while the rest were still
// running. `finally` so an abort or a network error releases it too.
useAppStore.getState().addTtsInflight?.(1);
try {
// Returns the full Response so callers can stream the WAV blob + read headers.
return await apiFetch('/generate', { method: 'POST', body: formData, signal });
} finally {
useAppStore.getState().addTtsInflight?.(-1);
}
}
export async function listHistory(): Promise<unknown> {
+12 -1
View File
@@ -141,7 +141,18 @@ export function detectHints(message, logs = []) {
if (/uv sync failed/i.test(all)) hints.push('bootstrap.hint_uv_sync');
if (/hatchling|build_editable/i.test(all)) hints.push('bootstrap.hint_build_backend');
if (/ffmpeg/i.test(all) && /download|timeout/i.test(all)) hints.push('bootstrap.hint_ffmpeg');
if (/port.*in use|address.*in use/i.test(all)) hints.push('bootstrap.hint_port');
// #1223: Windows' WSAEADDRINUSE text is "only one usage of each socket
// address is normally permitted" it contains neither "port ... in use" nor
// "address ... in use", and the OS translates it into the user's locale
// (the report that surfaced this was in Russian). Match the locale-
// independent errnos too: 10048 (Windows), 48 (macOS/BSD), 98 (Linux), and
// the backend's own EX_CONFIG exit code for this case.
if (
/port.*in use|address.*in use|errno 10048|errno 48|errno 98|only one usage of each socket|exit code 78/i.test(
all,
)
)
hints.push('bootstrap.hint_port');
if (/no error output/i.test(all)) hints.push('bootstrap.hint_silent_crash');
if (/seems stuck at|never reported ready/i.test(all)) hints.push('bootstrap.hint_stuck');
if (/blocking GitHub|couldn't download Python|python-build-standalone|dns error/i.test(all))
+101
View File
@@ -0,0 +1,101 @@
/**
* "An update is available" as a toast with actions, not a wall of text.
*
* The release notes for a version are the whole changelog section, which for
* v0.4.1 was 42 bullets. Anything that renders them inline becomes unusable:
* older builds put them in a blocking OS dialog that filled the screen and had
* to be dismissed before the app could be touched.
*
* The opposite failure is just as real this app's only remaining signal was a
* 6-pixel dot beside the version number in the footer, which nobody notices.
*
* So: a toast that states the version, offers the two actions that matter, and
* leaves. The notes stay one click away in Settings Updates, where there is
* room for them.
*/
import React from 'react';
import { useTranslation } from 'react-i18next';
import toast from 'react-hot-toast';
import { useAppStore } from '../store';
import { installUpdate } from '../utils/updater';
import { isAppBusy } from '../utils/appBusy';
/** Toast id — one per version, so a re-check can't stack duplicates. */
const toastId = (version) => `update-available-${version}`;
function UpdateToastBody({ id, version }) {
const { t } = useTranslation();
const openNotes = () => {
useAppStore.getState().openSettingsTab?.('updates');
toast.dismiss(id);
};
const install = () => {
// Installing relaunches the process, so anything in flight is lost not
// just a dub synth, but an upload, a transcription, a translation, an
// export or a standalone TTS run. `isAppBusy` is the shared answer;
// UpdatesPanel asks it too, because that path is reachable without this
// toast and the two checks must not drift.
if (isAppBusy(useAppStore.getState())) {
toast(t('update.busy'), { icon: '⏳' });
return;
}
toast.dismiss(id);
installUpdate(useAppStore.getState());
};
return (
<div className="flex flex-col gap-2" data-testid="update-toast">
<div className="leading-snug">
{t('update.toast_available', {
version,
defaultValue: 'OmniVoice Studio {{version}} is available',
})}
</div>
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
onClick={install}
className="rounded px-2 py-[3px] text-[0.72rem] font-semibold [background:var(--chrome-accent)] text-black hover:opacity-90"
>
{t('update.toast_install', { defaultValue: 'Install and restart' })}
</button>
<button
type="button"
onClick={openNotes}
className="rounded px-2 py-[3px] text-[0.72rem] underline opacity-80 hover:opacity-100"
>
{t('update.toast_whats_new', { defaultValue: "What's new" })}
</button>
<button
type="button"
onClick={() => toast.dismiss(id)}
className="ml-auto rounded px-2 py-[3px] text-[0.72rem] opacity-60 hover:opacity-100"
>
{t('update.toast_later', { defaultValue: 'Later' })}
</button>
</div>
</div>
);
}
/**
* Announce *version* once. Idempotent per version: react-hot-toast replaces a
* toast with the same id rather than stacking, so the 6-hourly re-check can't
* pile up copies of the same announcement.
*
* Deliberately never auto-dismisses an update the user hasn't answered is
* still true. It does not block anything, and "Later" removes it.
*/
export function showUpdateToast(version) {
if (!version) return;
const id = toastId(version);
toast.custom((tst) => <UpdateToastBody id={tst.id} version={version} />, {
id,
duration: Infinity,
position: 'bottom-right',
});
}
export default UpdateToastBody;
+28 -4
View File
@@ -21,6 +21,7 @@ import { prepareReleases } from '../utils/updatePresentation';
import { setChannel } from '../utils/channelControl';
import { fetchChangelog, fetchBackupState } from '../utils/updatesApi';
import { APP_VERSION } from '../utils/appVersion';
import { isAppBusy } from '../utils/appBusy';
import MarkdownLite from './MarkdownLite';
import ChangelogViewer from './ChangelogViewer';
@@ -37,7 +38,11 @@ export default function UpdatesPanel() {
const releasesStatus = useAppStore((s) => s.releasesStatus);
const loadReleases = useAppStore((s) => s.loadReleases);
const dismissUpdate = useAppStore((s) => s.dismissUpdate);
// Subscribed, not read via getState() `busy` disables the install button,
// so it has to re-render when the work starts or finishes.
const dubStep = useAppStore((s) => s.dubStep);
const pillStage = useAppStore((s) => s.stage);
const ttsInflight = useAppStore((s) => s.ttsInflight);
const [changelog, setChangelog] = useState([]);
const [backup, setBackup] = useState(null);
@@ -66,9 +71,18 @@ export default function UpdatesPanel() {
if (v) useAppStore.getState().setWhatsNewSeenVersion?.(v);
}, [appVersion]);
const busy = dubStep === 'generating';
// Installing relaunches the process. `dubStep === 'generating'` used to be
// the whole check, which let a relaunch through during an upload, a
// transcription, a translation, an export or a standalone synth.
//
// Subscribed (not getState()) so this re-renders when work starts or stops:
// it greys the button out and says why, rather than letting the user click
// and get a toast back.
const busy = isAppBusy({ dubStep, stage: pillStage, ttsInflight });
const onInstall = () => {
if (busy) {
// The click-time read is the authority work can start between the last
// render and the click, so `busy` above cannot be the safety check.
if (isAppBusy(useAppStore.getState())) {
toast(t('update.busy'), { icon: '⏳' });
return;
}
@@ -81,7 +95,12 @@ export default function UpdatesPanel() {
<div className="updates-panel">
<div className="updates-panel__live">
{status === 'available' && (
<button className="updates-panel__cta" onClick={onInstall}>
<button
className="updates-panel__cta"
onClick={onInstall}
disabled={busy}
title={busy ? t('update.busy') : undefined}
>
<Download size={13} /> {t('update.available', { version: version || '' })} ·{' '}
{t('update.install')}
</button>
@@ -95,7 +114,12 @@ export default function UpdatesPanel() {
</span>
)}
{status === 'ready' && (
<button className="updates-panel__cta" onClick={onInstall}>
<button
className="updates-panel__cta"
onClick={onInstall}
disabled={busy}
title={busy ? t('update.busy') : undefined}
>
<RotateCw size={13} /> {t('update.restart')}
</button>
)}
+9 -4
View File
@@ -1438,7 +1438,8 @@
"report": "الإبلاغ عن هذا الخطأ",
"report_failed": "تعذّر فتح تقرير الخطأ. حاول مرة أخرى، أو انسخ التفاصيل أعلاه إلى مشكلة جديدة.",
"searchIssues": "البحث عن مشكلات مشابهة",
"unexpected": "خطأ غير متوقع: {{message}}"
"unexpected": "خطأ غير متوقع: {{message}}",
"backend_shutting_down": "يجري إغلاق OmniVoice. أعد فتح التطبيق وحاول مرة أخرى."
},
"keyboard": {
"title": "اختصارات لوحة المفاتيح",
@@ -1891,11 +1892,15 @@
"install_hint": "نزّل التحديث وأعد التشغيل إلى الإصدار الجديد",
"downloading": "جارٍ التحديث… {{pct}}%",
"restart": "أعد التشغيل للتحديث",
"busy": "أكمل الدبلجة أولاً — ثم ثبّت التحديث.",
"busy": "انتظر حتى ينتهي العمل الجاري — ثم ثبّت التحديث.",
"whats_new": "ما هو الجديد",
"failed": "فشل التحديث",
"retry": "أعد المحاولة",
"dismiss": "استبعاد"
"dismiss": "استبعاد",
"toast_available": "الإصدار {{version}} من OmniVoice Studio متاح",
"toast_install": "تثبيت وإعادة التشغيل",
"toast_whats_new": "ما الجديد",
"toast_later": "لاحقًا"
},
"archetypes": {
"featured": "مميز",
@@ -2279,4 +2284,4 @@
"output_title": "ما أبلغ عنه التطبيق",
"retry_hint": "جرّب \"إعادة المحاولة\" من الإعدادات → السجلات → الواجهة الخلفية. وإذا فشلت مجددًا، فإن \"تنظيف وإعادة المحاولة\" يعيد بناء البيئة من الصفر."
}
}
}
+12 -7
View File
@@ -308,8 +308,8 @@
"frontend": "Frontend",
"tauri": "Tauri",
"cancelOp": "Vorgang abbrechen",
"dismiss": "Entlassen",
"dismissStatus": "Status entlassen",
"dismiss": "Schließen",
"dismissStatus": "Status schließen",
"search": "Suchen…",
"no_matches": "Keine Übereinstimmungen",
"recent_and_popular": "Neu und beliebt",
@@ -1276,7 +1276,7 @@
"later": "Vielleicht später",
"star": "Stern auf GitHub",
"opt_out": "Fragen Sie nicht noch einmal",
"dismiss_aria": "Entlassen"
"dismiss_aria": "Schließen"
}
},
"enterprise": {
@@ -1438,7 +1438,8 @@
"report": "Diesen Fehler melden",
"report_failed": "Der Fehlerbericht konnte nicht geöffnet werden. Versuche es erneut oder kopiere die obigen Details in ein neues Issue.",
"searchIssues": "Ähnliche Probleme suchen",
"unexpected": "Unerwarteter Fehler: {{message}}"
"unexpected": "Unerwarteter Fehler: {{message}}",
"backend_shutting_down": "OmniVoice wird beendet. Öffnen Sie die App erneut und versuchen Sie es noch einmal."
},
"keyboard": {
"title": "Tastaturkürzel",
@@ -1891,11 +1892,15 @@
"install_hint": "Update herunterladen und in die neue Version neu starten",
"downloading": "Wird aktualisiert… {{pct}} %",
"restart": "Zum Aktualisieren neu starten",
"busy": "Beende zuerst deine Synchronisation dann installiere das Update.",
"busy": "Warten Sie, bis die laufende Arbeit fertig ist dann installieren Sie das Update.",
"whats_new": "Was ist neu?",
"failed": "Update fehlgeschlagen",
"retry": "Versuchen Sie es noch einmal",
"dismiss": "Entlassen"
"dismiss": "Schließen",
"toast_available": "OmniVoice Studio {{version}} ist verfügbar",
"toast_install": "Installieren und neu starten",
"toast_whats_new": "Neuerungen",
"toast_later": "Später"
},
"archetypes": {
"featured": "Hervorgehoben",
@@ -2279,4 +2284,4 @@
"output_title": "Meldung der App",
"retry_hint": "Versuche \"Erneut versuchen\" unter Einstellungen → Protokolle → Backend. Schlägt es erneut fehl, baut \"Bereinigen & erneut versuchen\" die Umgebung neu auf."
}
}
}
+8 -3
View File
@@ -43,11 +43,15 @@
"install_hint": "Download the update and restart into the new version",
"downloading": "Updating… {{pct}}%",
"restart": "Restart to update",
"busy": "Finish your dub first — then install the update.",
"busy": "Wait for the work in progress to finish — then install the update.",
"whats_new": "What's new",
"failed": "Update failed",
"retry": "Retry",
"dismiss": "Dismiss"
"dismiss": "Dismiss",
"toast_available": "OmniVoice Studio {{version}} is available",
"toast_install": "Install and restart",
"toast_whats_new": "What's new",
"toast_later": "Later"
},
"updates": {
"tab": "Updates",
@@ -1911,7 +1915,8 @@
"report": "Report this bug",
"report_failed": "Couldn't open the bug report. Please try again, or copy the details above into a new issue.",
"searchIssues": "Search similar issues",
"unexpected": "Unexpected error: {{message}}"
"unexpected": "Unexpected error: {{message}}",
"backend_shutting_down": "OmniVoice is shutting down. Reopen the app and try again."
},
"crash": {
"notice": "The voice backend crashed ({{exit}}) {{ago}} ago and is being restarted automatically.",
+9 -4
View File
@@ -1438,7 +1438,8 @@
"report": "Informar de este error",
"report_failed": "No se pudo abrir el informe de error. Inténtalo de nuevo o copia los detalles anteriores en una incidencia nueva.",
"searchIssues": "Buscar problemas similares",
"unexpected": "Error inesperado: {{message}}"
"unexpected": "Error inesperado: {{message}}",
"backend_shutting_down": "OmniVoice se está cerrando. Vuelve a abrir la aplicación e inténtalo de nuevo."
},
"keyboard": {
"title": "Atajos de teclado",
@@ -1891,11 +1892,15 @@
"install_hint": "Descarga la actualización y reinicia en la nueva versión",
"downloading": "Actualizando… {{pct}}%",
"restart": "Reiniciar para actualizar",
"busy": "Termina tu doblaje primero — luego instala la actualización.",
"busy": "Espera a que termine el trabajo en curso — luego instala la actualización.",
"whats_new": "¿Qué hay de nuevo?",
"failed": "La actualización falló",
"retry": "Reintentar",
"dismiss": "Descartar"
"dismiss": "Descartar",
"toast_available": "OmniVoice Studio {{version}} está disponible",
"toast_install": "Instalar y reiniciar",
"toast_whats_new": "Novedades",
"toast_later": "Más tarde"
},
"archetypes": {
"featured": "Destacado",
@@ -2279,4 +2284,4 @@
"output_title": "Lo que informó la aplicación",
"retry_hint": "Prueba \"Reintentar\" en Ajustes → Registros → Backend. Si vuelve a fallar, \"Limpiar y reintentar\" reconstruye el entorno desde cero."
}
}
}
+10 -5
View File
@@ -309,7 +309,7 @@
"tauri": "Taureau",
"cancelOp": "Annuler l'opération",
"dismiss": "Rejeter",
"dismissStatus": "Statut de rejet",
"dismissStatus": "Ignorer le statut",
"search": "Rechercher…",
"no_matches": "Aucune correspondance",
"recent_and_popular": "Récents et populaires",
@@ -1438,7 +1438,8 @@
"report": "Signaler ce bogue",
"report_failed": "Impossible d'ouvrir le rapport de bug. Réessayez, ou copiez les détails ci-dessus dans un nouveau ticket.",
"searchIssues": "Rechercher des problèmes similaires",
"unexpected": "Erreur inattendue : {{message}}"
"unexpected": "Erreur inattendue : {{message}}",
"backend_shutting_down": "OmniVoice est en cours de fermeture. Rouvrez lapplication et réessayez."
},
"keyboard": {
"title": "Raccourcis clavier",
@@ -1891,11 +1892,15 @@
"install_hint": "Télécharger la mise à jour et redémarrer dans la nouvelle version",
"downloading": "Mise à jour… {{pct}} %",
"restart": "Redémarrer pour mettre à jour",
"busy": "Terminez d'abord votre doublage, puis installez la mise à jour.",
"busy": "Attendez la fin du travail en cours, puis installez la mise à jour.",
"whats_new": "Quoi de neuf",
"failed": "La mise à jour a échoué",
"retry": "Réessayer",
"dismiss": "Rejeter"
"dismiss": "Rejeter",
"toast_available": "OmniVoice Studio {{version}} est disponible",
"toast_install": "Installer et redémarrer",
"toast_whats_new": "Nouveautés",
"toast_later": "Plus tard"
},
"archetypes": {
"featured": "En vedette",
@@ -2279,4 +2284,4 @@
"output_title": "Ce que l'application a signalé",
"retry_hint": "Essayez « Réessayer » dans Paramètres → Journaux → Backend. En cas de nouvel échec, « Nettoyer et réessayer » reconstruit l'environnement de zéro."
}
}
}
+9 -4
View File
@@ -1438,7 +1438,8 @@
"report": "इस बग की रिपोर्ट करें",
"report_failed": "बग रिपोर्ट नहीं खुल सकी। कृपया फिर से कोशिश करें, या ऊपर दिए विवरण को नए इशू में कॉपी करें।",
"searchIssues": "मिलते-जुलते मुद्दे खोजें",
"unexpected": "अप्रत्याशित त्रुटि: {{message}}"
"unexpected": "अप्रत्याशित त्रुटि: {{message}}",
"backend_shutting_down": "OmniVoice बंद हो रहा है। ऐप दोबारा खोलें और फिर कोशिश करें।"
},
"keyboard": {
"title": "कीबोर्ड शॉर्टकट",
@@ -1891,11 +1892,15 @@
"install_hint": "अपडेट डाउनलोड करें और नए संस्करण में पुनः आरंभ करें",
"downloading": "अपडेट हो रहा है… {{pct}}%",
"restart": "अपडेट करने के लिए पुनः आरंभ करें",
"busy": "पहले अपनी डबिंग पूरी करें — फिर अपडेट इंस्टॉल करें।",
"busy": "चल रहे काम के पूरा होने का इंतज़ार करें — फिर अपडेट इंस्टॉल करें।",
"whats_new": "नया क्या है",
"failed": "अद्यतन विफल रहा",
"retry": "पुनः प्रयास करें",
"dismiss": "ख़ारिज करें"
"dismiss": "ख़ारिज करें",
"toast_available": "OmniVoice Studio {{version}} उपलब्ध है",
"toast_install": "इंस्टॉल करें और पुनः आरंभ करें",
"toast_whats_new": "नया क्या है",
"toast_later": "बाद में"
},
"archetypes": {
"featured": "विशेष रुप से प्रदर्शित",
@@ -2279,4 +2284,4 @@
"output_title": "ऐप ने क्या बताया",
"retry_hint": "सेटिंग्स → लॉग → बैकएंड में \"पुनः प्रयास\" आज़माएँ। फिर भी विफल हो तो \"साफ़ करें और पुनः प्रयास\" वातावरण को नए सिरे से बनाता है।"
}
}
}
+9 -4
View File
@@ -1438,7 +1438,8 @@
"report": "Laporkan bug ini",
"report_failed": "Tidak dapat membuka laporan bug. Coba lagi, atau salin detail di atas ke isu baru.",
"searchIssues": "Cari masalah serupa",
"unexpected": "Kesalahan tak terduga: {{message}}"
"unexpected": "Kesalahan tak terduga: {{message}}",
"backend_shutting_down": "OmniVoice sedang ditutup. Buka kembali aplikasinya lalu coba lagi."
},
"keyboard": {
"title": "Pintasan keyboard",
@@ -1891,11 +1892,15 @@
"install_hint": "Unduh pembaruan dan mulai ulang ke versi baru",
"downloading": "Memperbarui… {{pct}}%",
"restart": "Mulai ulang untuk memperbarui",
"busy": "Selesaikan sulih suara Anda dulu — lalu instal pembaruannya.",
"busy": "Tunggu pekerjaan yang sedang berjalan selesai — lalu instal pembaruannya.",
"whats_new": "Apa yang baru",
"failed": "Pembaruan gagal",
"retry": "Coba lagi",
"dismiss": "Singkirkan"
"dismiss": "Singkirkan",
"toast_available": "OmniVoice Studio {{version}} tersedia",
"toast_install": "Instal dan mulai ulang",
"toast_whats_new": "Yang baru",
"toast_later": "Nanti"
},
"archetypes": {
"featured": "Unggulan",
@@ -2279,4 +2284,4 @@
"output_title": "Yang dilaporkan aplikasi",
"retry_hint": "Coba \"Coba lagi\" di Pengaturan → Log → Backend. Jika gagal lagi, \"Bersihkan & Coba lagi\" akan membangun ulang lingkungan dari awal."
}
}
}
+9 -4
View File
@@ -1438,7 +1438,8 @@
"report": "Segnala questo bug",
"report_failed": "Impossibile aprire la segnalazione. Riprova oppure copia i dettagli qui sopra in una nuova issue.",
"searchIssues": "Cerca problemi simili",
"unexpected": "Errore imprevisto: {{message}}"
"unexpected": "Errore imprevisto: {{message}}",
"backend_shutting_down": "OmniVoice si sta chiudendo. Riapri lapp e riprova."
},
"keyboard": {
"title": "Scorciatoie da tastiera",
@@ -1891,11 +1892,15 @@
"install_hint": "Scarica l'aggiornamento e riavvia nella nuova versione",
"downloading": "Aggiornamento… {{pct}}%",
"restart": "Riavvia per aggiornare",
"busy": "Completa prima il tuo doppiaggio, poi installa l'aggiornamento.",
"busy": "Attendi il completamento del lavoro in corso, poi installa laggiornamento.",
"whats_new": "Cosa c'è di nuovo",
"failed": "Aggiornamento non riuscito",
"retry": "Riprova",
"dismiss": "Ignora"
"dismiss": "Ignora",
"toast_available": "OmniVoice Studio {{version}} è disponibile",
"toast_install": "Installa e riavvia",
"toast_whats_new": "Novità",
"toast_later": "Più tardi"
},
"archetypes": {
"featured": "In primo piano",
@@ -2279,4 +2284,4 @@
"output_title": "Cosa ha segnalato l'app",
"retry_hint": "Prova \"Riprova\" in Impostazioni → Log → Backend. Se fallisce di nuovo, \"Pulisci e riprova\" ricostruisce l'ambiente da zero."
}
}
}
+12 -7
View File
@@ -308,8 +308,8 @@
"frontend": "フロントエンド",
"tauri": "タウリ",
"cancelOp": "操作をキャンセルする",
"dismiss": "解雇する",
"dismissStatus": "ステータスを却下",
"dismiss": "閉じる",
"dismissStatus": "ステータスを閉じる",
"search": "検索…",
"no_matches": "一致しません",
"recent_and_popular": "最近の人気の",
@@ -1276,7 +1276,7 @@
"later": "たぶん後で",
"star": "GitHub でスターを付ける",
"opt_out": "二度と聞かないでください",
"dismiss_aria": "解雇する"
"dismiss_aria": "閉じる"
}
},
"enterprise": {
@@ -1438,7 +1438,8 @@
"report": "このバグを報告",
"report_failed": "バグレポートを開けませんでした。もう一度お試しいただくか、上記の内容を新しい Issue にコピーしてください。",
"searchIssues": "類似の問題を検索",
"unexpected": "予期しないエラー: {{message}}"
"unexpected": "予期しないエラー: {{message}}",
"backend_shutting_down": "OmniVoice を終了しています。アプリを開き直してからもう一度お試しください。"
},
"keyboard": {
"title": "キーボードショートカット",
@@ -1891,11 +1892,15 @@
"install_hint": "アップデートをダウンロードして新しいバージョンで再起動します",
"downloading": "更新中… {{pct}}%",
"restart": "再起動してアップデート",
"busy": "先に吹き替えを完了してから、アップデートをインストールしてください。",
"busy": "実行中の処理が終わってから、アップデートをインストールしてください。",
"whats_new": "新機能",
"failed": "アップデートに失敗しました",
"retry": "再試行",
"dismiss": "解雇する"
"dismiss": "閉じる",
"toast_available": "OmniVoice Studio {{version}} が利用可能です",
"toast_install": "インストールして再起動",
"toast_whats_new": "新機能",
"toast_later": "後で"
},
"archetypes": {
"featured": "注目の",
@@ -2279,4 +2284,4 @@
"output_title": "アプリが報告した内容",
"retry_hint": "設定 → ログ → バックエンド の「再試行」をお試しください。再度失敗する場合は「クリーンして再試行」で環境を一から再構築します。"
}
}
}
+9 -4
View File
@@ -1438,7 +1438,8 @@
"report": "이 버그 신고",
"report_failed": "버그 리포트를 열지 못했습니다. 다시 시도하거나 위 내용을 새 이슈에 복사해 주세요.",
"searchIssues": "유사한 문제 검색",
"unexpected": "예기치 않은 오류: {{message}}"
"unexpected": "예기치 않은 오류: {{message}}",
"backend_shutting_down": "OmniVoice를 종료하는 중입니다. 앱을 다시 열고 시도하세요."
},
"keyboard": {
"title": "키보드 단축키",
@@ -1891,11 +1892,15 @@
"install_hint": "업데이트를 다운로드하고 새 버전으로 다시 시작합니다",
"downloading": "업데이트 중… {{pct}}%",
"restart": "다시 시작하여 업데이트",
"busy": "먼저 더빙을 완료한 후 업데이트를 설치하세요.",
"busy": "진행 중인 작업이 끝난 후 업데이트를 설치하세요.",
"whats_new": "새로운 소식",
"failed": "업데이트 실패",
"retry": "재시도",
"dismiss": "닫기"
"dismiss": "닫기",
"toast_available": "OmniVoice Studio {{version}}을(를) 사용할 수 있습니다",
"toast_install": "설치 후 재시작",
"toast_whats_new": "새로운 기능",
"toast_later": "나중에"
},
"archetypes": {
"featured": "추천",
@@ -2279,4 +2284,4 @@
"output_title": "앱이 보고한 내용",
"retry_hint": "설정 → 로그 → 백엔드에서 \"다시 시도\"를 눌러 보세요. 다시 실패하면 \"정리 후 다시 시도\"가 환경을 처음부터 다시 구성합니다."
}
}
}
+9 -4
View File
@@ -1438,7 +1438,8 @@
"report": "Deze bug melden",
"report_failed": "Kon het bugrapport niet openen. Probeer het opnieuw of kopieer de details hierboven naar een nieuwe issue.",
"searchIssues": "Vergelijkbare problemen zoeken",
"unexpected": "Onverwachte fout: {{message}}"
"unexpected": "Onverwachte fout: {{message}}",
"backend_shutting_down": "OmniVoice wordt afgesloten. Open de app opnieuw en probeer het nog eens."
},
"keyboard": {
"title": "Sneltoetsen",
@@ -1891,11 +1892,15 @@
"install_hint": "Download de update en start opnieuw op in de nieuwe versie",
"downloading": "Bijwerken… {{pct}}%",
"restart": "Opnieuw opstarten om bij te werken",
"busy": "Voltooi eerst je nasynchronisatie — installeer daarna de update.",
"busy": "Wacht tot het lopende werk klaar is — installeer daarna de update.",
"whats_new": "Wat is er nieuw",
"failed": "Update mislukt",
"retry": "Opnieuw proberen",
"dismiss": "Negeren"
"dismiss": "Negeren",
"toast_available": "OmniVoice Studio {{version}} is beschikbaar",
"toast_install": "Installeren en herstarten",
"toast_whats_new": "Wat is er nieuw",
"toast_later": "Later"
},
"archetypes": {
"featured": "Uitgelicht",
@@ -2279,4 +2284,4 @@
"output_title": "Wat de app meldde",
"retry_hint": "Probeer \"Opnieuw\" in Instellingen → Logboeken → Backend. Mislukt het weer, dan bouwt \"Opschonen en opnieuw\" de omgeving helemaal opnieuw op."
}
}
}
+9 -4
View File
@@ -1438,7 +1438,8 @@
"report": "Zgłoś ten błąd",
"report_failed": "Nie udało się otworzyć zgłoszenia błędu. Spróbuj ponownie lub skopiuj powyższe szczegóły do nowego zgłoszenia.",
"searchIssues": "Szukaj podobnych problemów",
"unexpected": "Nieoczekiwany błąd: {{message}}"
"unexpected": "Nieoczekiwany błąd: {{message}}",
"backend_shutting_down": "OmniVoice się zamyka. Otwórz aplikację ponownie i spróbuj jeszcze raz."
},
"keyboard": {
"title": "Skróty klawiaturowe",
@@ -1891,11 +1892,15 @@
"install_hint": "Pobierz aktualizację i uruchom ponownie w nowej wersji",
"downloading": "Aktualizowanie… {{pct}}%",
"restart": "Uruchom ponownie, aby zaktualizować",
"busy": "Najpierw dokończ dubbing — potem zainstaluj aktualizację.",
"busy": "Poczekaj na zakończenie trwającej pracy — potem zainstaluj aktualizację.",
"whats_new": "Co nowego?",
"failed": "Aktualizacja nie powiodła się",
"retry": "Spróbuj ponownie",
"dismiss": "Odrzuć"
"dismiss": "Odrzuć",
"toast_available": "OmniVoice Studio {{version}} jest dostępna",
"toast_install": "Zainstaluj i uruchom ponownie",
"toast_whats_new": "Co nowego",
"toast_later": "Później"
},
"archetypes": {
"featured": "Polecane",
@@ -2279,4 +2284,4 @@
"output_title": "Co zgłosiła aplikacja",
"retry_hint": "Spróbuj „Ponów” w Ustawienia → Dzienniki → Backend. Jeśli znów się nie uda, „Wyczyść i ponów” odbuduje środowisko od zera."
}
}
}
+9 -4
View File
@@ -1438,7 +1438,8 @@
"report": "Relatar este bug",
"report_failed": "Não foi possível abrir o relatório de bug. Tente novamente ou copie os detalhes acima para uma nova issue.",
"searchIssues": "Pesquisar problemas semelhantes",
"unexpected": "Erro inesperado: {{message}}"
"unexpected": "Erro inesperado: {{message}}",
"backend_shutting_down": "O OmniVoice está sendo encerrado. Reabra o aplicativo e tente novamente."
},
"keyboard": {
"title": "Atalhos de teclado",
@@ -1891,11 +1892,15 @@
"install_hint": "Baixe a atualização e reinicie na nova versão",
"downloading": "Atualizando… {{pct}}%",
"restart": "Reiniciar para atualizar",
"busy": "Termine sua dublagem primeiro — depois instale a atualização.",
"busy": "Aguarde o trabalho em andamento terminar — depois instale a atualização.",
"whats_new": "O que há de novo",
"failed": "Falha na atualização",
"retry": "Tentar novamente",
"dismiss": "Dispensar"
"dismiss": "Dispensar",
"toast_available": "OmniVoice Studio {{version}} está disponível",
"toast_install": "Instalar e reiniciar",
"toast_whats_new": "Novidades",
"toast_later": "Mais tarde"
},
"archetypes": {
"featured": "Destaque",
@@ -2279,4 +2284,4 @@
"output_title": "O que o app relatou",
"retry_hint": "Tente \"Tentar novamente\" em Configurações → Logs → Backend. Se falhar de novo, \"Limpar e tentar novamente\" reconstrói o ambiente do zero."
}
}
}
+12 -7
View File
@@ -308,8 +308,8 @@
"frontend": "Внешний интерфейс",
"tauri": "Тавр",
"cancelOp": "Отменить операцию",
"dismiss": "Увольнять",
"dismissStatus": "Отклонить статус",
"dismiss": "Закрыть",
"dismissStatus": "Закрыть статус",
"search": "Поиск…",
"no_matches": "Нет совпадений",
"recent_and_popular": "Последние и популярные",
@@ -1276,7 +1276,7 @@
"later": "Может быть, позже",
"star": "Звезда на GitHub",
"opt_out": "Не спрашивай больше",
"dismiss_aria": "Увольнять"
"dismiss_aria": "Закрыть"
}
},
"enterprise": {
@@ -1438,7 +1438,8 @@
"report": "Сообщить об этой ошибке",
"report_failed": "Не удалось открыть отчёт об ошибке. Попробуйте ещё раз или скопируйте детали выше в новое обращение.",
"searchIssues": "Искать похожие проблемы",
"unexpected": "Непредвиденная ошибка: {{message}}"
"unexpected": "Непредвиденная ошибка: {{message}}",
"backend_shutting_down": "OmniVoice завершает работу. Откройте приложение заново и повторите попытку."
},
"keyboard": {
"title": "Сочетания клавиш",
@@ -1891,11 +1892,15 @@
"install_hint": "Загрузить обновление и перезапустить в новой версии",
"downloading": "Обновление… {{pct}}%",
"restart": "Перезапустить для обновления",
"busy": "Сначала завершите дубляж — затем установите обновление.",
"busy": "Дождитесь завершения текущей задачи — затем установите обновление.",
"whats_new": "Что нового",
"failed": "Обновление не удалось",
"retry": "Повторить попытку",
"dismiss": "Увольнять"
"dismiss": "Закрыть",
"toast_available": "OmniVoice Studio {{version}} доступна",
"toast_install": "Установить и перезапустить",
"toast_whats_new": "Что нового",
"toast_later": "Позже"
},
"archetypes": {
"featured": "Рекомендуемые",
@@ -2279,4 +2284,4 @@
"output_title": "Что сообщило приложение",
"retry_hint": "Попробуйте «Повторить» в Настройки → Журналы → Бэкенд. Если снова не выйдет, «Очистить и повторить» пересоберёт окружение с нуля."
}
}
}
+9 -4
View File
@@ -1438,7 +1438,8 @@
"report": "Rapportera den här buggen",
"report_failed": "Det gick inte att öppna felrapporten. Försök igen, eller kopiera detaljerna ovan till ett nytt ärende.",
"searchIssues": "Sök liknande problem",
"unexpected": "Oväntat fel: {{message}}"
"unexpected": "Oväntat fel: {{message}}",
"backend_shutting_down": "OmniVoice stängs av. Öppna appen igen och försök på nytt."
},
"keyboard": {
"title": "Kortkommandon",
@@ -1891,11 +1892,15 @@
"install_hint": "Ladda ner uppdateringen och starta om i den nya versionen",
"downloading": "Uppdaterar… {{pct}} %",
"restart": "Starta om för att uppdatera",
"busy": "Slutför din dubbning först installera sedan uppdateringen.",
"busy": "Vänta tills det pågående arbetet är klart installera sedan uppdateringen.",
"whats_new": "Vad är nytt",
"failed": "Uppdateringen misslyckades",
"retry": "Försök igen",
"dismiss": "Avvisa"
"dismiss": "Avvisa",
"toast_available": "OmniVoice Studio {{version}} är tillgänglig",
"toast_install": "Installera och starta om",
"toast_whats_new": "Nyheter",
"toast_later": "Senare"
},
"archetypes": {
"featured": "Utvalda",
@@ -2279,4 +2284,4 @@
"output_title": "Vad appen rapporterade",
"retry_hint": "Prova \"Försök igen\" under Inställningar → Loggar → Backend. Misslyckas det igen bygger \"Rensa och försök igen\" om miljön från grunden."
}
}
}
+9 -4
View File
@@ -1438,7 +1438,8 @@
"report": "รายงานข้อบกพร่องนี้",
"report_failed": "เปิดรายงานข้อบกพร่องไม่ได้ โปรดลองอีกครั้ง หรือคัดลอกรายละเอียดด้านบนไปยังรายงานใหม่",
"searchIssues": "ค้นหาปัญหาที่คล้ายกัน",
"unexpected": "ข้อผิดพลาดที่ไม่คาดคิด: {{message}}"
"unexpected": "ข้อผิดพลาดที่ไม่คาดคิด: {{message}}",
"backend_shutting_down": "OmniVoice กำลังปิดอยู่ เปิดแอปอีกครั้งแล้วลองใหม่"
},
"keyboard": {
"title": "แป้นพิมพ์ลัด",
@@ -1891,11 +1892,15 @@
"install_hint": "ดาวน์โหลดอัปเดตและรีสตาร์ทเป็นเวอร์ชันใหม่",
"downloading": "กำลังอัปเดต… {{pct}}%",
"restart": "รีสตาร์ทเพื่ออัปเดต",
"busy": "ทำการพากย์เสียงของคุณให้เสร็จก่อน แล้วจึงติดตั้งอัปเดต",
"busy": "รอให้งานที่กำลังทำอยู่เสร็จก่อน แล้วจึงติดตั้งอัปเดต",
"whats_new": "มีอะไรใหม่",
"failed": "การอัปเดตล้มเหลว",
"retry": "ลองอีกครั้ง",
"dismiss": "ยกเลิก"
"dismiss": "ยกเลิก",
"toast_available": "OmniVoice Studio {{version}} พร้อมใช้งาน",
"toast_install": "ติดตั้งและรีสตาร์ท",
"toast_whats_new": "มีอะไรใหม่",
"toast_later": "ภายหลัง"
},
"archetypes": {
"featured": "จุดเด่น",
@@ -2279,4 +2284,4 @@
"output_title": "สิ่งที่แอปรายงาน",
"retry_hint": "ลอง \"ลองใหม่\" ใน การตั้งค่า → บันทึก → แบ็กเอนด์ หากยังล้มเหลว \"ล้างและลองใหม่\" จะสร้างสภาพแวดล้อมขึ้นใหม่ทั้งหมด"
}
}
}
+9 -4
View File
@@ -1438,7 +1438,8 @@
"report": "Bu hatayı bildir",
"report_failed": "Hata raporu açılamadı. Tekrar deneyin veya yukarıdaki ayrıntıları yeni bir konuya kopyalayın.",
"searchIssues": "Benzer sorunları ara",
"unexpected": "Beklenmeyen hata: {{message}}"
"unexpected": "Beklenmeyen hata: {{message}}",
"backend_shutting_down": "OmniVoice kapanıyor. Uygulamayı yeniden açıp tekrar dene."
},
"keyboard": {
"title": "Klavye kısayolları",
@@ -1891,11 +1892,15 @@
"install_hint": "Güncellemeyi indir ve yeni sürümle yeniden başlat",
"downloading": "Güncelleniyor… %{{pct}}",
"restart": "Güncellemek için yeniden başlat",
"busy": "Önce dublajını bitir — sonra güncellemeyi yükle.",
"busy": "Devam eden işlem bitene kadar bekle — sonra güncellemeyi yükle.",
"whats_new": "Yenilikler",
"failed": "Güncelleme başarısız oldu",
"retry": "Yeniden dene",
"dismiss": "Reddet"
"dismiss": "Reddet",
"toast_available": "OmniVoice Studio {{version}} kullanılabilir",
"toast_install": "Yükle ve yeniden başlat",
"toast_whats_new": "Yenilikler",
"toast_later": "Daha sonra"
},
"archetypes": {
"featured": "Öne Çıkanlar",
@@ -2279,4 +2284,4 @@
"output_title": "Uygulamanın bildirdiği",
"retry_hint": "Ayarlar → Günlükler → Arka uç bölümünden \"Yeniden dene\"yi deneyin. Yine başarısız olursa \"Temizle ve yeniden dene\" ortamı sıfırdan kurar."
}
}
}
+9 -4
View File
@@ -1438,7 +1438,8 @@
"report": "Повідомити про цю помилку",
"report_failed": "Не вдалося відкрити звіт про помилку. Спробуйте ще раз або скопіюйте деталі вище в нове звернення.",
"searchIssues": "Шукати схожі проблеми",
"unexpected": "Неочікувана помилка: {{message}}"
"unexpected": "Неочікувана помилка: {{message}}",
"backend_shutting_down": "OmniVoice завершує роботу. Відкрийте застосунок знову та спробуйте ще раз."
},
"keyboard": {
"title": "Комбінації клавіш",
@@ -1891,11 +1892,15 @@
"install_hint": "Завантажити оновлення та перезапустити в новій версії",
"downloading": "Оновлення… {{pct}}%",
"restart": "Перезапустити для оновлення",
"busy": "Спочатку завершіть дубляж — потім встановіть оновлення.",
"busy": "Зачекайте, доки завершиться поточна робота — потім встановіть оновлення.",
"whats_new": "Що нового",
"failed": "Не вдалося оновити",
"retry": "Повторіть спробу",
"dismiss": "Відхилити"
"dismiss": "Відхилити",
"toast_available": "OmniVoice Studio {{version}} доступна",
"toast_install": "Встановити та перезапустити",
"toast_whats_new": "Що нового",
"toast_later": "Пізніше"
},
"archetypes": {
"featured": "Рекомендовані",
@@ -2279,4 +2284,4 @@
"output_title": "Що повідомив застосунок",
"retry_hint": "Спробуйте «Повторити» в Налаштування → Журнали → Бекенд. Якщо знову не вдасться, «Очистити й повторити» перебудує середовище з нуля."
}
}
}
+9 -4
View File
@@ -1438,7 +1438,8 @@
"report": "Báo cáo lỗi này",
"report_failed": "Không mở được báo cáo lỗi. Vui lòng thử lại, hoặc sao chép chi tiết ở trên vào một issue mới.",
"searchIssues": "Tìm các vấn đề tương tự",
"unexpected": "Lỗi không mong muốn: {{message}}"
"unexpected": "Lỗi không mong muốn: {{message}}",
"backend_shutting_down": "OmniVoice đang tắt. Mở lại ứng dụng rồi thử lại."
},
"keyboard": {
"title": "Phím tắt",
@@ -1891,11 +1892,15 @@
"install_hint": "Tải bản cập nhật và khởi động lại vào phiên bản mới",
"downloading": "Đang cập nhật… {{pct}}%",
"restart": "Khởi động lại để cập nhật",
"busy": "Hoàn tất lồng tiếng của bạn trước — rồi cài đặt bản cập nhật.",
"busy": "Chờ công việc đang chạy hoàn tất — rồi cài đặt bản cập nhật.",
"whats_new": "Có gì mới",
"failed": "Cập nhật không thành công",
"retry": "Thử lại",
"dismiss": "Loại bỏ"
"dismiss": "Loại bỏ",
"toast_available": "OmniVoice Studio {{version}} đã có",
"toast_install": "Cài đặt và khởi động lại",
"toast_whats_new": "Có gì mới",
"toast_later": "Để sau"
},
"archetypes": {
"featured": "Nổi bật",
@@ -2279,4 +2284,4 @@
"output_title": "Nội dung ứng dụng báo về",
"retry_hint": "Hãy thử \"Thử lại\" trong Cài đặt → Nhật ký → Backend. Nếu vẫn lỗi, \"Dọn dẹp & Thử lại\" sẽ dựng lại môi trường từ đầu."
}
}
}
+12 -7
View File
@@ -96,8 +96,8 @@
"frontend": "前端",
"tauri": "Tauri",
"cancelOp": "取消操作",
"dismiss": "解雇",
"dismissStatus": "解除状态",
"dismiss": "关闭",
"dismissStatus": "关闭状态",
"search": "搜索...",
"no_matches": "没有匹配项",
"recent_and_popular": "最近和热门",
@@ -1239,7 +1239,7 @@
"later": "也许稍后",
"star": "在 GitHub 上加星标",
"opt_out": "不要再问",
"dismiss_aria": "解雇"
"dismiss_aria": "关闭"
}
},
"enterprise": {
@@ -1444,7 +1444,8 @@
"report": "报告此错误",
"report_failed": "无法打开错误报告。请重试,或将上面的详细信息复制到新的 issue 中。",
"searchIssues": "搜索类似问题",
"unexpected": "意外错误:{{message}}"
"unexpected": "意外错误:{{message}}",
"backend_shutting_down": "OmniVoice 正在关闭。请重新打开应用后再试。"
},
"keyboard": {
"title": "键盘快捷键",
@@ -1898,11 +1899,15 @@
"install_hint": "下载更新并重启到新版本",
"downloading": "更新中… {{pct}}%",
"restart": "重启以更新",
"busy": "请先完成配音,再安装更新。",
"busy": "请等待正在进行的任务完成,再安装更新。",
"whats_new": "更新内容",
"failed": "更新失败",
"retry": "重试",
"dismiss": "解雇"
"dismiss": "关闭",
"toast_available": "OmniVoice Studio {{version}} 可用",
"toast_install": "安装并重启",
"toast_whats_new": "新增内容",
"toast_later": "稍后"
},
"archetypes": {
"featured": "精选",
@@ -2286,4 +2291,4 @@
"output_title": "应用报告的内容",
"retry_hint": "请在 设置 → 日志 → 后端 中尝试“重试”。若再次失败,“清理并重试”会从头重建运行环境。"
}
}
}
+12 -7
View File
@@ -308,8 +308,8 @@
"frontend": "前端",
"tauri": "金牛座",
"cancelOp": "取消操作",
"dismiss": "解僱",
"dismissStatus": "解除狀態",
"dismiss": "關閉",
"dismissStatus": "關閉狀態",
"search": "搜尋...",
"no_matches": "沒有匹配項",
"recent_and_popular": "最近和熱門",
@@ -1276,7 +1276,7 @@
"later": "也許稍後",
"star": "在 GitHub 上加星標",
"opt_out": "不要再問",
"dismiss_aria": "解僱"
"dismiss_aria": "關閉"
}
},
"enterprise": {
@@ -1438,7 +1438,8 @@
"report": "回報此錯誤",
"report_failed": "無法開啟錯誤回報。請重試,或將上方的詳細資訊複製到新的 issue 中。",
"searchIssues": "搜尋類似問題",
"unexpected": "未預期的錯誤:{{message}}"
"unexpected": "未預期的錯誤:{{message}}",
"backend_shutting_down": "OmniVoice 正在關閉。請重新開啟應用程式後再試。"
},
"keyboard": {
"title": "鍵盤快速鍵",
@@ -1891,11 +1892,15 @@
"install_hint": "下載更新並重新啟動至新版本",
"downloading": "更新中… {{pct}}%",
"restart": "重新啟動以更新",
"busy": "請先完成配音,再安裝更新。",
"busy": "請等待正在進行的工作完成,再安裝更新。",
"whats_new": "有什麼新消息",
"failed": "更新失敗",
"retry": "重試",
"dismiss": "解僱"
"dismiss": "關閉",
"toast_available": "OmniVoice Studio {{version}} 可用",
"toast_install": "安裝並重新啟動",
"toast_whats_new": "新增內容",
"toast_later": "稍後"
},
"archetypes": {
"featured": "精選",
@@ -2279,4 +2284,4 @@
"output_title": "應用程式回報的內容",
"retry_hint": "請在 設定 → 記錄 → 後端 中嘗試「重試」。若再次失敗,「清理並重試」會從頭重建執行環境。"
}
}
}
+6 -2
View File
@@ -2533,12 +2533,16 @@ input[type="file"]::file-selector-button:hover {
}
.app-startup__title { font-size: 18px; color: #ebdbb2; }
.app-wizard-wrap {
/* Fill viewport above the fixed LogsFooter */
/* Fills the WHOLE viewport: the first-run wizard deliberately renders no
LogsFooter (studio chrome belongs to the studio), so there is nothing to
reserve space for. While it did reserve 28px, the wizard's own root was
`fixed inset-0` and escaped this box anyway its pinned Continue / HF
token row rendered underneath the status bar and was unreachable. */
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: var(--logs-footer-height, 28px);
bottom: 0;
overflow: hidden;
background: var(--color-bg, #1d2021);
display: flex;
+6
View File
@@ -6,6 +6,12 @@ if (import.meta.env.DEV && !window.__vite_plugin_react_preamble_installed__) {
window.__vite_plugin_react_preamble_installed__ = true;
}
// Web-platform gap fills for the oldest WebView we support (macOS 12 ships
// WKWebView 15.6). MUST be first: these are touched during the first React
// render, so a missing one throws mid-render and leaves a dead window rather
// than a degraded feature (#1245).
import './utils/webCompat.js';
// AudioContext autoplay-policy unlock MUST install before any module that
// constructs an AudioContext (wavesurfer.js, the AEC tap, the dictation
// capture, etc.). The side-effecting import patches `window.AudioContext`
+6 -1
View File
@@ -294,7 +294,12 @@ export default function SetupWizard({ onReady }) {
};
return (
<div className="fixed inset-0 flex flex-col items-center overflow-hidden bg-bg px-6 pt-12 font-sans text-fg">
// `absolute`, not `fixed`: this mounts inside `.app-wizard-wrap`, and a
// fixed root would ignore that box and lay its pinned footer out against
// the viewport putting Continue and the HF-token card behind the status
// bar, off the bottom of the window. `pb-4` keeps the pinned row off the
// very edge now that it really is the last thing on screen.
<div className="absolute inset-0 flex flex-col items-center overflow-hidden bg-bg px-6 pb-4 pt-12 font-sans text-fg">
<div className="flex w-full max-w-[1100px] flex-1 flex-col">
{/* ── Masthead: identical identity to setup + install acts ────────── */}
<header
+20
View File
@@ -45,6 +45,22 @@ export interface GenerateSlice {
designSeed: number | null;
keepSeed: boolean;
/**
* How many synth requests are in flight right now.
*
* A COUNT, not a flag. Syntheses overlap the Generate tab, voice previews,
* the compare modal, the stories editor and profile previews can all be
* running at once, and a boolean would be cleared by whichever finished
* first while the others were still going, letting the updater relaunch and
* discard them.
*
* Maintained by `api/generate.ts` around the single `/generate` call every
* one of those paths goes through, so a new caller is covered without having
* to remember this exists. Read via `utils/appBusy`. Transient: never
* persisted, since a synth cannot survive a reload.
*/
ttsInflight: number;
setText: (v: string) => void;
setRefText: (v: string) => void;
setInstruct: (v: string) => void;
@@ -65,6 +81,8 @@ export interface GenerateSlice {
setDesignSeed: (v: number | null) => void;
setKeepSeed: (v: boolean) => void;
/** +1 on synth start, -1 on settle. Floors at 0; never goes negative. */
addTtsInflight: (delta: number) => void;
}
const INITIAL_VD: VDStates = {
@@ -97,6 +115,7 @@ export const createGenerateSlice: StateCreator<GenerateSlice, [], [], GenerateSl
designSeed: null,
keepSeed: false,
ttsInflight: 0,
setText: (v) => set({ text: v }),
setRefText: (v) => set({ refText: v }),
@@ -121,4 +140,5 @@ export const createGenerateSlice: StateCreator<GenerateSlice, [], [], GenerateSl
setDesignSeed: (v) => set({ designSeed: v }),
setKeepSeed: (v) => set({ keepSeed: v }),
addTtsInflight: (delta) => set((st) => ({ ttsInflight: Math.max(0, st.ttsInflight + delta) })),
});
@@ -0,0 +1,124 @@
/**
* First-run wizard chrome: the pinned action row must stay on screen, and the
* studio's status bar must not appear before the user reaches the studio.
*
* The bug: `SetupWizard`'s root was `fixed inset-0`, so it laid itself out
* against the VIEWPORT rather than `.app-wizard-wrap` the box App.jsx sizes
* to stop above the fixed `LogsFooter`. Its pinned footer (Continue, Back, and
* the "set a Hugging Face token" card) therefore rendered underneath the status
* bar, clipped off the bottom of the window: on the Models & engines step the
* Continue button was simply unreachable.
*
* Both halves are pinned here the root must not be `fixed`, and the wizard
* branch must render no `LogsFooter` because either one alone reintroduces
* the clip.
*/
import React from 'react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { I18nextProvider } from 'react-i18next';
import fs from 'node:fs';
import path from 'node:path';
import i18n from '../i18n';
vi.mock('../components/WizardLibrary', () => ({ default: () => null }));
vi.mock('../components/MediaEngineCard', () => ({ default: () => null }));
vi.mock('../components/MirrorRescue', () => ({ default: () => null }));
vi.mock('../components/DictationDemo', () => ({ default: () => null }));
vi.mock('../components/HfTokenCard', () => ({
default: ({ className }) => <div data-testid="hf-token-card" className={className} />,
}));
vi.mock('../api/external', () => ({ openExternal: vi.fn(() => Promise.resolve()) }));
vi.mock('../api/hooks', () => ({
useSetupStatus: () => ({
data: { models_ready: true, missing: [], hf_cache_dir: '/tmp/hf' },
refetch: vi.fn(),
}),
usePreflight: () => ({
data: { ok: true, has_warnings: false, checks: [] },
isLoading: false,
refetch: vi.fn(),
}),
}));
vi.mock('../api/client', () => ({
apiJson: vi.fn(() => Promise.resolve({ available: false, prompted: true })),
apiFetch: vi.fn(() => Promise.resolve({ ok: true, json: () => Promise.resolve({}) })),
API: '',
}));
import SetupWizard from '../pages/SetupWizard';
const withI18n = (node) => <I18nextProvider i18n={i18n}>{node}</I18nextProvider>;
const readSrc = (rel) => fs.readFileSync(path.resolve(__dirname, '..', rel), 'utf8');
beforeEach(() => {
document.body.innerHTML = '';
});
describe('SetupWizard — the pinned action row stays on screen', () => {
it('does not position its root against the viewport', () => {
const { container } = render(withI18n(<SetupWizard onReady={() => {}} />));
const root = container.firstElementChild;
// `fixed` ignores .app-wizard-wrap's box; `absolute` fills it.
expect(root.className).not.toMatch(/\bfixed\b/);
expect(root.className).toMatch(/\babsolute\b/);
// ...and the pinned row needs clearance now that it really is the last
// thing on screen flush against the window edge is the same bug's
// cosmetic tail.
expect(root.className).toMatch(/\bpb-\d/);
});
it('keeps Continue and the HF-token card OUT of the scrolling region', async () => {
render(withI18n(<SetupWizard onReady={() => {}} />));
fireEvent.click(await screen.findByText(/All good — continue/i));
// Models step: the token card and the action row are siblings of the
// scroller, not children of it otherwise they scroll away instead of
// staying pinned.
const card = await screen.findByTestId('hf-token-card');
expect(card.className).toMatch(/shrink-0/);
const cta = await screen.findByText(/Required models ready/i);
const row = cta.closest('div');
expect(row.className).toMatch(/shrink-0/);
let el = card;
while (el) {
expect(el.className || '').not.toMatch(/overflow-y-auto/);
el = el.parentElement;
}
});
});
describe('studio chrome does not appear before the studio', () => {
it('renders no LogsFooter in the first-run wizard or the pre-wizard splash', () => {
const app = readSrc('App.jsx');
// Split App.jsx at the last pre-studio early return. Everything above is a
// screen the user sees BEFORE the studio (awaiting_setup splash,
// !setupChecked splash, the wizard); everything below is the studio.
const split = app.indexOf('// Block the main UI until Rust reports the backend is ready');
expect(split).toBeGreaterThan(0);
const preStudio = app.slice(app.indexOf("if (bootstrapStage === 'awaiting_setup')"), split);
const studio = app.slice(split);
// Asserted per-branch rather than as a global count: a bare count of 1
// would still pass if the mount MOVED from the studio into the splash.
expect(preStudio).not.toContain('LogsFooter');
expect(studio.match(/<LogsFooter\s*\/>/g) || []).toHaveLength(1);
});
it('gives the wizard the whole viewport, since nothing is reserved below it', () => {
const css = readSrc('index.css');
const rule = css.slice(css.indexOf('.app-wizard-wrap {'));
const body = rule.slice(0, rule.indexOf('}'));
// A leftover `bottom: var(--logs-footer-height)` would strand a dead 28px
// gap under the wizard now that no footer is rendered there.
expect(body).not.toContain('--logs-footer-height');
expect(body).toMatch(/bottom:\s*0;/);
});
});
+167
View File
@@ -0,0 +1,167 @@
/**
* The update announcement must be a toast with actions not a wall of text,
* and not a 6-pixel dot.
*
* Release notes for a version are the whole changelog section: v0.4.1's was 42
* bullets. Older builds put that in a blocking OS dialog that filled the screen
* and had to be dismissed before the app could be used at all. Removing the
* dialog left the opposite failure the only remaining signal was a dot beside
* the version number in the footer, which is easy to never notice.
*
* What is pinned here: the toast says which version, offers install and a route
* to the notes, never renders the notes inline, and cannot stack duplicates
* when the 6-hourly re-check fires again.
*/
import React from 'react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { I18nextProvider } from 'react-i18next';
import i18n from '../i18n';
// vi.hoisted: vi.mock factories are lifted above every import, so anything
// they close over has to be hoisted with them rather than declared below.
const { toastCalls, dismissed, installUpdate, openSettingsTab, storeState } = vi.hoisted(() => ({
toastCalls: [],
dismissed: [],
installUpdate: vi.fn(),
openSettingsTab: vi.fn(),
storeState: { dubStep: 'idle', stage: 'idle', ttsInflight: 0 },
}));
vi.mock('react-hot-toast', () => {
const toast = vi.fn();
toast.custom = vi.fn((render, opts) => {
toastCalls.push({ render, opts });
return opts?.id;
});
toast.dismiss = vi.fn((id) => dismissed.push(id));
return { default: toast, toast };
});
vi.mock('../utils/updater', () => ({ installUpdate: (...a) => installUpdate(...a) }));
vi.mock('../store', () => ({
useAppStore: Object.assign(() => undefined, {
getState: () => ({ openSettingsTab, ...storeState }),
}),
}));
import UpdateToastBody, { showUpdateToast } from '../components/UpdateToast';
const withI18n = (node) => <I18nextProvider i18n={i18n}>{node}</I18nextProvider>;
beforeEach(() => {
toastCalls.length = 0;
dismissed.length = 0;
installUpdate.mockClear();
openSettingsTab.mockClear();
Object.assign(storeState, { dubStep: 'idle', stage: 'idle', ttsInflight: 0 });
});
afterEach(() => vi.clearAllMocks());
describe('showUpdateToast', () => {
it('announces the version', async () => {
showUpdateToast('0.4.2');
expect(toastCalls).toHaveLength(1);
render(withI18n(toastCalls[0].render({ id: 'x' })));
expect(await screen.findByText(/0\.4\.2 is available/i)).toBeTruthy();
});
it('uses one id per version, so a re-check cannot stack duplicates', () => {
showUpdateToast('0.4.2');
showUpdateToast('0.4.2');
// Same id both times react-hot-toast replaces rather than appends.
expect(toastCalls.map((c) => c.opts.id)).toEqual([
'update-available-0.4.2',
'update-available-0.4.2',
]);
});
it('never auto-dismisses — an unanswered update is still true', () => {
showUpdateToast('0.4.2');
expect(toastCalls[0].opts.duration).toBe(Infinity);
});
it('does nothing without a version', () => {
showUpdateToast(null);
expect(toastCalls).toHaveLength(0);
});
});
describe('the toast body', () => {
it('offers install, notes and dismiss', async () => {
render(withI18n(<UpdateToastBody id="t1" version="0.4.2" />));
expect(await screen.findByText(/Install and restart/i)).toBeTruthy();
expect(screen.getByText(/What's new/i)).toBeTruthy();
expect(screen.getByText(/Later/i)).toBeTruthy();
});
it('routes to the notes instead of rendering them', async () => {
render(withI18n(<UpdateToastBody id="t1" version="0.4.2" />));
fireEvent.click(await screen.findByText(/What's new/i));
expect(openSettingsTab).toHaveBeenCalledWith('updates');
expect(dismissed).toContain('t1');
});
it('installs on request', async () => {
render(withI18n(<UpdateToastBody id="t1" version="0.4.2" />));
fireEvent.click(await screen.findByText(/Install and restart/i));
expect(installUpdate).toHaveBeenCalled();
expect(dismissed).toContain('t1');
});
// Installing relaunches the process. The guard used to be
// `dubStep === 'generating'` alone, which let the relaunch through and
// silently discarded the work during every other long operation.
it.each([
['a dub upload', { dubStep: 'uploading' }],
['a dub transcription', { dubStep: 'transcribing' }],
['a dub synth', { dubStep: 'generating' }],
['a dub being stopped', { dubStep: 'stopping' }],
['a translation', { stage: 'translating' }],
['an ASR model load', { stage: 'loading-model' }],
['a dictation capture', { stage: 'recording' }],
['an export', { stage: 'exporting' }],
['a standalone TTS synth', { ttsInflight: 1 }],
['a voice preview overlapping another synth', { ttsInflight: 2 }],
])('refuses to restart during %s', async (_label, state) => {
Object.assign(storeState, state);
render(withI18n(<UpdateToastBody id="t1" version="0.4.2" />));
fireEvent.click(await screen.findByText(/Install and restart/i));
expect(installUpdate).not.toHaveBeenCalled();
});
// The mirror image: a guard that never lets go is just a broken updater.
it.each([
['nothing is running', { dubStep: 'idle', stage: 'idle', ttsInflight: 0 }],
['the transcript is open for editing', { dubStep: 'editing' }],
['a dub has finished', { dubStep: 'done' }],
])('still installs when %s', async (_label, state) => {
Object.assign(storeState, state);
render(withI18n(<UpdateToastBody id="t1" version="0.4.2" />));
fireEvent.click(await screen.findByText(/Install and restart/i));
expect(installUpdate).toHaveBeenCalled();
});
it('Later removes it without installing', async () => {
render(withI18n(<UpdateToastBody id="t1" version="0.4.2" />));
fireEvent.click(await screen.findByText(/Later/i));
expect(dismissed).toContain('t1');
expect(installUpdate).not.toHaveBeenCalled();
});
it('renders no release notes inline, however long they are', () => {
const { container } = render(withI18n(<UpdateToastBody id="t1" version="0.4.2" />));
// The 42-bullet changelog is what made the old dialog unusable. The toast
// takes no notes prop at all this asserts the shape stays that way.
expect(container.textContent).not.toMatch(/Highlights|### |^- /m);
expect(container.textContent.length).toBeLessThan(200);
});
});
+53
View File
@@ -0,0 +1,53 @@
/**
* `isAppBusy` decides whether it is safe to throw away the running process.
*
* The bug it replaces: the updater asked `dubStep === 'generating'` and nothing
* else, so installing an update relaunched the app discarding the work
* during a dub upload, a transcription, a translation, an export, or a
* standalone synth. Two copies of that check existed and could drift.
*
* These cases are the contract. Adding a long-running operation to the app
* means adding it here too.
*/
import { describe, it, expect } from 'vitest';
import { isAppBusy } from '../utils/appBusy';
const idle = { dubStep: 'idle', stage: 'idle', ttsInflight: 0 };
describe('isAppBusy', () => {
it.each([
['dub upload', { dubStep: 'uploading' }],
['dub transcription', { dubStep: 'transcribing' }],
['dub synth', { dubStep: 'generating' }],
['dub being stopped', { dubStep: 'stopping' }],
['ASR model load', { stage: 'loading-model' }],
['dictation capture', { stage: 'recording' }],
['transcription', { stage: 'transcribing' }],
['translation', { stage: 'translating' }],
['pill-tracked synth', { stage: 'generating' }],
['export encode', { stage: 'exporting' }],
['refine pass', { stage: 'refining' }],
['standalone TTS synth', { ttsInflight: 1 }],
['overlapping synths', { ttsInflight: 3 }],
])('is busy during %s', (_label, state) => {
expect(isAppBusy({ ...idle, ...state })).toBe(true);
});
it.each([
['nothing running', {}],
// Waiting on the user is not an operation. Counting it would block updates
// for as long as a transcript tab stays open.
['an open transcript', { dubStep: 'editing' }],
['a finished dub', { dubStep: 'done' }],
['a finished pill', { stage: 'done' }],
['a failed pill', { stage: 'error' }],
])('is not busy with %s', (_label, state) => {
expect(isAppBusy({ ...idle, ...state })).toBe(false);
});
it('treats a missing state as not busy rather than throwing', () => {
expect(isAppBusy(undefined)).toBe(false);
expect(isAppBusy(null)).toBe(false);
expect(isAppBusy({})).toBe(false);
});
});
@@ -61,3 +61,67 @@ describe('toastErrorWithReport user-fixable marker mapping (#1188)', () => {
}
});
});
// #1276: a shutdown is not a fault and must not offer a bug report. Matched on
// the [shutting_down] MARKER, never on the bare 503 status 503 is also how a
// real engine-load timeout and an unavailable engine are reported (#1246,
// #1260, #1277), and those are genuine bugs users need to be able to file.
describe('toastErrorWithReport shutdown handling (#1276)', () => {
beforeEach(() => {
toastErrorMock.mockClear();
});
const withStatus = (message, status) => {
const e = new Error(message);
e.name = 'ApiError';
e.status = status;
return e;
};
const SHUTDOWN =
"[shutting_down] OmniVoice is shutting down, so it didn't start loading " +
'the model. Reopen the app and try again.';
it('shows localized guidance for the shutdown marker, with no Report action', () => {
const err = withStatus(SHUTDOWN, 503);
toastErrorWithReport(err.message, err);
expect(toastErrorMock).toHaveBeenCalledTimes(1);
expect(toastErrorMock).toHaveBeenCalledWith('t:errors.backend_shutting_down', {
duration: 8000,
});
});
// The regression this exists to prevent: an earlier version of the fix keyed
// off the 503 status alone, which would have removed the Report button from
// the compute-timeout class the exact bug #1277 was filed for.
it('still offers Report for a 503 that is a real failure', () => {
const err = withStatus(
'503 Service Unavailable: TTS generate ran for more than 300s of actual ' +
'compute time and was abandoned',
503,
);
toastErrorWithReport(err.message, err);
expect(toastErrorMock).toHaveBeenCalledTimes(1);
// A render function, not a string the reportable path.
expect(typeof toastErrorMock.mock.calls[0][0]).toBe('function');
});
it('still offers Report for an engine-unavailable 503', () => {
const err = withStatus("503 Service Unavailable: TTS engine 'xtts' is unavailable", 503);
toastErrorWithReport(err.message, err);
expect(typeof toastErrorMock.mock.calls[0][0]).toBe('function');
});
it('still offers Report for a genuine 500', () => {
const err = withStatus('500 Internal Server Error: something actually broke', 500);
toastErrorWithReport(err.message, err);
expect(typeof toastErrorMock.mock.calls[0][0]).toBe('function');
});
it('still offers Report when there is no status at all', () => {
toastErrorWithReport('Something broke', new Error('Something broke'));
expect(typeof toastErrorMock.mock.calls[0][0]).toBe('function');
});
});
+111
View File
@@ -0,0 +1,111 @@
/**
* #1223 "Backend died (exit code 1)" when port 3900 was already taken.
*
* The backend log said:
* ERROR: [Errno 10048] error while attempting to bind on address
* ('127.0.0.1', 3900): обычно разрешается только одно использование адреса
*
* Two reasons the user got nothing useful:
*
* - `detectHints` only matched /port.*in use|address.*in use/. Windows'
* WSAEADDRINUSE wording ("only one usage of each socket address is normally
* permitted") contains NEITHER phrase and Windows translates it into the
* user's locale, so no English phrase can be relied on at all. The correct
* hint string existed in en.json and was simply unreachable on Windows.
* - `crashCauseHint` had no branch for it, so a port conflict was described
* with the small-GPU VRAM guidance.
*
* Both are pinned here on the locale-independent signals: the errno and the
* backend's dedicated exit code.
*/
import { describe, it, expect } from 'vitest';
import { detectHints } from '../components/BootstrapSplash';
import { crashCauseHint } from '../utils/backendCrash';
const line = (s) => [{ line: s }];
describe('detectHints — port-in-use (#1223)', () => {
it('matches the Windows errno even when the message is localised', () => {
// The reporter's actual log line, Russian text and all.
const russian =
"ERROR: [Errno 10048] error while attempting to bind on address ('127.0.0.1', 3900): " +
'обычно разрешается только одно использование адреса сокета';
expect(detectHints('', line(russian))).toContain('bootstrap.hint_port');
});
it('matches the English Windows wording', () => {
const english =
"[Errno 10048] error while attempting to bind on address ('127.0.0.1', 3900): " +
'only one usage of each socket address (protocol/network address/port) is normally permitted';
expect(detectHints('', line(english))).toContain('bootstrap.hint_port');
});
it.each([
['macOS/BSD', "[Errno 48] error while attempting to bind on address ('127.0.0.1', 3900)"],
['Linux', "[Errno 98] error while attempting to bind on address ('127.0.0.1', 3900)"],
])('matches the %s errno', (_os, msg) => {
expect(detectHints('', line(msg))).toContain('bootstrap.hint_port');
});
it("matches the backend's dedicated exit code with no log at all", () => {
// The crash path where stderr was never captured — the exit code is the
// only signal left.
expect(detectHints('Backend process exited (exit code 78)')).toContain('bootstrap.hint_port');
});
it('still matches the pre-existing English phrasings', () => {
expect(detectHints('', line('address already in use'))).toContain('bootstrap.hint_port');
expect(detectHints('', line('Port 3900 is in use'))).toContain('bootstrap.hint_port');
});
it('does not fire on unrelated failures', () => {
expect(detectHints('', line('uv sync failed'))).not.toContain('bootstrap.hint_port');
// The generic fallback must still be the only hint here.
expect(detectHints('something else entirely')).toEqual(['bootstrap.hint_default']);
});
});
describe('crashCauseHint — port conflict is not a memory problem (#1223)', () => {
it('explains the port conflict for the dedicated exit code', () => {
const hint = crashCauseHint({ exit_code: 78, signal: null });
expect(hint).toMatch(/port 3900 is already in use/i);
expect(hint).not.toMatch(/VRAM|RAM/);
});
it('leaves the OS-OOM branch alone', () => {
expect(crashCauseHint({ exit_code: null, signal: 9 })).toMatch(/ran out of/i);
});
it('leaves the default VRAM branch alone', () => {
expect(crashCauseHint({ exit_code: 1, signal: null })).toMatch(/VRAM/);
});
});
describe('the Rust failure messages reach the localised hint (#1223)', () => {
// These are the two BootstrapStage::Failed messages bootstrap.rs emits when
// it cannot free the port. They are English (as every Rust-side failure
// message is), but they only need to be MATCHABLE: detectHints turns them
// into `bootstrap.hint_port`, which IS localised. If either message is
// reworded so the matcher misses it, the user loses the translated guidance
// — which is exactly the #1223 failure mode, one layer up.
it.each([
[
'take-ownership',
'Port 3900 is already in use by another application, and OmniVoice could not free it. ' +
'Quit whatever is using that port (another copy of OmniVoice, or an app that claimed it) ' +
'and try again.',
],
[
'respawn',
'Port 3900 is still in use by another application and OmniVoice could not free it, so the ' +
"backend can't restart. Quit whatever is using that port and relaunch.",
],
[
'early-exit',
'Port 3900 is already in use, so the backend could not start. Another copy of OmniVoice — ' +
'or an app that claimed that port — is holding it.',
],
])('%s message maps to the localised port hint', (_which, message) => {
expect(detectHints(message)).toContain('bootstrap.hint_port');
});
});
+113
View File
@@ -0,0 +1,113 @@
/**
* Every synth path must register as "in flight" for as long as it runs, so the
* updater can't relaunch the process and discard it.
*
* Two ways the earlier version of this got it wrong:
*
* 1. It was tracked in useTTS, so the Generate tab counted but voice
* previews, the compare modal, the stories editor and profile previews
* all of which call generateSpeech directly did not.
* 2. It was a boolean, so two overlapping syntheses cleared each other:
* whichever settled first said "nothing is running" while the other was
* still going.
*
* Both are fixed by counting, at the one call they all share.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
const { apiFetch, state } = vi.hoisted(() => ({
apiFetch: vi.fn(),
state: { ttsInflight: 0 },
}));
vi.mock('../api/client', () => ({
API: '',
apiUrl: (p) => p,
apiFetch: (...a) => apiFetch(...a),
apiJson: vi.fn(),
}));
vi.mock('../store', () => ({
useAppStore: {
getState: () => ({
...state,
addTtsInflight: (d) => {
state.ttsInflight = Math.max(0, state.ttsInflight + d);
},
}),
},
}));
import { generateSpeech } from '../api/generate';
import { isAppBusy } from '../utils/appBusy';
import { createGenerateSlice } from '../store/generateSlice';
beforeEach(() => {
state.ttsInflight = 0;
apiFetch.mockReset();
});
describe('synth in-flight tracking', () => {
it('counts a synth as busy for as long as the request runs', async () => {
let release;
apiFetch.mockReturnValue(
new Promise((res) => {
release = () => res({ ok: true });
}),
);
const p = generateSpeech(new FormData());
expect(isAppBusy(state)).toBe(true);
release();
await p;
expect(isAppBusy(state)).toBe(false);
});
it('survives overlapping syntheses — the first to finish does not clear the rest', async () => {
const releases = [];
apiFetch.mockImplementation(() => new Promise((res) => releases.push(() => res({ ok: true }))));
const a = generateSpeech(new FormData()); // Generate tab
const b = generateSpeech(new FormData()); // a voice preview alongside it
expect(state.ttsInflight).toBe(2);
releases[0]();
await a;
// b is still synthesising — a relaunch here would discard it.
expect(isAppBusy(state)).toBe(true);
releases[1]();
await b;
expect(isAppBusy(state)).toBe(false);
});
it('releases on failure and on abort, not just on success', async () => {
apiFetch.mockRejectedValueOnce(new Error('network died'));
await expect(generateSpeech(new FormData())).rejects.toThrow('network died');
expect(isAppBusy(state)).toBe(false);
const abort = new DOMException('aborted', 'AbortError');
apiFetch.mockRejectedValueOnce(abort);
await expect(generateSpeech(new FormData())).rejects.toThrow();
expect(isAppBusy(state)).toBe(false);
});
// Against the real slice, not a stand-in: an unbalanced release must not
// drive the count below zero, or a later synth would start already "idle".
it('never goes negative', () => {
let s = { ttsInflight: 0 };
const set = (fn) => {
s = { ...s, ...fn(s) };
};
const slice = createGenerateSlice(set, () => s, {});
slice.addTtsInflight(-1);
slice.addTtsInflight(-5);
expect(s.ttsInflight).toBe(0);
slice.addTtsInflight(1);
expect(s.ttsInflight).toBe(1);
expect(isAppBusy(s)).toBe(true);
});
});
@@ -0,0 +1,94 @@
/**
* The Install button in Settings Updates must be greyed out while work is
* running and, just as importantly, must NOT be greyed out otherwise.
*
* Installing relaunches the process, so a click during a synth or a dub throws
* that work away. The click handler is the safety check (work can start
* between the last render and the click), but the disabled state is what tells
* the user why the button won't do anything.
*
* The failure mode this pins is the expensive one: a busy predicate that is
* accidentally always true makes the app permanently un-updatable, which is
* far worse than the bug it was added to fix.
*/
import React from 'react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen } from '@testing-library/react';
const { storeState, installUpdate } = vi.hoisted(() => ({
storeState: {},
installUpdate: vi.fn(),
}));
const BASE = {
updateStatus: 'available',
updateVersion: '0.4.2',
updateNotes: null,
updateError: null,
updateProgress: 0,
appVersion: '0.4.1',
updateChannel: 'stable',
releases: [],
releasesStatus: 'idle',
loadReleases: vi.fn(),
dismissUpdate: vi.fn(),
setWhatsNewSeenVersion: vi.fn(),
dubStep: 'idle',
stage: 'idle',
ttsInflight: 0,
};
vi.mock('../store', () => ({
useAppStore: Object.assign((sel) => sel(storeState), { getState: () => storeState }),
}));
vi.mock('../utils/updater', () => ({
installUpdate: (...a) => installUpdate(...a),
checkForUpdate: vi.fn(),
}));
vi.mock('../utils/updatesApi', () => ({
fetchChangelog: () => Promise.resolve([]),
fetchBackupState: () => Promise.resolve(null),
}));
vi.mock('../utils/channelControl', () => ({ setChannel: vi.fn() }));
vi.mock('../utils/updatePresentation', () => ({ prepareReleases: () => [] }));
vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (k) => k }) }));
vi.mock('react-hot-toast', () => ({ default: Object.assign(vi.fn(), { error: vi.fn() }) }));
import UpdatesPanel from '../components/UpdatesPanel';
const renderWith = (over) => {
Object.keys(storeState).forEach((k) => delete storeState[k]);
Object.assign(storeState, BASE, over);
return render(<UpdatesPanel />);
};
beforeEach(() => installUpdate.mockClear());
describe('UpdatesPanel install button', () => {
it('is enabled when nothing is running', () => {
renderWith({});
expect(screen.getByRole('button', { name: /update\.install/ })).not.toBeDisabled();
});
it.each([
['a dub upload', { dubStep: 'uploading' }],
['a translation', { stage: 'translating' }],
['a synth', { ttsInflight: 1 }],
['overlapping synths', { ttsInflight: 2 }],
])('is disabled during %s', (_label, over) => {
renderWith(over);
expect(screen.getByRole('button', { name: /update\.install/ })).toBeDisabled();
});
it('disables the restart button too, not just install', () => {
renderWith({ updateStatus: 'ready', ttsInflight: 1 });
expect(screen.getByRole('button', { name: /update\.restart/ })).toBeDisabled();
});
it('an open transcript does not block updating', () => {
// 'editing' waits on the user; treating it as busy would make the app
// un-updatable for as long as a dub tab stays open.
renderWith({ dubStep: 'editing' });
expect(screen.getByRole('button', { name: /update\.install/ })).not.toBeDisabled();
});
});
+205
View File
@@ -0,0 +1,205 @@
/**
* #1245: the app was dead on arrival on macOS 12 (Monterey).
*
* The reporter's whole session was one line `view:launchpad` and
* "Last backend response: none this session — it may never have started".
* The stack is a React render throwing:
*
* AbortSignal.timeout is not a function.
* (In 'AbortSignal.timeout(2e3)', 'AbortSignal.timeout' is undefined)
*
* `useRealtimeEvents` polls backend health with `AbortSignal.timeout(2000)`
* on mount. That method arrived in Safari 16.0; `tauri.conf.json` declares
* `minimumSystemVersion: "12.0"` and `docs/install/macos.md` promises macOS 12,
* which ships WKWebView **15.6**. So this was not an exotic environment it
* was the floor we advertise.
*
* Two halves are pinned:
* 1. the polyfill behaves like the real thing (aborts, and with TimeoutError);
* 2. no app module reaches for a post-floor API that nothing fills in the
* whole class, not just the one method that got reported.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
import { installAbortSignalTimeout } from '../utils/webCompat.js';
const SRC = path.resolve(__dirname, '..');
describe('AbortSignal.timeout polyfill', () => {
let original;
beforeEach(() => {
vi.useFakeTimers();
original = AbortSignal.timeout;
});
afterEach(() => {
vi.useRealTimers();
AbortSignal.timeout = original;
});
it('fills the method in when the WebView lacks it', () => {
// Reproduce Monterey: the method is simply absent.
delete AbortSignal.timeout;
expect(AbortSignal.timeout).toBeUndefined();
installAbortSignalTimeout();
expect(typeof AbortSignal.timeout).toBe('function');
});
it('aborts after the delay, with a TimeoutError reason', () => {
delete AbortSignal.timeout;
installAbortSignalTimeout();
const signal = AbortSignal.timeout(2000);
expect(signal.aborted).toBe(false);
vi.advanceTimersByTime(1999);
expect(signal.aborted).toBe(false);
vi.advanceTimersByTime(1);
expect(signal.aborted).toBe(true);
// `reason` is how a caller tells a timeout apart from a user cancel.
expect(signal.reason?.name).toBe('TimeoutError');
});
it('does not clobber a native implementation', () => {
const native = vi.fn(() => new AbortController().signal);
AbortSignal.timeout = native;
installAbortSignalTimeout();
expect(AbortSignal.timeout).toBe(native);
});
it('is installed before the app boots', () => {
// The gap fill is worthless if it loads after the chunk that throws.
const main = fs.readFileSync(path.join(SRC, 'main.jsx'), 'utf8');
const compat = main.indexOf('utils/webCompat');
const app = main.indexOf('main-app.jsx');
expect(compat).toBeGreaterThan(-1);
expect(app).toBeGreaterThan(-1);
expect(compat).toBeLessThan(app);
});
});
/**
* The recurrence guard. `AbortSignal.timeout` was not special it was
* whichever post-floor API we happened to reach for first. Each entry is an
* API that does NOT exist in Safari 15.6 (the WebView shipped with the macOS
* version `tauri.conf.json` declares) and that `webCompat.js` does not fill
* in, so using one would break Monterey exactly the way #1245 did.
*
* To use one of these: either fill it in inside `webCompat.js` and delete the
* entry, or guard the call site with a runtime check and a fallback.
*
* WHAT THIS DOES NOT COVER deliberately stated so it is not over-trusted:
*
* - **Syntax.** A post-floor *grammar* feature (RegExp lookbehind, Safari
* 16.4) is a parse-time SyntaxError that kills its whole chunk before a
* line runs, and no polyfill can help. Text patterns here cannot see that.
* - **Dependencies.** Only `frontend/src` is scanned. A bundled library using
* a post-floor API or grammar is invisible here and ships anyway.
* - **CSS.** Tailwind v4's own floor is Safari 16.4, and `index.css` uses
* `color-mix()` (16.2) throughout, so the *rendering* floor is already
* above macOS 12 regardless of what JavaScript does. See #1268.
* - **Computed access.** `AbortSignal["timeout"]` or a destructured
* reference evades every pattern below.
*
* This guard closes the class it can see first-party source. The rest is
* tracked in #1268 rather than pretended away.
*/
const POST_FLOOR_APIS = [
// Safari 18.0
{ pattern: /\bURL\s*\.\s*parse\s*\(/, name: 'URL.parse', since: 'Safari 18.0' },
// Safari 17.4
{ pattern: /\bAbortSignal\s*\.\s*any\b/, name: 'AbortSignal.any', since: 'Safari 17.4' },
{ pattern: /\bObject\s*\.\s*groupBy\b/, name: 'Object.groupBy', since: 'Safari 17.4' },
{ pattern: /\bMap\s*\.\s*groupBy\b/, name: 'Map.groupBy', since: 'Safari 17.4' },
{
pattern: /\bPromise\s*\.\s*withResolvers\b/,
name: 'Promise.withResolvers',
since: 'Safari 17.4',
},
{ pattern: /\.checkVisibility\s*\(/, name: 'Element#checkVisibility', since: 'Safari 17.4' },
// Safari 17.0
{ pattern: /\bURL\s*\.\s*canParse\b/, name: 'URL.canParse', since: 'Safari 17.0' },
// Safari 16.4
{ pattern: /\.isWellFormed\s*\(/, name: 'String#isWellFormed', since: 'Safari 16.4' },
{ pattern: /\.toWellFormed\s*\(/, name: 'String#toWellFormed', since: 'Safari 16.4' },
{ pattern: /\bArray\s*\.\s*fromAsync\b/, name: 'Array.fromAsync', since: 'Safari 16.4' },
// Safari 16.0 — the change-by-copy family. `with` is the one most likely to
// be reached for in React state code (`arr.with(i, v)`), and it was missing
// from this list until review caught it.
{ pattern: /\.toSorted\s*\(/, name: 'Array#toSorted', since: 'Safari 16.0' },
{ pattern: /\.toReversed\s*\(/, name: 'Array#toReversed', since: 'Safari 16.0' },
{ pattern: /\.toSpliced\s*\(/, name: 'Array#toSpliced', since: 'Safari 16.0' },
// `arr.with(i, v)` is the idiomatic immutable-state form in React code, so
// it is the sibling most likely to be reached for. It was missing from this
// list until review caught it.
{ pattern: /\.with\s*\(/, name: 'Array#with', since: 'Safari 16.0' },
{ pattern: /\bAbortSignal\s*\.\s*timeout\b/, name: 'AbortSignal.timeout', since: 'Safari 16.0' },
];
/** Entries `webCompat.js` fills in, so app code may use them freely. */
const POLYFILLED = new Set(['AbortSignal.timeout']);
const walk = (dir, out = []) => {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
if (entry.name === 'test' || entry.name === 'node_modules') continue;
walk(full, out);
} else if (/\.(js|jsx|ts|tsx)$/.test(entry.name)) {
out.push(full);
}
}
return out;
};
describe('no app code depends on an API newer than the macOS floor', () => {
it('is written against the macOS version tauri.conf.json declares', () => {
const conf = JSON.parse(
fs.readFileSync(path.resolve(SRC, '..', 'src-tauri', 'tauri.conf.json'), 'utf8'),
);
// The list above is derived from the WebView that ships with this macOS
// version (12.0 → WKWebView 15.6). If the floor moves, re-derive it — the
// test is only as correct as the version it is written against.
//
// Note this asserts what we DECLARE, not what we deliver: the CSS layer
// (Tailwind v4, color-mix) needs Safari 16.4, so 12.0 is currently a
// promise the frontend stack does not keep. Tracked in #1268; asserted
// here so raising the floor forces this list to be revisited.
expect(conf.bundle?.macOS?.minimumSystemVersion).toBe('12.0');
});
it('uses no unfilled post-Safari-15.6 API', () => {
const offenders = [];
for (const file of walk(SRC)) {
if (file.endsWith(path.join('utils', 'webCompat.js'))) continue;
const src = fs.readFileSync(file, 'utf8');
for (const api of POST_FLOOR_APIS) {
if (POLYFILLED.has(api.name)) continue;
if (api.pattern.test(src)) {
offenders.push(`${path.relative(SRC, file)} uses ${api.name} (${api.since})`);
}
}
}
expect(offenders).toEqual([]);
});
it('still guards the API that broke Monterey', () => {
// Sanity: AbortSignal.timeout IS used by app code, so the exemption above
// is load-bearing — if the polyfill is deleted the exemption must go too.
const users = walk(SRC).filter(
(f) =>
!f.endsWith(path.join('utils', 'webCompat.js')) &&
/\bAbortSignal\s*\.\s*timeout\b/.test(fs.readFileSync(f, 'utf8')),
);
expect(users.length).toBeGreaterThan(0);
const compat = fs.readFileSync(path.join(SRC, 'utils', 'webCompat.js'), 'utf8');
expect(compat).toMatch(/AbortSignal\.timeout\s*=/);
});
});
+62
View File
@@ -0,0 +1,62 @@
/**
* "Is long-running work in flight?" the single answer, for anything that
* would destroy that work.
*
* Today the only caller that matters is the updater: installing an update
* relaunches the process, so anything mid-flight is lost. That check used to
* be written inline as `dubStep === 'generating'`, which covered exactly one
* of the app's long operations and silently permitted a relaunch during a dub
* upload, a transcription, a translation, a standalone TTS synth, or an
* export. It was also duplicated in two places, so the two could drift.
*
* The signals unioned here are the ones the store actually knows about:
*
* - `dubStep` the dub workflow's own state machine
* - `stage` the floating status pill, which every long background
* operation already pushes to (ASR model load, dictation
* capture, transcribe, translate, export, refine)
* - `ttsInflight` a COUNT of synth requests in flight, maintained by
* `api/generate.ts` around the one `/generate` call every
* synth path shares (Generate tab, voice previews, the
* compare modal, the stories editor, profile previews).
* A count because those overlap: a boolean would be
* cleared by whichever finished first while the rest were
* still running.
*
* Deliberately NOT busy:
*
* - `dubStep === 'editing'` the transcript is open and waiting on the
* user, which is not an operation. Counting it would block updates for as
* long as a tab stays open, which is its own bug.
* - `'done'` / `'error'` terminal; the work already survived or didn't.
*/
/** Dub steps where work is actually running (see `DubStep` in dubSlice). */
const BUSY_DUB_STEPS = new Set(['uploading', 'transcribing', 'generating', 'stopping']);
/** Pill stages that represent an operation in progress (see `PillStage`). */
const BUSY_PILL_STAGES = new Set([
'loading-model',
'recording',
'transcribing',
'translating',
'generating',
'exporting',
'refining',
]);
/**
* True when interrupting the app right now would lose work the user cares
* about. Takes a store snapshot (`useAppStore.getState()`) rather than reading
* the store itself, so it stays usable from event handlers and from tests.
*/
export function isAppBusy(state) {
if (!state) return false;
return (
BUSY_DUB_STEPS.has(state.dubStep) ||
BUSY_PILL_STAGES.has(state.stage) ||
(state.ttsInflight ?? 0) > 0
);
}
export default isAppBusy;
+12
View File
@@ -196,6 +196,18 @@ export function describeCrashExit(
* the dominant cause for real GPU aborts.
*/
export function crashCauseHint(marker: Pick<BackendCrashMarker, 'exit_code' | 'signal'>): string {
// #1223: the backend exits 78 (EX_CONFIG) when it could not bind its port.
// That is not a crash and has nothing to do with memory — the old message
// sent a user whose real problem was a leftover process off to shrink their
// ASR model. Keep in sync with _EXIT_PORT_IN_USE in backend/main.py.
if (marker.exit_code === 78) {
return (
'The backend could not start because port 3900 is already in use — another copy of ' +
'OmniVoice (or an app that claimed that port) is holding it. Quit the other instance and ' +
'relaunch; if nothing is visibly running, an orphaned backend from a previous session is ' +
'still holding the port.'
);
}
if (marker.signal === 9) {
return (
'It was force-killed (signal 9), which usually means the operating system ran out of ' +
+13 -2
View File
@@ -14,10 +14,21 @@ import { buildBugReportUrl } from './bugReport';
// #1188: backend errors that carry a machine-readable "[code]" marker are
// user-fixable input problems, not bugs show localized guidance (what
// happened + the concrete fix) instead of the raw English detail, and skip
// the "Report" action. Markers are emitted by the backend (the
// [clone_ref_unusable] one in omnivoice/utils/audio.py) keep in sync.
// the "Report" action.
//
// #1276 adds [shutting_down]: the backend is on its way out, so nothing
// failed and there is nothing to report.
//
// Matched on the MARKER, never on the bare 503 status. 503 is also how a real
// engine-load timeout and an unavailable engine are reported (#1246, #1260,
// #1277) those are genuine bugs users need to file, and keying off the
// status alone would silence exactly that class.
//
// Markers are emitted by the backend ([clone_ref_unusable] in
// omnivoice/utils/audio.py, [shutting_down] in main.py) keep in sync.
const USER_FIXABLE_MARKERS = {
'[clone_ref_unusable]': 'tts_errors.ref_audio_unusable',
'[shutting_down]': 'errors.backend_shutting_down',
};
export function toastErrorWithReport(message, error) {
+19 -2
View File
@@ -50,8 +50,25 @@ export async function checkForUpdate(store) {
const { invoke } = await import('@tauri-apps/api/core');
const channel = await currentChannel();
const update = await invoke('check_update', { channel });
if (update) store.setUpdateAvailable(update.version, update.notes || null);
else store.setUpdateIdle();
if (update) {
store.setUpdateAvailable(update.version, update.notes || null);
// Announce it where the user is looking. The footer's version dot stays
// as the persistent, non-intrusive marker; this is the one-time nudge.
// Lazy so the toast never loads in a browser/dev build that can't update.
//
// Caught separately: a failed chunk load must not fall through to the
// outer catch, which calls setUpdateIdle() and would erase the update we
// just found. The announcement is the optional part — the available
// state (footer dot, Settings → Updates) is what has to survive.
try {
const { showUpdateToast } = await import('../components/UpdateToast');
showUpdateToast(update.version);
} catch (e) {
console.debug('Update toast failed to load (non-fatal):', e);
}
} else {
store.setUpdateIdle();
}
} catch (e) {
// Endpoint 404s until the first signed release on a channel — non-fatal.
console.debug('Update check failed (non-fatal):', e);
+55
View File
@@ -0,0 +1,55 @@
/**
* Web-platform gap fills for the OLDEST WebView we claim to support.
*
* `tauri.conf.json` declares `minimumSystemVersion: "12.0"` and the install
* docs promise macOS 12 (Monterey) which ships Safari/WKWebView **15.6**.
* Anything newer than that is not present at runtime on a supported machine,
* and because our entry chunk touches these during the first React render, a
* single missing method is not a degraded feature: it throws mid-render, the
* tree unmounts, and the user gets a dead window with no backend ever started
* (#1245).
*
* This module must be imported for side effects as the FIRST thing in
* `main.jsx`, before any app chunk loads. `test/webCompat.test.js` keeps the
* list honest: it fails CI if app code reaches for a post-15.6 API that is not
* filled in here.
*
* Windows (evergreen WebView2) and Linux (WebKitGTK 2.44 on Ubuntu 24.04+)
* both clear the floor comfortably macOS 12 is the binding constraint.
*/
/**
* `AbortSignal.timeout(ms)` Safari 16.0. Used by the backend health poll on
* the very first render, so its absence took the whole app down on Monterey.
*/
export function installAbortSignalTimeout() {
if (typeof AbortSignal === 'undefined' || typeof AbortController === 'undefined') return;
if (typeof AbortSignal.timeout === 'function') return;
AbortSignal.timeout = function timeout(ms) {
const controller = new AbortController();
setTimeout(() => {
// The spec aborts with a DOMException named TimeoutError, which is how
// callers tell "we gave up" apart from "the user cancelled". Pass it:
// `abort(reason)` has been supported since Safari 15.4, so it IS
// honoured on our 15.6 floor. Do not "simplify" this to a bare
// `controller.abort()` — that would silently turn every TimeoutError
// into an AbortError and break exactly the distinction it exists for.
let reason;
try {
reason = new DOMException('signal timed out', 'TimeoutError');
} catch {
reason = new Error('signal timed out');
reason.name = 'TimeoutError';
}
controller.abort(reason);
}, ms);
return controller.signal;
};
}
export function installWebCompat() {
installAbortSignalTimeout();
}
installWebCompat();
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "omnivoice"
version = "0.4.0"
version = "0.4.2"
description = "OmniVoice: Towards Omnilingual Zero-Shot Text-to-Speech with Diffusion Language Models"
readme = "README.md"
# Free and open-source under the GNU Affero General Public License v3 (see
+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
+540
View File
@@ -0,0 +1,540 @@
"""#1252/#1253: a dub ingest failed with the toast ``ingest: 'mgw39lx3'``.
Two reports, same reporter, same session, four `dub:upload` actions in a row.
The entire user-facing error was eight characters of their own job id:
ingest: 'mgw39lx3'
Error: ingest: 'mgw39lx3'
That is ``str(KeyError("mgw39lx3"))`` the repr of a dict key. ``KeyError``
does not put "a lookup failed" in its message, so `build_failure` faithfully
reported a value with no explanation attached to it.
The lookup that failed: ``ingest_pipeline`` finished with a bare
``_dub_jobs[job_id].update(...)``. Everything before it demucs, scene
detection, thumbnailing takes minutes, and ``DELETE /dub/history/{id}`` pops
the entry. Deleting an in-flight dub therefore raised ``KeyError`` from a
pipeline that was, by then, doing exactly what it was told.
Both halves are covered: the crash no longer happens, and no exception whose
``str()`` is a bare value can present itself to a user that way again.
"""
from __future__ import annotations
import pytest
import time
from core.failure import build_failure, describe_exception
from services import dub_pipeline
@pytest.fixture(autouse=True)
def _clean_jobs():
dub_pipeline._dub_jobs.clear()
yield
dub_pipeline._dub_jobs.clear()
# ── the crash ────────────────────────────────────────────────────────────
def test_merging_into_a_live_job_updates_it():
dub_pipeline.put_job("job1", {"filename": "a.mp4", "scene_cuts": []})
assert dub_pipeline.merge_job("job1", {"scene_cuts": [1.0, 2.0]}) is True
assert dub_pipeline._dub_jobs["job1"]["scene_cuts"] == [1.0, 2.0]
assert dub_pipeline._dub_jobs["job1"]["filename"] == "a.mp4", "a merge, not a replace"
def test_merging_into_a_deleted_job_reports_it_instead_of_raising():
"""The #1252 moment: the user deleted the dub while it was still ingesting."""
dub_pipeline.put_job("mgw39lx3", {"filename": "a.mp4"})
dub_pipeline._dub_jobs.pop("mgw39lx3") # DELETE /dub/history/{id}
assert dub_pipeline.merge_job("mgw39lx3", {"scene_cuts": []}) is False
def test_a_deleted_job_is_not_resurrected():
"""`False` must mean "stop", not "insert it back" — the user deleted this
on purpose, and re-adding it would put a phantom row back in history."""
assert dub_pipeline.merge_job("gone", {"scene_cuts": []}) is False
assert "gone" not in dub_pipeline._dub_jobs
def test_the_ingest_pipeline_no_longer_blind_subscripts_the_job():
"""The call site itself. A direct `_dub_jobs[job_id].update(` anywhere in
the pipeline reintroduces exactly this KeyError."""
import inspect
src = inspect.getsource(dub_pipeline.ingest_pipeline)
assert "_dub_jobs[job_id].update(" not in src
assert "merge_and_save_job(" in src
# ── the message ──────────────────────────────────────────────────────────
def test_a_keyerror_no_longer_presents_as_a_bare_key():
"""The exact reason string the reporter saw."""
failure = build_failure(KeyError("mgw39lx3"), stage="ingest")
assert failure["reason"] != "'mgw39lx3'"
assert failure["error_class"] == "KeyError"
assert "KeyError" in failure["reason"], "name what happened, not just the value"
assert "mgw39lx3" in failure["reason"], "but keep the value — it is the only clue"
def test_an_exception_with_no_message_at_all_still_names_itself():
assert describe_exception(RuntimeError()) == "RuntimeError"
assert describe_exception(ValueError(" ")) == "ValueError"
def test_a_real_message_is_left_exactly_alone():
"""The fix must not prefix class names onto errors that already read fine —
every existing hint and classification matches on message text."""
assert describe_exception(RuntimeError("CUDA out of memory")) == "CUDA out of memory"
assert describe_exception(
OSError("[Errno 28] No space left on device")
) == "[Errno 28] No space left on device"
def test_classification_still_works_through_the_wrapper():
"""`build_failure` classifies on the raw text; adding a class prefix must
not break the hint lookup for messages that do classify."""
failure = build_failure(RuntimeError("No module named 'omnivoice'"), stage="startup")
assert failure["docs_topic"] == "BROKEN_VENV"
assert failure["hint"]
# ── the delete race the first fix left open ──────────────────────────────
def test_merge_and_save_are_one_step(monkeypatch):
"""Review finding (#1252): merging and persisting as two steps leaves a
window where the user deletes the dub in between and the pending save
then UPSERTs the row straight back, so a dub they deleted reappears."""
saved = []
monkeypatch.setattr(
dub_pipeline, "save_job",
lambda job_id, job, *a, **kw: saved.append(job_id),
)
dub_pipeline.put_job("job1", {"filename": "a.mp4"})
assert dub_pipeline.merge_and_save_job("job1", {"scene_cuts": [1.0]}) is True
assert saved == ["job1"]
assert dub_pipeline._dub_jobs["job1"]["scene_cuts"] == [1.0]
def test_a_deleted_job_is_never_persisted(monkeypatch):
saved = []
monkeypatch.setattr(
dub_pipeline, "save_job",
lambda job_id, job, *a, **kw: saved.append(job_id),
)
assert dub_pipeline.merge_and_save_job("gone", {"scene_cuts": []}) is False
assert saved == [], "a withdrawn job must not reach the database"
def test_a_delete_landing_mid_save_cannot_be_overtaken(monkeypatch):
"""The race itself — asserted on ORDER, which is the only thing that
distinguishes it.
Two earlier versions of this test were not tests. The first deleted the job
before calling `merge_and_save_job`, so it merely re-checked the absent-job
case. The second interleaved a real thread but asserted only *what*
happened, not *when* so splitting merge from save (the exact bug) still
passed, because a save landing AFTER the delete looks identical to one
landing before if you only check that both occurred.
The resurrection is precisely `save` completing after `delete`. So record
the order and assert on it: with merge+save atomic under the lock, the
purge cannot start until the save has finished, and the sequence is always
save-then-delete. Split them and the purge is free to run first.
"""
import threading
order = []
order_lock = threading.Lock()
entered_save = threading.Event()
purge_attempted = threading.Event()
def _slow_save(job_id, job, *a, **kw):
entered_save.set()
# Wait until the purge thread has actually reached its purge call, so
# the two are genuinely in flight together, then hold a moment.
purge_attempted.wait(timeout=2.0)
time.sleep(0.05)
with order_lock:
order.append("save")
monkeypatch.setattr(dub_pipeline, "save_job", _slow_save)
dub_pipeline.put_job("job1", {"filename": "a.mp4"})
def _delete_rows():
with order_lock:
order.append("delete")
def _purge():
entered_save.wait(timeout=2.0)
purge_attempted.set()
dub_pipeline.purge_jobs(["job1"], delete_rows=_delete_rows)
purger = threading.Thread(target=_purge)
purger.start()
merged = dub_pipeline.merge_and_save_job("job1", {"scene_cuts": [1.0]})
purge_attempted.set() # release the save if the purge never got that far
purger.join(timeout=5.0)
assert merged is True, "the save started first, so it must complete"
assert order == ["save", "delete"], (
f"the write must never land after the delete — got {order}. "
"'delete' first means the row was resurrected."
)
assert "job1" not in dub_pipeline._dub_jobs
def test_a_purge_that_wins_the_race_stops_the_save_entirely(monkeypatch):
"""The other ordering: purge first, then the pipeline reaches its save."""
saved = []
monkeypatch.setattr(
dub_pipeline, "save_job", lambda job_id, job, *a, **kw: saved.append(job_id),
)
dub_pipeline.put_job("job1", {"filename": "a.mp4"})
dub_pipeline.purge_jobs(["job1"], delete_rows=lambda: None)
assert dub_pipeline.merge_and_save_job("job1", {"scene_cuts": []}) is False
assert saved == [], "a withdrawn job must never reach the database"
def test_the_create_checkpoints_are_atomic_too(monkeypatch):
"""Review finding (#1252): the mid-pipeline put_job + save_job pairs were
still unlocked, so a clear-history landing between them left a ghost row
behind the purge."""
import inspect
src = inspect.getsource(dub_pipeline.ingest_pipeline)
assert "put_and_save_job(" in src
assert "put_job(job_id," not in src, "the unlocked pair is the race"
saved = []
monkeypatch.setattr(
dub_pipeline, "save_job", lambda job_id, job, *a, **kw: saved.append(job_id),
)
dub_pipeline.put_and_save_job("job2", {"filename": "b.mp4"})
assert saved == ["job2"]
assert dub_pipeline._dub_jobs["job2"]["filename"] == "b.mp4"
def test_clear_history_also_evicts_in_memory_jobs(monkeypatch):
"""`DELETE /dub/history` deleted every row but evicted nothing from memory,
so an in-flight job survived "clear history" outright and re-saved itself
on completion."""
import inspect
from api.routers import dub_core
src = inspect.getsource(dub_core.clear_dub_history)
assert "purge_jobs" in src, "clear-all must evict memory too, not just rows"
def test_the_ingest_pipeline_persists_atomically():
import inspect
src = inspect.getsource(dub_pipeline.ingest_pipeline)
assert "merge_and_save_job(" in src
# The two-step form is what the race lived in.
assert "save_job(job_id, get_job(" not in src
# ── withdrawal survives a job's FIRST write ──────────────────────────────
@pytest.fixture(autouse=True)
def _clean_tombstones():
dub_pipeline._inflight_jobs.clear()
dub_pipeline._withdrawn_jobs.clear()
yield
dub_pipeline._inflight_jobs.clear()
dub_pipeline._withdrawn_jobs.clear()
def test_clearing_history_mid_ingest_is_not_undone_by_the_next_checkpoint(monkeypatch):
"""Review finding (#1252): dict membership can't express "withdrawn".
An ingest's FIRST persistence CREATES the entry, so an absent key means
"not written yet" for a new job and "deleted" for an established one two
opposite instructions from one signal. A clear-history landing before that
first checkpoint was therefore silently undone by the checkpoint recreating
the row, and the run went on to persist its result into history the user
had just cleared.
"""
saved = []
monkeypatch.setattr(
dub_pipeline, "save_job", lambda job_id, job, *a, **kw: saved.append(job_id),
)
dub_pipeline.begin_ingest("job1")
# Clear history BEFORE the job has ever been written. It appears in no row,
# so `job_ids` is empty — only the in-flight sweep can catch it.
dub_pipeline.purge_jobs([], delete_rows=lambda: None, include_inflight=True)
assert dub_pipeline.put_and_save_job("job1", {"filename": "a.mp4"}) is False
assert saved == [], "the checkpoint must not recreate a cleared job"
# ...and the terminal write stays refused too.
assert dub_pipeline.merge_and_save_job("job1", {"scene_cuts": []}) is False
def test_a_single_delete_also_withdraws_an_inflight_job(monkeypatch):
saved = []
monkeypatch.setattr(
dub_pipeline, "save_job", lambda job_id, job, *a, **kw: saved.append(job_id),
)
dub_pipeline.begin_ingest("job1")
dub_pipeline.put_and_save_job("job1", {"filename": "a.mp4"})
saved.clear()
dub_pipeline.purge_jobs(["job1"], delete_rows=lambda: None)
assert dub_pipeline.put_and_save_job("job1", {"filename": "a.mp4"}) is False
assert saved == []
def test_a_normal_ingest_is_unaffected(monkeypatch):
"""The gate must be invisible when nobody deletes anything — this runs on
every import."""
saved = []
monkeypatch.setattr(
dub_pipeline, "save_job", lambda job_id, job, *a, **kw: saved.append(job_id),
)
dub_pipeline.begin_ingest("job1")
assert dub_pipeline.put_and_save_job("job1", {"filename": "a.mp4"}) is True
assert dub_pipeline.merge_and_save_job("job1", {"scene_cuts": [1.0]}) is True
assert saved == ["job1", "job1"]
def test_a_delete_during_a_RENDER_is_honoured(monkeypatch):
"""The case Greptile actually reported, and the one an earlier version of
this fix did not close.
A dub is imported ONCE and rendered many times, so the realistic delete
lands during a render long after its ingest ended. Scoping the tombstone
to in-flight ingests looked right and protected almost nothing: the render's
own `save_job` wrote the row straight back.
"""
written = []
monkeypatch.setattr(
dub_pipeline, "_persist_job", lambda *a, **kw: written.append(a[0]),
)
# An ordinary saved dub whose import finished long ago.
dub_pipeline.begin_ingest("old1")
dub_pipeline.put_and_save_job("old1", {"filename": "a.mp4"})
dub_pipeline.end_ingest("old1")
written.clear()
# Deleted from history while a render is still running.
dub_pipeline.purge_jobs(["old1"], delete_rows=lambda: None)
# The render completes and persists, exactly as dub_generate.py does.
dub_pipeline.save_job("old1", {"filename": "a.mp4", "dubbed_tracks": {"en": {}}})
assert written == [], "a render finishing after the delete must not revive it"
def test_the_ingest_ending_is_not_an_un_delete(monkeypatch):
"""`end_ingest` used to clear the tombstone, which is what opened the gap
above the ingest finishing is not the user un-deleting anything."""
monkeypatch.setattr(dub_pipeline, "_persist_job", lambda *a, **kw: None)
dub_pipeline.begin_ingest("job1")
dub_pipeline.purge_jobs(["job1"], delete_rows=lambda: None)
dub_pipeline.end_ingest("job1")
assert "job1" in dub_pipeline._withdrawn_jobs
assert dub_pipeline._inflight_jobs == set()
def test_re_importing_the_same_id_revives_it(monkeypatch):
"""The one thing that legitimately un-deletes a job."""
written = []
monkeypatch.setattr(
dub_pipeline, "_persist_job", lambda *a, **kw: written.append(a[0]),
)
dub_pipeline.purge_jobs(["job1"], delete_rows=lambda: None)
assert dub_pipeline.save_job("job1", {"filename": "a.mp4"}) is None
assert written == []
dub_pipeline.begin_ingest("job1") # a deliberate re-import
assert dub_pipeline.put_and_save_job("job1", {"filename": "a.mp4"}) is True
assert written == ["job1"]
def test_clearing_a_large_history_mid_render_does_not_evict_the_running_job(
monkeypatch,
):
"""Review finding (#1252, Greptile P1): a count-bounded LRU is evictable by
ordinary use.
`DELETE /dub/history` selects every row with no limit, so a user with a
large history clearing it while a render is running would push that very
job's marker out — and the render would then write it straight back. Age is
the honest policy: what matters is how long ago the delete happened, not how
many others happened to follow it.
"""
written = []
monkeypatch.setattr(
dub_pipeline, "_persist_job", lambda *a, **kw: written.append(a[0]),
)
# One dub is mid-render; the user clears a history far larger than any
# count cap would keep.
# Deliberately larger than the size cap, so the cap is forced to choose —
# which is exactly the situation that evicted a live marker before.
everything = ["rendering"] + [
f"old{i}" for i in range(dub_pipeline._WITHDRAWN_MAX + 500)
]
dub_pipeline.purge_jobs(everything, delete_rows=lambda: None)
dub_pipeline.save_job("rendering", {"filename": "a.mp4"})
assert written == [], "the running job's withdrawal must survive the sweep"
def test_markers_expire_by_age(monkeypatch):
"""They are held for the life of the process otherwise, so something has to
let them go but it must be time, not volume."""
import time as _time
base = _time.monotonic()
monkeypatch.setattr(dub_pipeline.time, "monotonic", lambda: base)
dub_pipeline.purge_jobs(["old"], delete_rows=lambda: None)
assert "old" in dub_pipeline._withdrawn_jobs
# Well past the TTL, a later delete sweeps the stale one out.
monkeypatch.setattr(
dub_pipeline.time, "monotonic",
lambda: base + dub_pipeline._WITHDRAWN_TTL_S + 1,
)
dub_pipeline.purge_jobs(["fresh"], delete_rows=lambda: None)
assert "old" not in dub_pipeline._withdrawn_jobs
assert "fresh" in dub_pipeline._withdrawn_jobs
def test_the_marker_map_cannot_grow_without_bound():
"""The count cap is a memory backstop, not the policy."""
for i in range(dub_pipeline._WITHDRAWN_MAX + 100):
dub_pipeline.purge_jobs([f"job{i}"], delete_rows=lambda: None)
assert len(dub_pipeline._withdrawn_jobs) <= dub_pipeline._WITHDRAWN_MAX
def test_the_pipeline_registers_and_releases_its_run():
import inspect
src = inspect.getsource(dub_pipeline.ingest_pipeline)
assert "begin_ingest(job_id)" in src
assert "end_ingest(job_id)" in src, "a leaked tombstone would block a later run"
# Released in `finally`, so a crash or cancel can't leak it.
finally_block = src[src.rindex("finally:"):]
assert "end_ingest(job_id)" in finally_block
def test_clear_history_sweeps_inflight_jobs():
import inspect
from api.routers import dub_core
src = inspect.getsource(dub_core.clear_dub_history)
assert "include_inflight=True" in src, (
"an ingest with no row yet appears in no id list — only the in-flight "
"sweep can clear it"
)
# ── the gate is at the choke point, not in the callers ───────────────────
def test_every_direct_save_path_honours_a_withdrawal(monkeypatch):
"""Review finding (#1252, Greptile P1): gating only the ingest helpers left
eight direct `save_job` call sites across dub generate, translate, export
and core able to resurrect a dub the user deleted *mid-render*. Deleting
during generation is at least as likely as deleting during import.
The gate now lives in `save_job` itself, so every caller inherits it and
the ninth one cannot forget."""
written = []
monkeypatch.setattr(
dub_pipeline, "_persist_job", lambda *a, **kw: written.append(a[0]),
)
dub_pipeline.begin_ingest("job1")
dub_pipeline.purge_jobs(["job1"], delete_rows=lambda: None)
# The shape every router uses: a bare save_job, no lock, no helper.
dub_pipeline.save_job("job1", {"filename": "a.mp4"})
assert written == [], "a post-ingest save must not resurrect a deleted dub"
def test_a_normal_save_still_writes(monkeypatch):
written = []
monkeypatch.setattr(
dub_pipeline, "_persist_job", lambda *a, **kw: written.append(a[0]),
)
dub_pipeline.save_job("job1", {"filename": "a.mp4"})
assert written == ["job1"]
def test_the_lock_is_reentrant():
"""`save_job` acquires the lock, and the atomic helpers call it while
already holding it a plain Lock would deadlock the backend here."""
import threading
assert isinstance(dub_pipeline._dub_jobs_lock, type(threading.RLock()))
def test_the_atomic_helpers_still_work_through_the_reentrant_path(monkeypatch):
"""Guards the deadlock directly: if this hangs, the RLock regressed."""
written = []
monkeypatch.setattr(
dub_pipeline, "_persist_job", lambda *a, **kw: written.append(a[0]),
)
dub_pipeline.begin_ingest("job1")
assert dub_pipeline.put_and_save_job("job1", {"filename": "a.mp4"}) is True
assert dub_pipeline.merge_and_save_job("job1", {"scene_cuts": [1.0]}) is True
assert written == ["job1", "job1"]
def test_eviction_order_does_not_depend_on_hash_seed():
"""A `set` of target ids made eviction order depend on PYTHONHASHSEED — so
which markers survived a cap-forced trim was luck. The test for the
behaviour above passed locally and reddened main for that reason alone.
Same input, same surviving markers, every time.
"""
ids = [f"j{i}" for i in range(50)]
dub_pipeline._withdrawn_jobs.clear()
dub_pipeline.purge_jobs(ids, delete_rows=lambda: None)
first = list(dub_pipeline._withdrawn_jobs)
dub_pipeline._withdrawn_jobs.clear()
dub_pipeline.purge_jobs(ids, delete_rows=lambda: None)
assert list(dub_pipeline._withdrawn_jobs) == first == ids
def test_a_purge_larger_than_the_cap_keeps_all_of_its_own_markers():
"""The cap must never discard a marker the current purge just recorded:
those are the newest and the likeliest to still be held. The bound is
therefore `cap + one purge`, which is the honest guarantee."""
dub_pipeline._withdrawn_jobs.clear()
oversized = [f"x{i}" for i in range(dub_pipeline._WITHDRAWN_MAX + 750)]
dub_pipeline.purge_jobs(oversized, delete_rows=lambda: None)
assert len(dub_pipeline._withdrawn_jobs) == len(oversized)
for job_id in oversized:
assert job_id in dub_pipeline._withdrawn_jobs
+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")
)
+42
View File
@@ -504,3 +504,45 @@ def test_classify_repair_messages():
def test_classify_unrelated_errors_not_cache_corrupt():
assert failure.classify("disk full") != "MODEL_CACHE_CORRUPT"
assert failure.classify("") != "MODEL_CACHE_CORRUPT"
# ── the second transformers wording (#1273) ────────────────────────────────
#
# transformers has two unrelated ways of saying "this snapshot has no weight
# shard". The hub-load wording is _SIGNATURE above; loading a *subfolder* of
# an already-cached snapshot raises this one instead, with no words in common.
# Matching only the first meant a half-written repo produced neither the
# automatic repair nor an actionable message — just a raw 500 (#1273, on a
# host with 10 GB free, i.e. a download that ran out of room).
_SIGNATURE_LOCAL_DIR = (
"Error no file named model.safetensors, or pytorch_model.bin, found in "
r"directory C:\Users\u\AppData\Local\OmniVoice\hf_cache\models--k2-fsa--"
r"OmniVoice\snapshots\c5fdb5ccb189668d56333f77ba2629f4cd7535f4\audio_tokenizer."
)
def test_classify_local_directory_missing_weights_signature():
assert failure.classify(_SIGNATURE_LOCAL_DIR) == "MODEL_CACHE_CORRUPT"
evt = failure.build_failure(_SIGNATURE_LOCAL_DIR, stage="model-load",
include_diagnostic=False)
assert evt["docs_topic"] == "MODEL_CACHE_CORRUPT"
assert "repairs this automatically" in evt["hint"]
def test_self_heal_recognises_both_wordings():
"""The repair ladder keys off the same predicate as the classifier — if it
misses a wording, the user gets a hint about an automatic repair that
never ran."""
from services import model_manager as mm
assert mm._is_incomplete_cache_error(OSError(_SIGNATURE))
assert mm._is_incomplete_cache_error(OSError(_SIGNATURE_LOCAL_DIR))
def test_incomplete_cache_predicate_does_not_overmatch():
# "found in directory" and "no file named" are common English; both
# fragments together are what identifies the class.
assert not failure.is_incomplete_cache_message("no file named foo.wav")
assert not failure.is_incomplete_cache_message("nothing found in directory /tmp")
assert not failure.is_incomplete_cache_message("")
assert failure.classify("no file named foo.wav") != "MODEL_CACHE_CORRUPT"
+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",
+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
+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.2"
source = { editable = "." }
dependencies = [
{ name = "accelerate" },