Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b01c635bfe | ||
|
|
e9307f2dc0 | ||
|
|
c3d0d6b123 | ||
|
|
f99832de90 | ||
|
|
9736fd4859 | ||
|
|
b66b09ceaa | ||
|
|
73ebd6518d | ||
|
|
e3fa40a952 | ||
|
|
1bfcedab43 | ||
|
|
67d1f6a43d | ||
|
|
e6ca31d7a6 | ||
|
|
b722ca7ad1 | ||
|
|
854306fef8 | ||
|
|
fe3efe3633 | ||
|
|
3e2b089698 | ||
|
|
8c77a82727 | ||
|
|
dbc87dbfe2 | ||
|
|
f67fde919c | ||
|
|
00da2d3c94 | ||
|
|
6d42db5052 | ||
|
|
95fae6df0d | ||
|
|
59c7e5a3bd | ||
|
|
916e075eb3 | ||
|
|
09b60901e4 | ||
|
|
2df159e3f7 | ||
|
|
2009ef329c | ||
|
|
a93f8f0c6c | ||
|
|
ca5ca99247 | ||
|
|
9b2659733a | ||
|
|
e02f5b231b | ||
|
|
90bc6f95db | ||
|
|
b0d5abe0fd | ||
|
|
1d3d611b2c | ||
|
|
aa041be850 | ||
|
|
57a8571769 | ||
|
|
4fc494383b | ||
|
|
027c08f1ff | ||
|
|
63d40c33ce | ||
|
|
d800f77e26 | ||
|
|
6715551c2a | ||
|
|
6e565c57d2 | ||
|
|
6cfef5c0cf | ||
|
|
ed321c56e6 | ||
|
|
8695b478cd | ||
|
|
8f4914272e | ||
|
|
8cef017ee5 | ||
|
|
b6d92637e7 | ||
|
|
791dae69db | ||
|
|
349eac9f77 | ||
|
|
cc138a4666 | ||
|
|
728ddcb829 | ||
|
|
3972a72bb9 | ||
|
|
07bbdb778e | ||
|
|
e38e27ad10 | ||
|
|
e79ad0e479 | ||
|
|
ab48594582 | ||
|
|
f92e4223db |
@@ -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.
|
||||
@@ -761,6 +761,75 @@ jobs:
|
||||
scripts/uninstall.sh scripts/uninstall.ps1 \
|
||||
--clobber --repo "${{ github.repository }}"
|
||||
|
||||
# ── Contributors avatar strip on STABLE releases ──────────────────────────
|
||||
# Stable v* releases keep their curated CHANGELOG body + the per-platform
|
||||
# checksums; this appends ONE "## Contributors" avatar strip crediting every
|
||||
# PR author for the tag — including the owner — the courtesy the preview
|
||||
# channel already gets (preview-notes job). It closes the gap where stable
|
||||
# releases credited nobody.
|
||||
#
|
||||
# Two things the naive version got wrong (fixed here):
|
||||
# 1. RANK by contribution. Authors are ordered by merged-PR count for the
|
||||
# tag (descending, ties broken by handle), not alphabetically — the
|
||||
# owner with 30+ PRs should not sort under a one-PR contributor.
|
||||
# 2. Exactly ONE section. GitHub auto-renders its OWN "Contributors" widget
|
||||
# from any plain `@handle` TEXT mention in the body (the CHANGELOG's
|
||||
# "— thanks @user!" credits → `mentions_count`), which duplicates ours
|
||||
# and can't be ranked or include the owner. We neutralise those inline
|
||||
# text mentions in the RELEASE body only (`thanks @u` → `thanks u`; the
|
||||
# repo CHANGELOG keeps the @handles) so GitHub renders no native widget —
|
||||
# our ranked strip's @handles live in HTML attributes, which GitHub does
|
||||
# not count as mentions, so the linked avatars stay clickable.
|
||||
#
|
||||
# MUST append via `gh release edit` on the EXISTING release (never a second
|
||||
# softprops publish — that races tauri-action's per-matrix draft and splits
|
||||
# installers across two releases; see uninstall-scripts). `needs: [build]`
|
||||
# guarantees the release + all checksum appends already landed, and this job
|
||||
# is single (no matrix) so there is no write race. Idempotent: it strips any
|
||||
# prior "## Contributors" block before re-appending, so re-runs don't stack.
|
||||
contributors-strip:
|
||||
needs: [build]
|
||||
if: >-
|
||||
github.event_name == 'push'
|
||||
&& startsWith(github.ref, 'refs/tags/v')
|
||||
&& !contains(github.ref, '-')
|
||||
runs-on: ubuntu-22.04
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Append ranked Contributors avatar strip to the stable release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
REPO: ${{ github.repository }}
|
||||
TAG: ${{ github.ref_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
NOTES=$(gh api --method POST "repos/$REPO/releases/generate-notes" -f tag_name="$TAG" --jq .body)
|
||||
# Rank PR authors by merged-PR count (desc), ties broken by handle.
|
||||
RANKED=$(printf '%s\n' "$NOTES" | grep -oE 'by @[A-Za-z0-9-]+' | sed 's/^by @//' \
|
||||
| sort | uniq -c | sort -k1,1nr -k2,2 | awk '{print $2}' || true)
|
||||
if [ -z "$RANKED" ]; then
|
||||
echo "No PR-author handles in the generated notes for $TAG — nothing to append."
|
||||
exit 0
|
||||
fi
|
||||
# Current body: drop any prior Contributors block (idempotent re-runs;
|
||||
# the strip is always the tail, and checksum sections use '### '
|
||||
# headers so they never match), then neutralise inline @thanks so
|
||||
# GitHub renders no duplicate native contributors widget.
|
||||
BODY=$(gh release view "$TAG" --repo "$REPO" --json body --jq .body)
|
||||
BODY=$(printf '%s\n' "$BODY" | sed '/^## Contributors$/,$d' | sed 's/thanks @/thanks /g')
|
||||
{
|
||||
printf '%s' "$BODY"
|
||||
printf '\n## Contributors\n\nThank you all 💜\n\n'
|
||||
while IFS= read -r h; do
|
||||
[ -z "$h" ] && continue
|
||||
printf '<a href="https://github.com/%s" title="@%s"><img src="https://github.com/%s.png?size=64" width="48" alt="@%s"/></a> ' "$h" "$h" "$h" "$h"
|
||||
done <<< "$RANKED"
|
||||
printf '\n'
|
||||
} > /tmp/stable-notes.md
|
||||
gh release edit "$TAG" --repo "$REPO" --notes-file /tmp/stable-notes.md
|
||||
echo "Appended ranked Contributors strip ($(printf '%s' "$RANKED" | tr '\n' ' ')) to $TAG."
|
||||
|
||||
# ── Auto-generated preview release notes ──────────────────────────────────
|
||||
# tauri-action publishes the rolling `preview` release with the plain
|
||||
# changelog-fallback body ("Auto-generated release for main…"). Replace it
|
||||
|
||||
+10
-1
@@ -36,14 +36,23 @@ frontend/src-tauri/target/
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Local agent-memory DB (memxt) — per-machine state, never committed
|
||||
memxt.db
|
||||
memxt.db-shm
|
||||
memxt.db-wal
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
# Editor / tool caches
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
# Ignore ad-hoc Claude Code state, but allow project-bundled skills
|
||||
# (CLAUDE.md invites `.claude/skills/<name>/SKILL.md`).
|
||||
# (CLAUDE.md invites `.claude/skills/<name>/SKILL.md`) and project-bundled
|
||||
# review agents — the owner's review standards belong with the code they
|
||||
# govern, not in one machine's local state.
|
||||
.claude/*
|
||||
!.claude/skills/
|
||||
!.claude/skills/**
|
||||
!.claude/agents/
|
||||
!.claude/agents/**
|
||||
/.cache*
|
||||
/.tmp/
|
||||
|
||||
|
||||
+83
-1
@@ -6,6 +6,88 @@ The format is loosely based on [Keep a Changelog](https://keepachangelog.com/).
|
||||
Versions track the desktop app (`tauri.conf.json` + `frontend/src-tauri/Cargo.toml`).
|
||||
The bundled TTS model package (`pyproject.toml`) is versioned independently.
|
||||
|
||||
## [0.4.2] — 2026-07-28
|
||||
|
||||
**Highlights**
|
||||
|
||||
- The update prompt is a small toast with buttons, not a screenful of release notes
|
||||
- Installing an update no longer throws away work that is still running
|
||||
- Quitting the app mid-generate stops reporting itself as a crash
|
||||
- A half-downloaded model repairs itself instead of dead-ending
|
||||
- "Dismiss" no longer reads as "terminate an employee" in five languages
|
||||
|
||||
### Changed
|
||||
|
||||
- An available update now announces itself as a toast with **Install and restart**, **What's new** and **Later**, instead of only a dot beside the version number. The release notes stay in Settings → Updates, where there is room for them — a version's notes are the whole changelog section, and rendering them inline is what made the old prompt fill the screen (#1272)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Installing an update no longer relaunches the app while work is running. The check only knew about dub synthesis, so a restart could silently discard an upload, a transcription, a translation, an export or a standalone synth — and two overlapping synths used to cancel each other's protection. Install is now greyed out while anything is in flight (#1272)
|
||||
- A half-downloaded model now repairs itself instead of failing with a raw 500. The automatic repair recognised only one of the two ways the loader reports missing weights, so an interrupted download whose subfolder failed to load got neither the repair nor a hint about what to do (#1273)
|
||||
- Quitting the app with a generate queued reported "500 Internal Server Error: model load skipped: backend shutting down" and offered to file a bug for it. A shutdown is not a fault: the backend now answers 503 with what to do, and no bug report is offered for it (#1276)
|
||||
- Dub history: clearing a large history while a render was running could still resurrect the deleted job — which markers survived depended on the process hash seed, and an oversized purge could discard a live one (#1252)
|
||||
- German, Japanese, Russian and both Chinese locales rendered "Dismiss" as the employment sense — "terminate an employee" — on close buttons (#1272)
|
||||
- The "wait for the current job to finish" message named dubbing specifically, though it now covers uploads, transcription, translation, exports and synthesis; reworded across all 21 languages (#1272)
|
||||
|
||||
## [0.4.1] — 2026-07-27
|
||||
|
||||
**Highlights**
|
||||
|
||||
- AMD GPUs are used again — every ROCm host was silently running on the CPU
|
||||
- Two synth failures that used to say "an error OmniVoice doesn't recognize" now say what actually went wrong
|
||||
- A dub URL ingest that fails on a disk problem now says which folder and why
|
||||
- A broken audio dependency no longer takes the whole backend down at startup
|
||||
- A GPU too small for the chosen engine now says so up front, not after a five-minute wait
|
||||
- A port conflict now says so, instead of "Backend died (exit code 1)"
|
||||
- A model download that dies at 90% now resumes instead of failing the install
|
||||
- First run: Continue and the Hugging Face token box no longer sit under the status bar
|
||||
- macOS 12 (Monterey): the app launches again instead of dying on startup
|
||||
- Exporting a voice or a dub no longer fails when the name isn't spelled in Latin letters
|
||||
- Two more failures that used to arrive as raw OS text now say what to do about them
|
||||
- Unload works on every model the panel offers it for, and a language the active engine can't speak says so
|
||||
- Deleting a dub no longer un-deletes itself when the job it belonged to finishes
|
||||
|
||||
### Changed
|
||||
|
||||
- First run: the status bar (Logs, version, Sponsors) appears once you reach the studio, instead of overlaying the setup steps (#1241)
|
||||
|
||||
### Added
|
||||
|
||||
- `OMNIVOICE_MCP_ALLOWED_HOSTS` — comma-separated host patterns (e.g. `host.containers.internal:*,192.168.1.5:*`) that extend the MCP SDK's DNS-rebinding allowlist, so AI agents running in Docker containers or on other machines can reach the `/mcp` endpoint. The SDK default is localhost-only; this env var is opt-in (#1249)
|
||||
|
||||
### Docs
|
||||
|
||||
- Docker: ROCm section explains that `torch.cuda.is_available() == True` isn't proof the app is on the GPU, and notes the `--group-add` needed for `/dev/kfd` on rootless hosts (#1228)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Deleting a dub while it was still importing crashed the import with the toast `ingest: 'mgw39lx3'` — a dict key and nothing else — and the delete could then be undone by the job's own pending write, in history or mid-render; both are fixed, and no failure can present itself as a bare value again — thanks @dustmaker124-ui! (#1252, #1253)
|
||||
- macOS 12 (Monterey): the app threw on startup and never started the backend — it called a Safari 16 method on the WebView that macOS ships. It launches and works now; some styling still needs a newer WebView (tracked in #1268) — thanks @singhrahat! (#1245)
|
||||
- Settings → Engines: Unload failed with `400 Unknown model id: engine:kittentts` on any in-process engine — the panel offered the button for ids the backend never accepted; the warm dictation model had the same gap — thanks @JavaxmI! (#1247)
|
||||
- Picking a language the active engine can't speak recited 23 codes without saying which engine refused or that switching engine was the fix — thanks @pulananave! (#1257)
|
||||
- A YouTube import that failed as "DRM protected" and then worked on a manual retry now escalates the player client automatically, and a genuinely undownloadable video says so — thanks @gysahlgreene! (#1254)
|
||||
- Exporting a voice profile, persona, dub, subtitle or stem whose name is Chinese, Japanese, Korean, Cyrillic, Greek, Hebrew or emoji failed with a `'latin-1' codec` 500 — every download endpoint now sends the name correctly, and browsers get the real one back — thanks @zvxzdx! (#1262)
|
||||
- A synth that failed because ffmpeg/ffprobe wasn't on the system path said "an error OmniVoice doesn't recognize"; it now names the media engine and points at Settings → Audio tools, and the app's own copy is published on PATH so dependencies find it in the first place — thanks @Heuvelsma! (#1256)
|
||||
- Windows "The paging file is too small" arrived as a bare 500; it now explains that this is a virtual-memory setting, not full RAM, and gives the steps to raise it — thanks @trankeny545-sudo! (#1251)
|
||||
- AMD/ROCm: every ROCm host was silently force-routed to the CPU — the compatibility gate compared a CUDA `sm_` tag against a ROCm build's `gfx` list, which can never match — thanks @simmessa! (#1228)
|
||||
- AMD/ROCm: `torch.compile` was disabled on all AMD hosts by the same mismatched comparison (#1228)
|
||||
- AMD/ROCm: `HSA_OVERRIDE_GFX_VERSION` is auto-set only when your card genuinely needs it and the remap target exists in your build; gfx1150/gfx1151 (Strix Point/Halo) added to the map (#1228)
|
||||
- Windows blocking an engine file (Smart App Control, WDAC, or AppLocker) is now named, with the fix for personal and managed PCs — thanks @AdityaHemantBhat! (#1227)
|
||||
- A failed audio write (`LibsndfileError: System error.`) now names the target file, its folder's writability and the drive's free space — thanks @morozov28061995-boop! (#1221)
|
||||
- Dub URL ingest: a disk error now names the job folder, its writability and the drive's free space, instead of pointing at the system TEMP folder it never used — thanks @dustmaker124-ui! (#1225)
|
||||
- Dub URL ingest fails immediately when the job folder is missing or unwritable, instead of starting a download that can only fail (#1225)
|
||||
- The backend no longer dies at startup when transformers can't resolve its audio tokenizer (a missing or mismatched torchaudio, common on Google Colab) — it starts, and the error arrives with a repair hint — thanks @Navdeep-Chauhan-777! (#1229)
|
||||
- Importing `omnivoice.utils.*` no longer drags in torch, torchaudio, transformers and the full model definition — thanks @Navdeep-Chauhan-777! (#1229)
|
||||
- Colab notebook: the install cell now catches a broken environment with the real error, instead of a 5-minute health timeout two cells later — thanks @Navdeep-Chauhan-777! (#1229)
|
||||
- A GPU with less VRAM than the chosen engine needs is flagged in Settings → Engines before you generate, instead of showing a clean green "accelerated" until the job times out — thanks @AdityaHemantBhat and @beingavais! (#1226, #1222)
|
||||
- A generation timeout now names your actual card and its VRAM and recommends a lighter engine (#1226, #1222)
|
||||
- First run: Continue and the Hugging Face token box rendered underneath the status bar, off the bottom of the window — the wizard laid itself out against the viewport instead of its own frame (#1241)
|
||||
- A busy port 3900 now reports a port conflict instead of "Backend died (exit code 1)", in every language — thanks @xipb14! (#1223)
|
||||
- The app verifies it actually freed the port before starting the backend, rather than assuming the kill worked (#1223)
|
||||
- A model download truncated near the end is now retried and resumed instead of aborting the whole install — thanks @Reaksa-Cambodia! (#1224)
|
||||
- Engine first-use downloads (VoxCPM2, MOSS-TTS-Nano) retry transient network failures instead of failing the load outright (#1224)
|
||||
- A backend killed by the OS mid-stream now leaves a low-memory trail in the crash report (#1224)
|
||||
|
||||
## [0.4.0] — 2026-07-21
|
||||
|
||||
**Highlights**
|
||||
@@ -326,7 +408,6 @@ The quality release. Three long-standing frictions got structural fixes: **regen
|
||||
|
||||
The cold-start release. Three "why is this broken on my machine" mysteries got solved at their roots: **first generations stop dying at 300 seconds** (the timeout was counting the model download as generation time — @moduvoice measured it on a Tesla T4: 0% GPU for the full window), **updates stop deleting engines you installed yourself** (the updater's dependency sync removed anything not in the app's lockfile — including things our own UI told you to install), and **the "slower than v0.3.5" regression is found and fixed** (clone profiles without a transcript were silently re-running a full Whisper transcription on every single generate). Also: Clear History is back, auto-played audio is finally stoppable, @stronghamjji hardened the dub pipeline against wedged transcribes, and @shakib30's community Colab notebook is now the linked no-GPU path. Thank you all.
|
||||
|
||||
|
||||
### Added
|
||||
|
||||
- **Agent Skills: `npx skills add debpalash/omnivoice-studio`.** Two installable [skills](https://skills.sh) now ship in the repo — `omnivoice` teaches any AI agent (Claude Code, Cursor, Codex, …) to speak and transcribe through your local install via the OpenAI-compatible API, including your cloned voices; `oss-maintainer` packages the maintainer methodology this project is run with.
|
||||
@@ -675,6 +756,7 @@ across dub, generate, and design (a corrupt-binary failure no longer poses as
|
||||
above Continue, framed around what it actually buys you — authenticated, faster,
|
||||
more reliable downloads (higher rate limits, fewer stalls) — with a one-click
|
||||
"get a free token" link. (#657, #669)
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Bug reports redact more secrets and every Windows username casing.** The
|
||||
|
||||
@@ -42,7 +42,7 @@ For anything new: prefer what's already pinned in `pyproject.toml` / `frontend/p
|
||||
- Docker: `ghcr.io/debpalash/omnivoice-studio:latest` = **main** (rolling preview); `:X.Y.Z` + `:X.Y` + `:stable` = tagged releases. `:latest` is the preview channel by design — stable users pin `:stable` or a version tag.
|
||||
- Do not bump minor/major or invent RCs/codenames without the owner asking. No "defer to next version" labels — scope is absorbed or declined, never re-versioned.
|
||||
|
||||
**Docs-sync (hard rule, owner-set 2026-06-11):** any change that alters something these docs describe — README.md, CONTRIBUTING.md, SECURITY.md, SUPPORT.md, LICENSE, or `docs/**` (install flows, Docker tag semantics, platform support, versioning/release behavior, review process, supported versions) — must update those docs **in the same PR** as the change. If a doc impact is discovered after merge, the docs fix is the immediate next commit, not backlog. Stale docs are treated as bugs.
|
||||
**Docs-sync (hard rule, owner-set 2026-06-11):** any change that alters something these docs describe — README.md, `.github/CONTRIBUTING.md`, `.github/SECURITY.md`, `.github/SUPPORT.md`, LICENSE, or `docs/**` (install flows, Docker tag semantics, platform support, versioning/release behavior, review process, supported versions) — must update those docs **in the same PR** as the change. If a doc impact is discovered after merge, the docs fix is the immediate next commit, not backlog. Stale docs are treated as bugs.
|
||||
|
||||
**Release notes / changelog (hard rule, owner-set 2026-06-16):** every tagged release gets a **high-quality, user-facing `## [X.Y.Z] — DATE` section in `CHANGELOG.md`** before (or in the same hour as) the tag — never the "Auto-generated release for vX.Y.Z…" fallback. `release.yml` extracts that section verbatim as the GitHub Release body (the `Extract CHANGELOG section for tag` step), so a missing/empty section ships a bare release. Quality bar (owner-restyled 2026-07-17, replaces the old bold-lead paragraphs): **quiet and scannable** — a short `**Highlights**` bullet list first (plain words, one line each), then `### Changed` / `### Added` / `### Docs` / `### Fixed` / `### License` / `### CI` subsections where each entry is a **single one-liner** with the `(#NNN)` issue/PR ref and contributor credit (`— thanks @user!`) where applicable. Written for users, grouped by theme, no multi-line paragraphs, **not** raw commit dumps. This applies to **preview builds too**: preview release notes summarize what's new on `main` since the last stable, in the same style. Workflow: as features merge, keep `## [Unreleased]` current; at release time rename it to the version + date. If a release was already cut with the fallback body, the next action is to backfill `CHANGELOG.md` **and** `gh release edit <tag>` the live body — not backlog.
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<p>
|
||||
<a href="#quickstart">Quickstart</a> ·
|
||||
<a href="#features">Features</a> ·
|
||||
<a href="#why-ovs">Why OVS</a> ·
|
||||
<a href="#why-ovs">vs Others</a> ·
|
||||
<a href="#tts-engines">Engines</a> ·
|
||||
<a href="#openai-api">API</a> ·
|
||||
<a href="#sponsor--donate">Donate</a> ·
|
||||
@@ -68,7 +68,7 @@
|
||||
<td align="center">
|
||||
<img src="docs/screenshot-gallery.png" alt="Voice Gallery" width="100%"/>
|
||||
<br/><b>Voice Gallery</b><br/>
|
||||
<sub>Browse ready-made archetype voices with language filters — or build your own library.</sub>
|
||||
<sub>Browse ready-made archetype voices with language filters, or build your own — then pick any of them in Studio, Audiobook, Stories, and Dubbing.</sub>
|
||||
</td>
|
||||
<td align="center">
|
||||
<img src="docs/screenshot-dub.png" alt="Video Dubbing" width="100%"/>
|
||||
@@ -96,44 +96,28 @@
|
||||
|
||||
## ✨ Features
|
||||
|
||||
The eight headliners — and twelve more waiting under the fold.
|
||||
Three flagships, five more headliners, and a dozen under the fold.
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center" width="25%">
|
||||
<h3>🎙️ Voice Cloning</h3>
|
||||
<p>3-second clip → mirror any voice.<br/><b>646 languages</b>, zero-shot.</p>
|
||||
</td>
|
||||
<td align="center" width="25%">
|
||||
<h3>🎨 Voice Design</h3>
|
||||
<p>Gender, age, accent, pitch, speed,<br/>emotion, dialect — <b>dial it in</b>.</p>
|
||||
</td>
|
||||
<td align="center" width="25%">
|
||||
<h3>🎬 Video Dubbing</h3>
|
||||
<p>YouTube URL or file → transcribe →<br/>translate → re-voice → <b>MP4</b>.</p>
|
||||
</td>
|
||||
<td align="center" width="25%">
|
||||
<h3>📖 Audiobook Editor</h3>
|
||||
<p>Import text, EPUB, or PDF. Auto-chapter,<br/>loudnorm, metadata. Export <b>.m4b</b>.</p>
|
||||
</td>
|
||||
<td width="33%"><img src="docs/features/clone.png" alt="Voice Cloning" width="100%"/></td>
|
||||
<td width="33%"><img src="docs/features/design.png" alt="Voice Design" width="100%"/></td>
|
||||
<td width="33%"><img src="docs/features/dub.png" alt="Video Dubbing" width="100%"/></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="top">
|
||||
<h3>🎭 Stories</h3>
|
||||
<p>Multi-voice editor. Assign voices<br/>per-line, preview, <b>export full cast</b>.</p>
|
||||
</td>
|
||||
<td align="center" valign="top">
|
||||
<h3>⌨️ Dictation Widget</h3>
|
||||
<p><kbd>⌘</kbd>+<kbd>⇧</kbd>+<kbd>Space</kbd> from <b>any app</b>.<br/>Transcribes, auto-pastes, disappears.</p>
|
||||
</td>
|
||||
<td align="center" valign="top">
|
||||
<h3>🔐 100% Local</h3>
|
||||
<p>No keys, no cloud, no accounts.<br/><b>Your machine only</b>.</p>
|
||||
</td>
|
||||
<td align="center" valign="top">
|
||||
<h3>🤖 MCP Server</h3>
|
||||
<p>Use OmniVoice from <b>Claude</b>,<br/>Cursor, or any MCP client.</p>
|
||||
</td>
|
||||
<td align="center">🎙️ <b>Voice Cloning</b><br/><sub>3-sec clip → any voice · 646 languages · zero-shot</sub></td>
|
||||
<td align="center">🎨 <b>Voice Design</b><br/><sub>Describe it — gender, age, accent, emotion</sub></td>
|
||||
<td align="center">🎬 <b>Video Dubbing</b><br/><sub>Transcribe → translate → re-voice → MP4</sub></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center" width="20%">📖<br/><b>Audiobook</b><br/><sub>EPUB/PDF → .m4b, multi-voice cast</sub></td>
|
||||
<td align="center" width="20%">🎭<br/><b>Stories</b><br/><sub>Multi-voice script editor</sub></td>
|
||||
<td align="center" width="20%">⌨️<br/><b>Dictation Widget</b><br/><sub><kbd>⌘⇧Space</kbd> in any app</sub></td>
|
||||
<td align="center" width="20%">🔐<br/><b>100% Local</b><br/><sub>No keys, no cloud, no accounts</sub></td>
|
||||
<td align="center" width="20%">🤖<br/><b>MCP Server</b><br/><sub>Use from Claude, Cursor, …</sub></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
@@ -171,38 +155,18 @@ The eight headliners — and twelve more waiting under the fold.
|
||||
<sub><b>macOS:</b> first launch needs a one-time approval — right-click → <b>Open</b> (or System Settings → Privacy & Security → <b>"Open Anyway"</b> on macOS 15). No Terminal needed. <a href="docs/install/macos.md#gatekeeper-quarantine">Why?</a> · <b>Intel Macs:</b> local backend unsupported (<a href="https://github.com/debpalash/OmniVoice-Studio/issues/889">#889</a>) — <a href="docs/install/macos.md">details</a>.</sub>
|
||||
</div>
|
||||
|
||||
Pick your OS and follow the guide end-to-end:
|
||||
|
||||
- 🍎 **macOS** — [docs/install/macos.md](docs/install/macos.md)
|
||||
- 🪟 **Windows** — [docs/install/windows.md](docs/install/windows.md)
|
||||
- 🐧 **Linux** — [docs/install/linux.md](docs/install/linux.md)
|
||||
- 🐳 **Docker** — [docs/install/docker.md](docs/install/docker.md) · [Docker Hub: `palashdeb/omnivoice-studio`](https://hub.docker.com/r/palashdeb/omnivoice-studio)
|
||||
|
||||
Feels slow? [docs/performance.md](docs/performance.md) covers where generation time actually goes, the tuning knobs, and the three classic causes of "it got slow".
|
||||
|
||||
Want breaths, laughter, pauses, whispering, or emotion in the output? [docs/expressive-speech.md](docs/expressive-speech.md) covers exactly what each engine can do today — and what's spec'd but not shipped yet.
|
||||
|
||||
> Coming from **[CorentinJ/Real-Time-Voice-Cloning](https://github.com/CorentinJ/Real-Time-Voice-Cloning)** (now archived)? There's a dedicated migration guide: [docs/migration/real-time-voice-cloning.md](docs/migration/real-time-voice-cloning.md).
|
||||
**Install guide:** [🍎 macOS](docs/install/macos.md) · [🪟 Windows](docs/install/windows.md) · [🐧 Linux](docs/install/linux.md) · [🐳 Docker](docs/install/docker.md)
|
||||
|
||||
<details>
|
||||
<summary><b>🧰 Stuck? Self-checks, tokens & restricted networks</b></summary>
|
||||
<summary><b>🧰 Troubleshooting · slow generation · HF tokens · restricted networks</b></summary>
|
||||
|
||||
<br/>
|
||||
|
||||
Run the built-in self-check first — **Settings → About → "Run
|
||||
self-check"** in the app, or `uv run python backend/main.py --diagnose` from
|
||||
a checkout (`--deep` also test-loads the active engine). Then see
|
||||
[docs/install/troubleshooting.md](docs/install/troubleshooting.md) for the
|
||||
top 10 install errors. The in-app error UI deeplinks to those entries when
|
||||
something breaks at runtime, and **Settings → About → "Save diagnostic
|
||||
bundle"** packages scrubbed logs + the self-check report for bug reports.
|
||||
|
||||
For Hugging Face token setup, see
|
||||
[docs/setup/huggingface-token.md](docs/setup/huggingface-token.md). For
|
||||
diarization-specific gating, see
|
||||
[docs/features/diarization.md](docs/features/diarization.md). For download
|
||||
speed, the ⚡ fast-download (Xet) status, and restricted-network / mirror
|
||||
options, see [docs/downloading-models.md](docs/downloading-models.md).
|
||||
- **Something broke?** Run the self-check — **Settings → About → "Run self-check"** (or `uv run python backend/main.py --diagnose --deep`) — then the [top 10 install errors](docs/install/troubleshooting.md). **"Save diagnostic bundle"** packages scrubbed logs for a bug report.
|
||||
- **Feels slow?** [docs/performance.md](docs/performance.md) — where the time goes and how to tune it.
|
||||
- **Want breaths, laughter, emotion?** [docs/expressive-speech.md](docs/expressive-speech.md) — what each engine can do today.
|
||||
- **HF tokens · diarization · download speed / mirrors:** [tokens](docs/setup/huggingface-token.md) · [diarization](docs/features/diarization.md) · [downloads](docs/downloading-models.md).
|
||||
- **Coming from [Real-Time-Voice-Cloning](https://github.com/CorentinJ/Real-Time-Voice-Cloning)?** [Migration guide](docs/migration/real-time-voice-cloning.md).
|
||||
|
||||
</details>
|
||||
|
||||
@@ -210,7 +174,7 @@ options, see [docs/downloading-models.md](docs/downloading-models.md).
|
||||
|
||||
<a id="why-ovs"></a>
|
||||
|
||||
## 💡 Why OmniVoice?
|
||||
## ⚖️ vs Others
|
||||
|
||||
ElevenLabs charges **$5–$330/mo** and processes your audio on their servers. OmniVoice Studio runs **on your hardware, with no usage limits.**
|
||||
|
||||
@@ -227,7 +191,7 @@ ElevenLabs charges **$5–$330/mo** and processes your audio on their servers. O
|
||||
| **GPU Support** | N/A (cloud) | CUDA · Apple Silicon · ROCm (Linux) · CPU |
|
||||
| **Desktop App** | ❌ | ✅ macOS · Windows · Linux |
|
||||
| **TTS Engines** | 1 | **14** — [full matrix](#tts-engines) |
|
||||
| **ASR Engines** | 1 | **10** — [full lineup](#asr-engines) |
|
||||
| **ASR Engines** | 1 | **11** — [full lineup](#asr-engines) |
|
||||
| **MCP Server** | ❌ | ✅ Use from Claude, Cursor, any MCP client |
|
||||
| **Self-check** | ❌ | ✅ Diagnostics suite, error journal, scrubbed debug bundles |
|
||||
| **Customizable** | ❌ Closed | ✅ Fork it, extend it, ship it |
|
||||
@@ -254,14 +218,8 @@ Professional-grade voice AI, minus the subscription and the cloud.
|
||||
| **Python** | 3.10+ (managed by `uv`) | 3.11–3.12 |
|
||||
| **GPU** | Optional — CPU works | NVIDIA CUDA · Apple Silicon MPS · AMD ROCm (Linux only) |
|
||||
|
||||
> [!TIP]
|
||||
> On GPUs with **≤8 GB VRAM**, OmniVoice automatically offloads TTS to CPU during transcription — no config needed. A dedicated GPU is not required; the entire pipeline runs on CPU (just slower).
|
||||
|
||||
> [!NOTE]
|
||||
> **AMD GPUs:** ROCm acceleration is **Linux-only and opt-in** — pick **"AMD GPU (ROCm)"** on the first-run setup screen or set `OMNIVOICE_TORCH_VARIANT=rocm` ([docs/install/linux.md](docs/install/linux.md#amd-gpu-rocm)). In **Docker/Podman**, pull the dedicated ROCm image instead: `ghcr.io/debpalash/omnivoice-studio:rocm` ([docs/install/docker.md](docs/install/docker.md#pull-and-run-amd-gpu--rocm)). **On Windows, AMD GPUs (incl. Ryzen AI iGPUs) run CPU-only**: PyTorch has no Windows ROCm wheels, so Windows GPU acceleration is NVIDIA/CUDA-only ([docs/install/windows.md](docs/install/windows.md#gpu-support)).
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **macOS Intel (x86_64) is unsupported for the local backend:** the app UI installs, but the Python backend cannot run because PyTorch no longer ships Intel-Mac wheels ([#889](https://github.com/debpalash/OmniVoice-Studio/issues/889)). Intel-Mac users can still point the UI at a remote backend on another machine — see [docs/install/macos.md](docs/install/macos.md).
|
||||
> **A GPU is optional** — the whole pipeline runs on CPU (just slower), and on ≤8 GB VRAM, TTS auto-offloads to CPU. Caveats: **AMD ROCm** is Linux-only + opt-in ([Linux](docs/install/linux.md#amd-gpu-rocm)) — Windows AMD/Ryzen AI is CPU-only ([Windows](docs/install/windows.md#gpu-support)); **macOS Intel** can't run the local backend, so point it at a remote one ([#889](https://github.com/debpalash/OmniVoice-Studio/issues/889) · [macOS](docs/install/macos.md)).
|
||||
|
||||
<a id="tts-engines"></a>
|
||||
|
||||
@@ -334,47 +292,85 @@ Professional-grade voice AI, minus the subscription and the cloud.
|
||||
|
||||
## 🏗️ Architecture
|
||||
|
||||
A **Tauri v2** desktop shell (Rust) wraps a **React** UI and a bundled **Python/FastAPI** backend that runs as a local sidecar on `localhost:3900`. Nothing external — every layer is on your machine.
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Frontend (React) │
|
||||
│ DubTab · VoiceConsole · Stories · Audiobook · Gallery │
|
||||
│ Dictation · BatchQueue · Diagnostics · MCP Client │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ Backend (FastAPI) │
|
||||
│ 100+ API endpoints · SSE+WSS streaming · SQLite │
|
||||
├──────────┬──────────┬──────────┬──────────┬────────────────┤
|
||||
│ WhisperX │ Demucs │OmniVoice │ Pyannote │ Engine Routing │
|
||||
│ (+7 ASR │ Source │ (+10 │ Diariz- │ ↳ GPU preflight │
|
||||
│ engines) │ Sep. │ TTS) │ ation │ ↳ No silent CPU │
|
||||
└──────────┴──────────┴──────────┴──────────┴────────────────┘
|
||||
CUDA / MPS / ROCm / CPU (auto-detected + routed)
|
||||
┌────────────────────────────────────────────────────────────────────┐
|
||||
│ Tauri v2 shell — Rust │
|
||||
│ window state · global dictation hotkey · system tray · │
|
||||
│ signed auto-updater (stable/preview) · single-instance · │
|
||||
│ first-run bootstrap (installs uv + Python venv) · blank guard │
|
||||
├────────────────────────────────────────────────────────────────────┤
|
||||
│ Frontend — React + Vite │
|
||||
│ Studio · Dub · Stories · Audiobook · Gallery · Dictation · │
|
||||
│ Batch · Diagnostics · MCP client — Zustand store · WS bus │
|
||||
│ ▲ IPC / HTTP + WS │
|
||||
├──────────────────────────┼─────────────────────────────────────────┤
|
||||
│ Backend — FastAPI sidecar @ localhost:3900 │
|
||||
│ 100+ REST endpoints · SSE + WebSocket streaming · │
|
||||
│ SQLite + Alembic (omnivoice_data/) · OpenAI-compatible API │
|
||||
├───────────┬───────────┬───────────┬───────────┬────────────────────┤
|
||||
│ TTS ×14 │ ASR ×11 │ Demucs │ Pyannote │ AudioSeal │
|
||||
│ clone / │ WhisperX │ vocal │ speaker │ watermark │
|
||||
│ design │ +10 more │ isolation│ diariz. │ embed / detect │
|
||||
├───────────┴───────────┴───────────┴───────────┴────────────────────┤
|
||||
│ Engine routing — per-engine GPU preflight, no silent CPU fallback │
|
||||
│ Hardware: CUDA · MPS · ROCm (Linux) · CPU (auto-detected) │
|
||||
└────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- **Shell (Rust)** — native OS integration: the system-wide dictation hotkey, tray, signed auto-updater (stable + preview channels), single-instance lock, and the first-run bootstrap that installs `uv` and a Python 3.11 venv.
|
||||
- **Frontend (React)** — every workspace tab over a Zustand store, with a WebSocket event bus that live-refreshes the UI when backend data changes.
|
||||
- **Backend (FastAPI)** — the bundled Python sidecar: 100+ endpoints, SSE/WSS streaming, a SQLite DB migrated by Alembic, and the OpenAI-compatible API surface.
|
||||
- **Engines** — 14 TTS + 11 ASR, plus Demucs (isolation), Pyannote (diarization), and AudioSeal (watermark), all behind routing that GPU-preflights each engine and refuses to silently fall back to CPU.
|
||||
|
||||
<a id="openai-api"></a>
|
||||
|
||||
## 🔌 OpenAI-compatible API
|
||||
|
||||
Already have a script, agent, or tool that speaks OpenAI's audio API? Point it at `http://localhost:3900/v1` — no key needed, no code changes. The backend ships a drop-in surface for the audio endpoints, wired to whichever TTS/ASR engine you have active (and yes, `voice` accepts your cloned voice-profile IDs).
|
||||
<div align="center">
|
||||
|
||||
**Drop-in replacement for OpenAI / ElevenLabs audio.** One line — no key, no code changes:
|
||||
|
||||
```diff
|
||||
- base_url="https://api.openai.com/v1"
|
||||
+ base_url="http://localhost:3900/v1"
|
||||
```
|
||||
|
||||
</div>
|
||||
|
||||
Your existing scripts, agents, and OpenAI/ElevenLabs SDK calls now run **locally** on whatever engine you have active. What the cloud can't do: `voice` takes **your own cloned-voice profile IDs**, and `model` can pin a **specific engine** per request.
|
||||
|
||||
| Endpoint | What it does |
|
||||
|---|---|
|
||||
| `POST /v1/audio/speech` | TTS — text in; `mp3` / `wav` / `flac` / `opus` / `pcm` out. `tts-1` / `tts-1-hd` map to your active engine; OpenAI voice names (`alloy`, …) are accepted. |
|
||||
| `POST /v1/audio/transcriptions` | STT — audio file in; `json`, `text`, `verbose_json`, `srt`, or `vtt` out. `whisper-1` maps to your active ASR engine. |
|
||||
| `POST /v1/audio/speech` | TTS — text in; `mp3` / `opus` / `aac` / `flac` / `wav` / `pcm` out. `model`: `tts-1`/`tts-1-hd` (active engine) or a specific one (`voxcpm2`, `cosyvoice`, `kittentts`, …). `voice`: a cloned profile ID, `default`, or an OpenAI name (`alloy`, …). `speed` supported. |
|
||||
| `POST /v1/audio/transcriptions` | STT — audio file in; `json` / `text` / `verbose_json` / `srt` / `vtt` out (`verbose_json` adds word-level timings). `whisper-1` maps to your active ASR engine. |
|
||||
| `GET /v1/audio/voices` | OmniVoice extension — lists every voice profile and engine, so clients can discover your clones. |
|
||||
|
||||
**Speak with your own cloned voice** — list the IDs, then pass one as `voice`:
|
||||
|
||||
```sh
|
||||
# 1 — find a cloned voice's profile ID
|
||||
curl -s http://localhost:3900/v1/audio/voices | jq '.voices[] | select(.type=="profile") | {voice_id, name}'
|
||||
|
||||
# 2 — synthesize with it
|
||||
curl http://localhost:3900/v1/audio/speech \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"model": "tts-1", "voice": "alloy", "input": "Generated on my own hardware.", "response_format": "wav"}' \
|
||||
-d '{"model":"tts-1","voice":"<profile-id>","input":"Made on my own hardware.","response_format":"wav"}' \
|
||||
--output speech.wav
|
||||
```
|
||||
|
||||
```python
|
||||
from openai import OpenAI
|
||||
client = OpenAI(base_url="http://localhost:3900/v1", api_key="none") # any string works — nothing checks it
|
||||
client = OpenAI(base_url="http://localhost:3900/v1", api_key="none") # any string — nothing checks it
|
||||
|
||||
result = client.audio.transcriptions.create(model="whisper-1", file=open("clip.wav", "rb"))
|
||||
print(result.text)
|
||||
# TTS with your cloned voice (or "alloy" / "default"; model= can pin a specific engine)
|
||||
with client.audio.speech.with_streaming_response.create(
|
||||
model="tts-1", voice="<profile-id>", input="Made on my own hardware.") as r:
|
||||
r.stream_to_file("speech.wav")
|
||||
|
||||
# STT
|
||||
print(client.audio.transcriptions.create(model="whisper-1", file=open("clip.wav", "rb")).text)
|
||||
```
|
||||
|
||||
Want the whole surface (100+ endpoints)? The full REST API reference is embedded in the app — **Settings → OpenAPI Reference** (Scalar-powered), or the `{}` button in the footer.
|
||||
@@ -385,17 +381,20 @@ Calling the backend from **another machine** (LAN, Tailscale, behind a proxy)? I
|
||||
|
||||
[](https://colab.research.google.com/github/debpalash/OmniVoice-Studio/blob/main/notebooks/OmniVoice_Studio_Colab.ipynb)
|
||||
|
||||
No local GPU? The official notebook ([notebooks/OmniVoice_Studio_Colab.ipynb](notebooks/OmniVoice_Studio_Colab.ipynb)) boots the full app — web UI included — on a free Colab T4: it builds the frontend in-notebook, installs the backend with uv (reusing Colab's preinstalled CUDA PyTorch), and opens the UI through Colab's built-in port proxy. No third-party tunnels, no API keys. It then walks the whole feature surface as a guided API tour with inline playback: multilingual TTS, voice cloning and design, saved voice profiles, transcription, AI-watermark detection, the OpenAI-compatible API, a multi-voice story, a chaptered m4b audiobook, and a miniature video dub with vocal-isolation stems.
|
||||
No local GPU? The [official notebook](notebooks/OmniVoice_Studio_Colab.ipynb) boots the full app — web UI included — on a free Colab T4, then walks the whole feature surface (TTS, cloning, design, transcription, dubbing, audiobook, watermarking, the OpenAI-compatible API) as a guided tour with inline playback. No tunnels, no API keys.
|
||||
|
||||
### 🤝 Agent Skills
|
||||
|
||||
Teach your AI agent (Claude Code, Cursor, Codex, …) to use OmniVoice with one command:
|
||||
Teach your coding agent to speak and listen through your local OmniVoice — one command, works with **Claude Code, Codex, Cursor, Grok, Kimi, opencode**, and any [skills.sh](https://skills.sh)-compatible agent:
|
||||
|
||||
```sh
|
||||
npx skills add debpalash/omnivoice-studio
|
||||
```
|
||||
|
||||
Ships two [skills](https://skills.sh): **`omnivoice`** — speak and transcribe through your local install (including your cloned voices) from any agent, free and offline; and **`oss-maintainer`** — the maintainer methodology this project is run with, for anyone running their own OSS project with an agent.
|
||||
Ships two [skills](https://skills.sh):
|
||||
|
||||
- **`omnivoice`** — generate speech (including your cloned voices) and transcribe audio from any agent, free and fully offline via your local install.
|
||||
- **`oss-maintainer`** — the maintainer methodology this project is run with, for anyone running their own OSS project with an agent.
|
||||
|
||||
---
|
||||
|
||||
@@ -415,13 +414,13 @@ Ships two [skills](https://skills.sh): **`omnivoice`** — speak and transcribe
|
||||
|
||||
| Category | Features |
|
||||
|----------|----------|
|
||||
| **Longform** | Audiobook editor (text/EPUB/PDF → chaptered .m4b), Stories multi-voice editor, two-pass loudnorm mastering, crash-resume for interrupted renders, pronunciation control + SSML-lite prosody |
|
||||
| **Longform** | Audiobook editor (text/EPUB/PDF → chaptered .m4b) with multi-voice cast, expressive controls, live per-chapter progress + Stop, and a one-click sample; Stories multi-voice editor, two-pass loudnorm mastering, crash-resume for interrupted renders, pronunciation control + SSML-lite prosody |
|
||||
| **Dubbing** | Full pipeline (transcribe→translate→synthesize→mux), scene-aware splitting, lip-sync scoring, streaming TTS, per-speaker voice assignment, Smart Fit timing + second-pass QC, paste-in translations from any external tool, dedicated Dub home |
|
||||
| **Voice** | Zero-shot cloning, voice design, A/B comparison, voice preview widget, gallery with favorites/tags, portable persona bundles (`.ovsvoice`), voice console workspace |
|
||||
| **Voice** | Zero-shot cloning, voice design, A/B comparison, voice preview widget, gallery with favorites/tags (its voices selectable in every picker — Studio, Audiobook, Stories, Dubbing), portable persona bundles (`.ovsvoice`), voice console workspace |
|
||||
| **Audio** | Demucs vocal isolation, per-segment gain, selective track export, stem/SRT/VTT/MP3 export, unlimited-length TTS via sentence-chunked generation |
|
||||
| **Multi-Lang** | Multi-language batch picker, batch dubbing queue with sequential GPU execution |
|
||||
| **Diarization** | Pyannote ML diarization, auto speaker clone extraction, per-speaker voice assignment |
|
||||
| **ASR** | 10 engines (WhisperX, Faster-Whisper, isolated Faster-Whisper, MLX Whisper, PyTorch Whisper, Parakeet TDT, Parakeet TDT v3 MLX, Moonshine, FunASR/SenseVoice, sherpa-onnx live dictation), crash-isolated subprocess backend |
|
||||
| **ASR** | 11 engines (WhisperX, Faster-Whisper, isolated Faster-Whisper, MLX Whisper, PyTorch Whisper, Parakeet TDT, Parakeet TDT v3 MLX, Moonshine, FunASR/SenseVoice, sherpa-onnx live dictation, OpenAI-compatible remote), crash-isolated subprocess backend |
|
||||
| **TTS** | 14 engines (OmniVoice, CosyVoice 3, GPT-SoVITS, VoxCPM2, MOSS-TTS-Nano, KittenTTS, MLX-Audio, Sherpa-ONNX, + lazy: IndexTTS 2, OmniVoice GGUF, Supertonic 3, MOSS-TTS-v1.5, dots.tts, Confucius4-TTS), engine routing with GPU preflight |
|
||||
| **Infra** | Docker deployment, CUDA/MPS/ROCm auto-detect, cuDNN 8 compat, VRAM-aware model offloading, engine routing (no silent CPU fallback), diagnostics suite & error journal, restricted-network mirror support |
|
||||
| **AI Provenance** | AudioSeal invisible watermarking (SynthID-like), video logo overlay, watermark detection API |
|
||||
@@ -443,13 +442,11 @@ Ships two [skills](https://skills.sh): **`omnivoice`** — speak and transcribe
|
||||
|
||||
## 💜 Sponsor / Donate
|
||||
|
||||
OmniVoice Studio is built by one developer using Claude Code and AI agents — and the agent bills are real (thousands of dollars over the last three months). If OmniVoice has created value for you, covering a slice of those bills keeps development full-time.
|
||||
One developer, real AI-agent bills. If OmniVoice is useful to you, chipping in keeps development full-time — every dollar goes straight to the bills.
|
||||
|
||||
<div align="center">
|
||||
|
||||
**This month's agent bill fund**
|
||||
|
||||
<img src="https://img.shields.io/badge/raised_%2410_of_%24200-5%25-EAB308?style=for-the-badge" alt="$10 / $200 raised" />
|
||||
<img src="https://img.shields.io/badge/raised_%2410_of_%24200-5%25-EAB308?style=for-the-badge" alt="This month's agent-bill fund: $10 / $200" />
|
||||
|
||||
<br/><br/>
|
||||
|
||||
@@ -457,15 +454,9 @@ OmniVoice Studio is built by one developer using Claude Code and AI agents — a
|
||||
|
||||
<a href="https://paypal.me/palashCoder"><img src="https://img.shields.io/badge/PayPal-Donate-00457C?style=for-the-badge&logo=paypal&logoColor=white" alt="PayPal" /></a>
|
||||
|
||||
<br/>
|
||||
<sub>Every dollar goes directly to agent bills — keeping OmniVoice development continuous.</sub>
|
||||
|
||||
<br/><br/>
|
||||
|
||||
<sub><b>More apps from the creator of OmniVoice Studio</b> — same local-first philosophy:
|
||||
<a href="https://github.com/debpalash/Opal"><b>Opal</b> 💠</a> (play everything — the media player for the AI era) ·
|
||||
<a href="https://github.com/debpalash/memxt"><b>memxt</b> 🧠</a> (local memory for Claude Code & coding agents).
|
||||
A ⭐ on those helps too → <a href="#more-from-the-maker">details below</a>.</sub>
|
||||
<sub>Also from the maker: <a href="https://github.com/debpalash/Opal"><b>Opal</b> 💠</a> · <a href="https://github.com/debpalash/memxt"><b>memxt</b> 🧠</a> — a ⭐ helps too.</sub>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -521,7 +512,7 @@ OmniVoice is **free** and **AGPL-3.0** — no paid tier, no SaaS revenue. Sponso
|
||||
|
||||
Yes please — bug fixes, new TTS engine adapters, UI improvements, docs, translations. All of it.
|
||||
|
||||
- 📖 Read the **[Contributing Guide](CONTRIBUTING.md)** for setup, code style, and PR workflow
|
||||
- 📖 Read the **[Contributing Guide](.github/CONTRIBUTING.md)** for setup, code style, and PR workflow
|
||||
- 🐛 Browse [good first issues](https://github.com/debpalash/OmniVoice-Studio/labels/good%20first%20issue)
|
||||
- 💬 Join our [Discord](https://discord.gg/bzQavDfVV9) to discuss ideas or ask for help
|
||||
|
||||
|
||||
+1
-1
@@ -509,7 +509,7 @@ OmniVoice **免费**且采用 **AGPL-3.0** 许可——没有付费版,没有
|
||||
|
||||
非常欢迎——Bug 修复、新的 TTS 引擎适配器、UI 改进、文档、翻译。统统欢迎。
|
||||
|
||||
- 📖 阅读 **[贡献指南](CONTRIBUTING.md)** 了解环境搭建、代码风格和 PR 工作流
|
||||
- 📖 阅读 **[贡献指南](.github/CONTRIBUTING.md)** 了解环境搭建、代码风格和 PR 工作流
|
||||
- 🐛 浏览 [good first issues](https://github.com/debpalash/OmniVoice-Studio/labels/good%20first%20issue)
|
||||
- 💬 加入我们的 [Discord](https://discord.gg/bzQavDfVV9) 讨论想法或寻求帮助
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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")},
|
||||
)
|
||||
|
||||
@@ -363,6 +363,39 @@ def _oom_friendly_reraise(e):
|
||||
f"([WinError 193]). Reinstall or repair that component — the Flush "
|
||||
f"button won't help here. Underlying error: {e}"
|
||||
) from e
|
||||
# #1227: Windows Smart App Control / an App Control (WDAC) policy blocked
|
||||
# a file the engine needs — "[WinError 4551] An Application Control policy
|
||||
# has blocked this file". WinError 1260 is the same class from the older
|
||||
# Software Restriction / AppLocker policies. Not OOM, and Flush can't help:
|
||||
# the OS is refusing to load the binary at all.
|
||||
if ("[winerror 4551]" in _low or "[winerror 1260]" in _low
|
||||
or "application control policy" in _low):
|
||||
raise RuntimeError(
|
||||
f"Windows blocked a file OmniVoice needs from running — an "
|
||||
f"Application Control policy (Smart App Control, WDAC, or "
|
||||
f"AppLocker) refused to load it. On a personal PC: Windows "
|
||||
f"Security → App & browser control → Smart App Control → Off "
|
||||
f"(note Windows only lets you turn it off once — re-enabling "
|
||||
f"needs a Windows reset), then restart OmniVoice. On a managed/"
|
||||
f"work PC ask IT to allow the OmniVoice install folder. The Flush "
|
||||
f"button won't help. Underlying error: {e}"
|
||||
) from e
|
||||
# #1221: libsndfile/soundfile could not read or write an audio file. Its
|
||||
# errors are bare ("LibsndfileError: System error.") so they used to fall
|
||||
# through to the unrecognized catch-all. audio_io._describe_write_failure
|
||||
# already names the target for the WRITE path; this covers every other
|
||||
# libsndfile surface (reading a reference clip, a decode) with the causes
|
||||
# that actually produce an OS-level audio I/O failure.
|
||||
if "libsndfile" in _low or "writing the audio file failed" in _low:
|
||||
raise RuntimeError(
|
||||
f"An audio file couldn't be read or written (libsndfile failed at "
|
||||
f"the OS level). This is a file/disk problem, not a memory one: "
|
||||
f"check the drive isn't full, the output and temp folders exist "
|
||||
f"and are writable, and that antivirus or OneDrive isn't locking "
|
||||
f"them (add an OmniVoice exclusion if you use one). If it happens "
|
||||
f"only with one reference clip, re-import that clip. Underlying "
|
||||
f"error: {e}"
|
||||
) from e
|
||||
# #715: a "[Errno 32] Broken pipe" (BrokenPipeError) surfacing from
|
||||
# generation is NOT out of memory — it means the backend's stdout/stderr
|
||||
# pipe to the desktop shell that launched it closed mid-render (an orphaned
|
||||
@@ -601,11 +634,66 @@ def _run_backend_inference(
|
||||
|
||||
except ValueError as e:
|
||||
# Don't wrap validation errors in OOM message
|
||||
raise e
|
||||
raise _language_rejection_or(e, backend, language)
|
||||
except Exception as e:
|
||||
rewritten = _language_rejection_or(e, backend, language)
|
||||
if rewritten is not e:
|
||||
raise rewritten from e
|
||||
_oom_friendly_reraise(e)
|
||||
|
||||
|
||||
# #1257: the language picker offers all 646 languages regardless of engine,
|
||||
# because MLXAudioBackend.supported_languages() returns ["multi"] on the stated
|
||||
# assumption that "each engine silently ignores languages it doesn't know".
|
||||
# That assumption is false — the underlying library raises, and the reporter got
|
||||
# a bare 400 that recited 23 language codes without saying which engine was
|
||||
# refusing, or that switching engines was the fix.
|
||||
# Each signature must be about the LANGUAGE itself. "Unsupported language" as a
|
||||
# bare prefix also matches "Unsupported language model configuration" — a model
|
||||
# problem handed engine-switch advice it has no use for (#1257 review) — so the
|
||||
# looser wordings require the rejected thing to end there or be a code/name.
|
||||
_LANGUAGE_REJECTION_SIGNATURES = (
|
||||
"invalid language code",
|
||||
"language not supported",
|
||||
"language is not supported",
|
||||
"unsupported language code",
|
||||
)
|
||||
|
||||
#: `unsupported language: xx` / `unsupported language 'xx'` — but not
|
||||
#: `unsupported language model ...`.
|
||||
_LANGUAGE_REJECTION_RE = re.compile(
|
||||
r"unsupported language\s*[:=]|unsupported language\s*['\"]|"
|
||||
r"unsupported language\s*$",
|
||||
re.IGNORECASE | re.MULTILINE,
|
||||
)
|
||||
|
||||
|
||||
def _language_rejection_or(e: BaseException, backend, language):
|
||||
"""``e`` rewritten with engine context when it's a language rejection.
|
||||
|
||||
Returns ``e`` unchanged otherwise, so this is safe to wrap any failure in.
|
||||
Matched on the message, not the type: the engines multiplex third-party
|
||||
libraries that each raise their own class.
|
||||
"""
|
||||
text = str(e)
|
||||
low = text.lower()
|
||||
if not any(sig in low for sig in _LANGUAGE_REJECTION_SIGNATURES) and not (
|
||||
_LANGUAGE_REJECTION_RE.search(text)
|
||||
):
|
||||
return e
|
||||
engine = getattr(backend, "display_name", None) or getattr(
|
||||
type(backend), "id", type(backend).__name__
|
||||
)
|
||||
requested = f" '{language}'" if language else ""
|
||||
return ValueError(
|
||||
f"The {engine} engine can't speak{requested}. OmniVoice offers every "
|
||||
f"language its default engine supports, but each engine covers a "
|
||||
f"different set — pick one this engine supports, or switch engine in "
|
||||
f"Settings → Engines (the OmniVoice engine has the widest coverage) "
|
||||
f"and generate again. Engine's own message: {e}"
|
||||
)
|
||||
|
||||
|
||||
def _persist_profile_ref_text(profile_id: str, ref_text: str) -> None:
|
||||
"""Cache an auto-transcribed reference transcript onto its profile row.
|
||||
|
||||
@@ -870,7 +958,15 @@ async def generate_speech(
|
||||
# /engines/select gate, so this is the only place it's enforced for synth).
|
||||
from core.device_caps import detect_host_caps
|
||||
from services.engine_routing import resolve_routing, routing_notice
|
||||
_routing = resolve_routing(getattr(backend_cls, "gpu_compat", ("cpu",)), detect_host_caps())
|
||||
# The engine's declared VRAM floor (#1226) — used by the routing gate and,
|
||||
# below, to let a generate TIMEOUT name the same shortfall. Resolved once:
|
||||
# every other job on this GPU pool (reference transcribe, assemble) leaves
|
||||
# it at 0, so only TTS generates can get the under-provisioned wording.
|
||||
_engine_min_vram_gb = getattr(backend_cls, "min_vram_gb", 0.0)
|
||||
_routing = resolve_routing(
|
||||
getattr(backend_cls, "gpu_compat", ("cpu",)), detect_host_caps(),
|
||||
_engine_min_vram_gb,
|
||||
)
|
||||
if _routing["routing_status"] == "unavailable":
|
||||
# The engine needs an accelerator this host lacks and has no CPU path.
|
||||
raise HTTPException(status_code=400, detail=_routing["routing_reason"])
|
||||
@@ -1195,6 +1291,7 @@ async def generate_speech(
|
||||
max_chunk_chars, crossfade_ms,
|
||||
),
|
||||
what="TTS generate",
|
||||
min_vram_gb=_engine_min_vram_gb,
|
||||
timeout=_generate_timeout_s(text),
|
||||
)
|
||||
sample_rate = _backend.sample_rate
|
||||
@@ -1210,6 +1307,7 @@ async def generate_speech(
|
||||
max_chunk_chars, crossfade_ms,
|
||||
),
|
||||
what="TTS generate",
|
||||
min_vram_gb=_engine_min_vram_gb,
|
||||
timeout=_generate_timeout_s(text),
|
||||
)
|
||||
sample_rate = _model.sampling_rate
|
||||
@@ -1244,6 +1342,7 @@ async def generate_speech(
|
||||
raw, preview, sample_rate = await run_on_gpu_pool_guarded(
|
||||
functools.partial(_render_stream_chunk, i, chunk_text),
|
||||
what="TTS generate",
|
||||
min_vram_gb=_engine_min_vram_gb,
|
||||
# Budget scaled to THIS chunk (#1190) — the flat
|
||||
# 300s here is what made long streamed renders fail
|
||||
# even after the v0.3.22 scaled budget shipped.
|
||||
@@ -1344,6 +1443,7 @@ async def generate_speech(
|
||||
max_chunk_chars, crossfade_ms,
|
||||
),
|
||||
what="TTS generate",
|
||||
min_vram_gb=_engine_min_vram_gb,
|
||||
timeout=_generate_timeout_s(text),
|
||||
)
|
||||
# Read after generation: engines with lazy model loading report
|
||||
@@ -1360,6 +1460,7 @@ async def generate_speech(
|
||||
max_chunk_chars, crossfade_ms,
|
||||
),
|
||||
what="TTS generate",
|
||||
min_vram_gb=_engine_min_vram_gb,
|
||||
timeout=_generate_timeout_s(text),
|
||||
)
|
||||
sample_rate = _model.sampling_rate
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
)
|
||||
|
||||
@@ -32,6 +32,7 @@ from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from services.model_manager import _gpu_pool, run_on_gpu_pool_guarded
|
||||
from core.http_headers import content_disposition
|
||||
|
||||
logger = logging.getLogger("omnivoice.openai_compat")
|
||||
|
||||
@@ -313,7 +314,10 @@ async def create_speech(req: SpeechRequest):
|
||||
# Routing gate (#21 — no silent CPU fallback), identical to REST /generate.
|
||||
from core.device_caps import detect_host_caps
|
||||
from services.engine_routing import resolve_routing, routing_notice
|
||||
_routing = resolve_routing(getattr(backend, "gpu_compat", ("cpu",)), detect_host_caps())
|
||||
_routing = resolve_routing(
|
||||
getattr(backend, "gpu_compat", ("cpu",)), detect_host_caps(),
|
||||
getattr(backend, "min_vram_gb", 0.0),
|
||||
)
|
||||
if _routing["routing_status"] == "unavailable":
|
||||
raise HTTPException(status_code=400, detail=_routing["routing_reason"])
|
||||
_routing_notice = routing_notice(_routing) # (status, reason) or None
|
||||
@@ -463,7 +467,7 @@ async def create_speech(req: SpeechRequest):
|
||||
|
||||
_headers = {
|
||||
"Content-Length": str(len(audio_bytes)),
|
||||
"Content-Disposition": f'inline; filename="speech.{ext}"',
|
||||
"Content-Disposition": content_disposition(f"speech.{ext}", disposition="inline"),
|
||||
}
|
||||
if _routing_notice:
|
||||
from services.engine_routing import header_safe_reason
|
||||
|
||||
@@ -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)),
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -90,6 +90,20 @@ async def ws_tts(websocket: WebSocket):
|
||||
get_backend_class,
|
||||
)
|
||||
engine_id = data.get("engine")
|
||||
# #1224: leave a breadcrumb when memory is already tight before
|
||||
# a heavy load. /generate has done this since the 16 GB-Mac
|
||||
# reports, but the streaming path — which the desktop UI tries
|
||||
# FIRST — never did, so the load most likely to tip the machine
|
||||
# into an OS OOM kill was the one load with no trail. The
|
||||
# captured stderr tail is what a SIGKILL report has to go on.
|
||||
# Advisory only: the OS can reclaim cache, and refusing here
|
||||
# would brick loads that would actually have coped.
|
||||
try:
|
||||
from services.memory_budget import log_if_low
|
||||
|
||||
log_if_low(f"TTS stream load ({engine_id or 'active engine'})")
|
||||
except Exception:
|
||||
pass
|
||||
if engine_id:
|
||||
cls = get_backend_class(engine_id)
|
||||
backend = cls()
|
||||
@@ -106,7 +120,8 @@ async def ws_tts(websocket: WebSocket):
|
||||
from services.engine_routing import resolve_routing, routing_notice
|
||||
from core.scrub import scrub_text
|
||||
_routing = resolve_routing(
|
||||
getattr(backend, "gpu_compat", ("cpu",)), detect_host_caps())
|
||||
getattr(backend, "gpu_compat", ("cpu",)), detect_host_caps(),
|
||||
getattr(backend, "min_vram_gb", 0.0))
|
||||
if _routing["routing_status"] == "unavailable":
|
||||
await websocket.send_json({
|
||||
"type": "error",
|
||||
|
||||
+156
-17
@@ -28,6 +28,7 @@ out of this backend-only slice.)
|
||||
from __future__ import annotations
|
||||
|
||||
import functools
|
||||
import os
|
||||
import platform as _platform
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
@@ -52,6 +53,146 @@ DIRECTML_MARKER = "DirectML device present"
|
||||
# ``wizard._detect_gpu`` (preflight), which already runs it. The probe only
|
||||
# emits the torch-visible SM-arch caveat (cheap, metadata-only).
|
||||
|
||||
# ── ROCm GFX version overrides ───────────────────────────────────────────
|
||||
# AMD GPUs on ROCm present through ``torch.cuda`` but some consumer parts have
|
||||
# GFX IDs the installed ROCm build wasn't compiled for. Setting
|
||||
# ``HSA_OVERRIDE_GFX_VERSION`` runs them on the closest supported architecture.
|
||||
# Applied (with side effects) by ``model_manager._configure_rocm_if_needed``;
|
||||
# read here so ``arch_unsupported()`` doesn't flag a GPU we know how to remap.
|
||||
#
|
||||
# Values are the TARGET gfx name, not the HSA version string, so callers can
|
||||
# check whether the installed wheel actually contains that target before
|
||||
# treating the remap as a solution (``hsa_override_for`` derives the env-var
|
||||
# form). Remapping onto an architecture the build doesn't ship is not a fix —
|
||||
# it just moves the failure from "no kernel for gfx1151" to "no kernel for
|
||||
# gfx1100".
|
||||
ROCM_GFX_OVERRIDES = {
|
||||
# RDNA 3.5 (Strix Point / Strix Halo APUs) — override to gfx1100
|
||||
"gfx1150": "gfx1100", "gfx1151": "gfx1100",
|
||||
# RDNA 3 (RX 7000 series) — override to gfx1100
|
||||
"gfx1101": "gfx1100", "gfx1102": "gfx1100", "gfx1103": "gfx1100",
|
||||
# RDNA 2 (RX 6000 series) — override to gfx1030
|
||||
"gfx1031": "gfx1030", "gfx1032": "gfx1030", "gfx1034": "gfx1030",
|
||||
# Vega (RX Vega / Radeon VII) — override to gfx900 / gfx906
|
||||
"gfx902": "gfx900", "gfx906": "gfx906",
|
||||
}
|
||||
|
||||
|
||||
def hsa_override_for(target_gfx: str) -> str:
|
||||
"""``"gfx1100"`` → ``"11.0.0"``, the form HSA_OVERRIDE_GFX_VERSION wants.
|
||||
|
||||
The digits are major / minor / step, with the last two characters always
|
||||
one digit each: gfx1100 → 11.0.0, gfx1030 → 10.3.0, gfx906 → 9.0.6.
|
||||
"""
|
||||
digits = _normalize_arch(target_gfx).removeprefix("gfx")
|
||||
if len(digits) < 3 or not digits.isdigit():
|
||||
raise ValueError(f"not a gfx architecture name: {target_gfx!r}")
|
||||
return f"{digits[:-2]}.{digits[-2]}.{digits[-1]}"
|
||||
|
||||
|
||||
def _normalize_arch(tag: str) -> str:
|
||||
"""``"gfx90a:xnack+"`` → ``"gfx90a"``. Feature flags dropped, lowercased."""
|
||||
return str(tag).split(":")[0].strip().lower()
|
||||
|
||||
|
||||
def build_arch_list(torch) -> list[str]:
|
||||
"""This torch build's compiled architecture list, or ``[]`` if unknown.
|
||||
|
||||
Prefers the public ``get_arch_list`` and falls back to the private
|
||||
``_get_arch_list`` (older wheels only expose the latter).
|
||||
"""
|
||||
for name in ("get_arch_list", "_get_arch_list"):
|
||||
fn = getattr(torch.cuda, name, None)
|
||||
if callable(fn):
|
||||
try:
|
||||
return [str(a) for a in (fn() or [])]
|
||||
except Exception:
|
||||
return []
|
||||
return []
|
||||
|
||||
|
||||
def gfx_for_hsa_override(value: str) -> str | None:
|
||||
"""``"11.0.0"`` → ``"gfx1100"``. The inverse of :func:`hsa_override_for`.
|
||||
|
||||
``None`` for anything that isn't a three-part numeric version — the user
|
||||
set something we don't understand, and a guess is worse than leaving it be.
|
||||
"""
|
||||
parts = str(value).strip().split(".")
|
||||
if len(parts) != 3 or not all(p.isdigit() for p in parts):
|
||||
return None
|
||||
major, minor, step = parts
|
||||
if len(minor) != 1 or len(step) != 1:
|
||||
return None
|
||||
return f"gfx{int(major)}{minor}{step}"
|
||||
|
||||
|
||||
def arch_unsupported(torch) -> tuple[str, tuple[str, ...]] | None:
|
||||
"""``(device_arch, build_archs)`` when device 0's architecture is absent
|
||||
from this torch build's compiled arch list — i.e. kernels cannot launch
|
||||
("no kernel image is available for execution"). ``None`` means supported,
|
||||
unknown, or not applicable.
|
||||
|
||||
**CUDA and ROCm name architectures in different namespaces.** A CUDA build
|
||||
reports ``sm_89`` / ``compute_89``; a ROCm build reports ``gfx1100``. The
|
||||
check must therefore branch on the build — comparing a CUDA ``sm_`` tag
|
||||
against a ROCm ``gfx`` list can never match, which made *every* ROCm host
|
||||
look unsupported and silently force-routed it to CPU (#1228). Callers must
|
||||
get the verdict from here rather than re-deriving a tag.
|
||||
|
||||
Never raises: any missing/odd metadata degrades to ``None`` (compatible),
|
||||
matching the pre-existing fail-open contract.
|
||||
"""
|
||||
try:
|
||||
if not torch.cuda.is_available():
|
||||
return None
|
||||
arch_list = build_arch_list(torch)
|
||||
if not arch_list:
|
||||
return None
|
||||
|
||||
if getattr(getattr(torch, "version", None), "hip", None) is not None:
|
||||
# ── ROCm / HIP: arch_list holds gfx names ─────────────────────
|
||||
override = os.environ.get("HSA_OVERRIDE_GFX_VERSION")
|
||||
if override:
|
||||
# An override remaps the device onto some other gfx target, so
|
||||
# the native gfx name no longer describes what will run — but
|
||||
# the remap is only valid if this build SHIPS that target. A
|
||||
# stale or copy-pasted value (the #1228 reporter had set
|
||||
# 11.0.0 on a card that no longer needs it) must not buy a free
|
||||
# pass into kernels that don't exist. Unparseable values are
|
||||
# left alone: the user asked for something we don't understand,
|
||||
# and guessing would be worse than trusting them.
|
||||
target = gfx_for_hsa_override(override)
|
||||
if target is None or _normalize_arch(target) in {
|
||||
_normalize_arch(a) for a in arch_list
|
||||
}:
|
||||
return None
|
||||
return f"{target} (HSA_OVERRIDE_GFX_VERSION={override})", tuple(arch_list)
|
||||
props = torch.cuda.get_device_properties(0)
|
||||
gfx = _normalize_arch(getattr(props, "gcnArchName", "") or "")
|
||||
if not gfx:
|
||||
return None
|
||||
build = {_normalize_arch(a) for a in arch_list}
|
||||
if gfx in build:
|
||||
return None
|
||||
# _configure_rocm_if_needed() can remap this GPU onto a supported
|
||||
# target before any kernel launches — but only counts as a fix if
|
||||
# the build actually SHIPS that target. Remapping gfx1151 onto
|
||||
# gfx1100 in a wheel that has neither just relocates the failure.
|
||||
target = ROCM_GFX_OVERRIDES.get(gfx)
|
||||
if target and _normalize_arch(target) in build:
|
||||
return None
|
||||
return gfx, tuple(arch_list)
|
||||
|
||||
# ── CUDA: arch_list holds sm_/compute_ tags ──────────────────────
|
||||
major, minor = torch.cuda.get_device_capability(0)
|
||||
sm_tag = f"sm_{major}{minor}"
|
||||
if sm_tag in arch_list or f"compute_{major}{minor}" in arch_list:
|
||||
return None
|
||||
return sm_tag, tuple(arch_list)
|
||||
except Exception:
|
||||
# Arch metadata unavailable on this torch build — treat as compatible.
|
||||
return None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class HostCaps:
|
||||
@@ -135,23 +276,16 @@ def _probe() -> HostCaps:
|
||||
vram_gb = float(total) / (1024 ** 3)
|
||||
except Exception:
|
||||
notes.append("VRAM query failed")
|
||||
# SM-arch mismatch (mirrors model_manager.check_device_compatibility).
|
||||
try:
|
||||
major, minor = torch.cuda.get_device_capability(0)
|
||||
arch_list = getattr(torch.cuda, "_get_arch_list", lambda: [])()
|
||||
if arch_list:
|
||||
sm_tag = f"sm_{major}{minor}"
|
||||
compute_tag = f"compute_{major}{minor}"
|
||||
if sm_tag not in arch_list and compute_tag not in arch_list:
|
||||
notes.append(
|
||||
f"{device_name or 'GPU'} ({sm_tag}) not in this torch "
|
||||
f"build's archs ({', '.join(arch_list)}) — "
|
||||
f"{KERNEL_RISK_MARKER}"
|
||||
)
|
||||
except Exception:
|
||||
# Arch metadata unavailable on this torch build — skip the check
|
||||
# (treated as compatible, exactly as check_device_compatibility).
|
||||
pass
|
||||
# Arch mismatch — sm_ tags on CUDA, gfx names on ROCm. Shared with
|
||||
# model_manager.check_device_compatibility() so probe and loader
|
||||
# can never disagree (they used to, on every ROCm host — #1228).
|
||||
mismatch = arch_unsupported(torch)
|
||||
if mismatch is not None:
|
||||
device_arch, archs = mismatch
|
||||
notes.append(
|
||||
f"{device_name or 'GPU'} ({device_arch}) not in this torch "
|
||||
f"build's archs ({', '.join(archs)}) — {KERNEL_RISK_MARKER}"
|
||||
)
|
||||
|
||||
# ── Intel XPU via IPEX ───────────────────────────────────────────────
|
||||
try:
|
||||
@@ -274,6 +408,11 @@ __all__ = [
|
||||
"detect_host_caps",
|
||||
"refresh",
|
||||
"mlx_supported",
|
||||
"arch_unsupported",
|
||||
"gfx_for_hsa_override",
|
||||
"hsa_override_for",
|
||||
"build_arch_list",
|
||||
"ROCM_GFX_OVERRIDES",
|
||||
"KERNEL_RISK_MARKER",
|
||||
"DIRECTML_MARKER",
|
||||
]
|
||||
|
||||
+234
-11
@@ -18,6 +18,7 @@ from __future__ import annotations
|
||||
import os
|
||||
import platform
|
||||
import re
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
@@ -39,11 +40,17 @@ _HINTS: dict[str, str] = {
|
||||
"HF_AUTH_FAILED": "Set a valid HF_TOKEN in Settings → Hugging Face and retry.",
|
||||
"PYANNOTE_LICENSE_REQUIRED": "Accept the pyannote model licenses on Hugging Face, then retry.",
|
||||
"COMPUTE_TYPE_UNSUPPORTED": "Your GPU doesn't support float16 — OmniVoice retried on int8. If transcription still fails, set OMNIVOICE/ASR_COMPUTE_TYPE=int8 or use CPU.",
|
||||
"TRANSFORMERS_IMPORT": "Your transformers install is incomplete. Reinstall it (`uv pip install --reinstall transformers`) or switch ASR to faster-whisper (Settings → Models).",
|
||||
"TRANSFORMERS_IMPORT": "Your transformers install is incomplete, or a package it loads models through (torchaudio) is missing or mismatched with your torch. Reinstall them together (`uv pip install --reinstall torch torchaudio transformers`), then restart the backend. If only transcription is affected, switching ASR to faster-whisper (Settings → Models) also works around it.",
|
||||
"WINDOWS_APP_CONTROL_BLOCKED": "Windows refused to load a file OmniVoice needs — an Application Control policy (Smart App Control, WDAC, or AppLocker) blocked it. On a personal PC: Windows Security → App & browser control → Smart App Control → Off (Windows only lets you turn it off once — re-enabling requires a Windows reset), then restart OmniVoice. On a managed/work PC, ask IT to allow the OmniVoice install folder.",
|
||||
"WINDOWS_PAGING_FILE_TOO_SMALL": "Windows ran out of virtual memory while mapping the model into memory — its paging file is smaller than the model needs. This is not the same as your RAM being full, and closing other apps usually won't fix it: Windows has to be allowed to back the mapping. Set a bigger paging file — Settings → System → About → Advanced system settings → Performance → Settings → Advanced → Virtual memory → Change: untick \"Automatically manage\", pick your system drive, choose \"Custom size\" and set both Initial and Maximum to at least 32768 MB (more than the model's size), then OK and restart Windows. A smaller/quantized engine (OmniVoice GGUF, Supertonic-3) also avoids the large mapping entirely.",
|
||||
"MEDIA_TOOL_MISSING": "OmniVoice's media engine (ffmpeg/ffprobe) wasn't on the system path when a component went looking for it. Open Settings → Audio tools and use Download/Repair to fetch the bundled copy, then retry — a restart picks it up for everything. If you'd rather use a system install, install ffmpeg (macOS: `brew install ffmpeg`; Windows: `winget install Gyan.FFmpeg`; Linux: your package manager) and restart OmniVoice, or point FFMPEG_PATH / OMNIVOICE_FFPROBE_PATH at the binaries in Settings.",
|
||||
"AUDIO_IO_FAILED": "An audio file couldn't be read or written at the OS level. Check the drive isn't full, that the output and temp folders exist and are writable, and that antivirus or OneDrive isn't locking them (add an OmniVoice exclusion if you use one).",
|
||||
"VIDEO_DOWNLOAD_OS_ERROR": "The OS refused a file operation while saving the downloaded video — this is a disk/folder problem, not a network one, so retrying the same link won't help. The download is written to a job folder under your OmniVoice data directory (Settings → Storage shows the path): check that drive isn't full, that the folder exists and is writable, and that antivirus or a cloud-sync client (OneDrive, Dropbox) isn't locking it — add an OmniVoice exclusion if you use one. If your data directory sits on a synced or network drive, move it to a local one.",
|
||||
"OS_INVALID_ARGUMENT": "The OS rejected a file operation (Errno 22 / invalid argument) — in the transcribe path this is the temporary WAV write before ASR. It's almost always the temp directory: missing, read-only, on a full or removed drive, or blocked by antivirus. Check that your system TEMP/TMP folder exists and is writable and the drive has free space (add an OmniVoice antivirus exclusion if you use one), then retry.",
|
||||
"SOCKS_PROXY_SUPPORT_MISSING": "A SOCKS proxy is configured in your environment (ALL_PROXY/HTTPS_PROXY=socks5://…) and the backend's HTTP client is missing SOCKS support. Newer OmniVoice builds ship SOCKS support (the socksio package) — update the app. If you still see this, unset ALL_PROXY/HTTPS_PROXY for OmniVoice, or run `uv pip install 'httpx[socks]'` in the backend venv, then restart.",
|
||||
"SSL_HANDSHAKE_FAILURE": "A corporate or antivirus proxy is intercepting HTTPS traffic and re-signing certificates with its own CA — your OS trusts that CA, but Python's bundled certifi CA list doesn't, so the TLS handshake fails even though the connection reached the server. Newer OmniVoice builds trust the OS certificate store at startup (the truststore package), which should already fix this — update the app and retry. If you still see this, add an HTTPS-scanning exclusion for OmniVoice/Python in your antivirus, or ask IT for the proxy's CA bundle and set SSL_CERT_FILE to it, then restart.",
|
||||
"UNSUPPORTED_VIDEO_URL": "This link isn't a directly downloadable video. Paste a direct video page (e.g. a youtube.com/watch?v=… or douyin.com/video/<id> link), not a share/profile/feed link — or download the file and drop it in directly.",
|
||||
"VIDEO_DRM_PROTECTED": "The video host only offered OmniVoice a DRM-protected copy, which can't be downloaded. This is often not a property of the video itself — the host serves a different format set to different clients, and OmniVoice already retried through every client it has. Try the link again in a minute, or download the video with a browser extension / the host's own download button and drop the file into Dubbing directly.",
|
||||
"VIDEO_DOWNLOAD_NETWORK": "The connection to the video server dropped mid-download (often a transient CDN/network blip or a regional rate-limit). Just retry — OmniVoice already cleaned up the partial download. If it keeps failing, check your network/VPN.",
|
||||
"BROKEN_VENV": "The Python backend environment was moved or damaged. OmniVoice rebuilds it automatically on the next launch; if it keeps failing, use Clean & Retry on the setup screen.",
|
||||
"MODEL_CACHE_CORRUPT": "The model cache had broken file links — snapshot entries that no longer point at their downloaded data (interrupted renames or antivirus interference can cause this). OmniVoice repairs this automatically and retries the load once. If the error persists, quit OmniVoice, delete the model's models--<org>--<name> folder inside the Hugging Face cache, and restart — the model re-downloads automatically.",
|
||||
@@ -84,6 +91,18 @@ _HF_CONNECTIVITY_SIGNATURES = (
|
||||
"timed out",
|
||||
"an error happened while trying to locate the file on the hub", # LocalEntryNotFoundError
|
||||
"we cannot find the requested files", # LocalEntryNotFoundError
|
||||
# #1224: a TRUNCATED download — the server closed mid-body, so the client
|
||||
# got fewer bytes than Content-Length promised. httpx words it "peer closed
|
||||
# connection without sending complete message body"; urllib3/http.client
|
||||
# raise IncompleteRead. This is as transient as a refused connection and
|
||||
# must retry — a 4.6 GB model that dies at 4.0 GB used to abort the whole
|
||||
# install (and, on the reporter's 16 GB Mac, take the process with it).
|
||||
"peer closed connection",
|
||||
"incomplete message body",
|
||||
"incompleteread",
|
||||
"incomplete read",
|
||||
"connection broken", # urllib3 ProtocolError wrapper
|
||||
"response ended prematurely",
|
||||
)
|
||||
|
||||
# The failure must also be Hugging-Face-shaped — the configured endpoint/host
|
||||
@@ -247,18 +266,43 @@ def classify(reason: str) -> str:
|
||||
# argument" wording) keeps this from mislabelling unrelated failures; the
|
||||
# transformers "errno 2" rule below is unaffected — it also requires the
|
||||
# transformers + site-packages markers, which this signature lacks.
|
||||
# #1225: the same errno raised by the DUB video download is a different
|
||||
# class with a different remedy — the failing directory is the job folder
|
||||
# under the OmniVoice data dir, not the system temp dir. Checked first so
|
||||
# a download's errno 22 stops being handed the transcribe path's
|
||||
# "check your TEMP folder" hint, which sends the user to the wrong place.
|
||||
if is_os_write_refusal(reason) and any(
|
||||
marker in low for marker in _DOWNLOAD_CONTEXT_MARKERS
|
||||
):
|
||||
return "VIDEO_DOWNLOAD_OS_ERROR"
|
||||
# #1256: a third-party library shelled out to `ffprobe`/`ffmpeg` BY NAME and
|
||||
# the OS had nothing to run. OmniVoice's own code always resolves the
|
||||
# bundled sidecar explicitly, so this only ever comes from a dependency —
|
||||
# which meant it arrived with no class at all and the user was told the
|
||||
# engine "stopped with an error OmniVoice doesn't recognize". Checked
|
||||
# before the generic errno-2 rules, which would otherwise claim it.
|
||||
if _is_missing_media_tool(low):
|
||||
return "MEDIA_TOOL_MISSING"
|
||||
# #1251: Windows refused to map the model because the PAGING FILE is too
|
||||
# small. The generate path already counted this as an OOM, but that hint
|
||||
# ("close other apps, use a lighter engine") is the wrong remedy — the
|
||||
# machine had 32 GB of RAM. Matched on the numeric code too, since the OS
|
||||
# translates the message text, and in both the Python (`[WinError 1455]`)
|
||||
# and Rust (`os error 1455`, from the safetensors mmap) spellings.
|
||||
if "1455" in low and ("winerror" in low or "os error" in low):
|
||||
return "WINDOWS_PAGING_FILE_TOO_SMALL"
|
||||
if "paging file is too small" in low:
|
||||
return "WINDOWS_PAGING_FILE_TOO_SMALL"
|
||||
if "errno 22" in low:
|
||||
return "OS_INVALID_ARGUMENT"
|
||||
# An HF cache whose snapshot entries don't resolve (dangling symlinks /
|
||||
# zero-byte stand-ins): transformers reports the weights missing ("does
|
||||
# not appear to have a file named pytorch_model.bin or model.safetensors")
|
||||
# even though the blobs are fully on disk. model_manager self-heals this
|
||||
# (delete broken entries → snapshot_download → retry once); the class here
|
||||
# covers both the raw transformers wording (any load surface can leak it)
|
||||
# and OmniVoice's own repair messages, so the user-facing error and the
|
||||
# auto bug report name the class and its automatic repair.
|
||||
if ("does not appear to have a file named" in low
|
||||
or "broken file link" in low):
|
||||
# zero-byte stand-ins) or which is simply missing its weight shard.
|
||||
# model_manager self-heals this (delete broken entries → snapshot_download
|
||||
# → retry once); the class here covers the raw transformers wordings (any
|
||||
# load surface can leak them) and OmniVoice's own repair messages, so the
|
||||
# user-facing error and the auto bug report name the class and its
|
||||
# automatic repair.
|
||||
if is_incomplete_cache_message(low) or "broken file link" in low:
|
||||
return "MODEL_CACHE_CORRUPT"
|
||||
if (
|
||||
"could not import module" in low
|
||||
@@ -318,6 +362,13 @@ def classify(reason: str) -> str:
|
||||
# Broken pipe" still classifies as a network blip.
|
||||
if "unsupported url" in low or "no video formats" in low or "is not a valid url" in low:
|
||||
return "UNSUPPORTED_VIDEO_URL"
|
||||
# #1254: reported as intermittent — the same URL failed, then succeeded on
|
||||
# a retry. That is a per-player-client format set, not real DRM, so the
|
||||
# download path now escalates the client the way it does for a 403. If
|
||||
# every client still says DRM, the video genuinely can't be fetched and the
|
||||
# user needs to hear that rather than retry a fourth time.
|
||||
if "drm protected" in low or "drm-protected" in low:
|
||||
return "VIDEO_DRM_PROTECTED"
|
||||
if (
|
||||
"broken pipe" in low
|
||||
or "connection reset" in low
|
||||
@@ -326,6 +377,25 @@ def classify(reason: str) -> str:
|
||||
or "timed out" in low
|
||||
):
|
||||
return "VIDEO_DOWNLOAD_NETWORK"
|
||||
# #1227: Windows Smart App Control / WDAC / AppLocker refused to load a
|
||||
# file the app needs. Matched on the numeric codes (locale-independent —
|
||||
# the OS translates the message text) plus the English policy phrase.
|
||||
if (
|
||||
"[winerror 4551]" in low
|
||||
or "[winerror 1260]" in low
|
||||
or "application control policy" in low
|
||||
):
|
||||
return "WINDOWS_APP_CONTROL_BLOCKED"
|
||||
# #1221: libsndfile failed an OS-level audio read/write. Its own wording is
|
||||
# a bare "System error.", so match the library name — audio_io already
|
||||
# prefixes the target path and free space onto the write-path failures.
|
||||
# ``audio_io.AUDIO_WRITE_FAILED_MARKER`` (kept as a literal — core must not
|
||||
# import services). Matching the marker instead of generic wording like
|
||||
# "error opening" keeps a failed MODEL/config/archive open from being handed
|
||||
# the audio remedy, while still classifying the enriched write failures that
|
||||
# no longer carry the word "libsndfile" verbatim.
|
||||
if "libsndfile" in low or "writing the audio file failed" in low:
|
||||
return "AUDIO_IO_FAILED"
|
||||
# A relocated/corrupted venv whose interpreter can't bootstrap its stdlib —
|
||||
# the Rust self-heal rebuilds it; this names the class for the toast.
|
||||
if "no module named 'encodings'" in low:
|
||||
@@ -411,6 +481,159 @@ def diagnostic(*, reason: str, error_class: str, stage: str) -> str:
|
||||
return sanitize(block)
|
||||
|
||||
|
||||
# Signatures of the OS refusing a file operation. One list, because two
|
||||
# consumers must agree: ``classify`` (which picks the class + hint) and
|
||||
# ``dub_pipeline._with_target_facts`` (which decides whether to attach the
|
||||
# destination). When they drifted, an ENOENT download classified as a disk
|
||||
# problem but never got the folder named — the one fact that would have made
|
||||
# the message actionable (#1225 review).
|
||||
_OS_WRITE_REFUSAL_SIGNATURES = (
|
||||
"errno 22", "invalid argument",
|
||||
"errno 13", "permission denied",
|
||||
"errno 28", "no space left",
|
||||
"errno 2", "no such file or directory",
|
||||
"unable to open for writing",
|
||||
"unable to rename file",
|
||||
)
|
||||
|
||||
# Wording that places a failure in the video-download path specifically.
|
||||
_DOWNLOAD_CONTEXT_MARKERS = (
|
||||
"unable to download video",
|
||||
"unable to open for writing",
|
||||
"unable to rename file",
|
||||
"yt_dlp",
|
||||
"yt-dlp",
|
||||
)
|
||||
|
||||
|
||||
#: The media binaries a dependency may shell out to by bare name.
|
||||
_MEDIA_TOOLS = ("ffprobe", "ffmpeg")
|
||||
|
||||
|
||||
def _is_missing_media_tool(low: str) -> bool:
|
||||
"""True when the failure is "the OS could not find ffprobe/ffmpeg" (#1256).
|
||||
|
||||
Deliberately narrow: it must name the binary *as the thing that could not
|
||||
be found*, so a perfectly ordinary "ffmpeg failed: no such file or
|
||||
directory: /path/to/input.wav" — a missing INPUT, an entirely different
|
||||
problem — is not handed the "install the media engine" remedy.
|
||||
"""
|
||||
if "no such file or directory" not in low and "[winerror 2]" not in low:
|
||||
return False
|
||||
# The name must appear UNQUALIFIED — quoted with no directory part, which
|
||||
# is how a bare-name spawn fails. A path that merely ends in the tool's
|
||||
# name ('/tmp/ffmpeg', '~/Movies/my-ffmpeg-export.mp4') is a missing FILE,
|
||||
# an entirely different problem that must not get the "repair your media
|
||||
# engine" remedy (#1256 review).
|
||||
return any(
|
||||
f"'{tool}'" in low or f'"{tool}"' in low
|
||||
for tool in _MEDIA_TOOLS
|
||||
)
|
||||
|
||||
|
||||
#: transformers' two ways of saying "this snapshot has no weight shard".
|
||||
#:
|
||||
#: They come from different code paths and share no wording, so matching only
|
||||
#: the first — which both the classifier and model_manager's self-heal used to
|
||||
#: do — silently missed half the class (#1273):
|
||||
#:
|
||||
#: hub load: "<repo> does not appear to have a file named
|
||||
#: pytorch_model.bin or model.safetensors"
|
||||
#: local dir: "Error no file named model.safetensors, or pytorch_model.bin,
|
||||
#: found in directory <path>"
|
||||
#:
|
||||
#: The second is what a load of a *subfolder* inside a cached snapshot raises
|
||||
#: (e.g. `audio_tokenizer/`), which is exactly where an interrupted download
|
||||
#: leaves a repo half-written. Both mean the same thing and both are repaired
|
||||
#: the same way, so both must classify and both must trigger the heal.
|
||||
_INCOMPLETE_CACHE_PHRASES = (
|
||||
"does not appear to have a file named",
|
||||
# Matched as two fragments: the file list between them varies with the
|
||||
# transformers version and the requested weight variant.
|
||||
("error no file named", "found in directory"),
|
||||
)
|
||||
|
||||
|
||||
def is_incomplete_cache_message(text: str) -> bool:
|
||||
"""True when *text* is transformers reporting a snapshot with no weights.
|
||||
|
||||
Takes an already-lowercased string. Shared by :func:`classify` and
|
||||
model_manager's cache self-heal so the healer and the error message can
|
||||
never disagree about what an interrupted download looks like.
|
||||
"""
|
||||
low = str(text).lower()
|
||||
for phrase in _INCOMPLETE_CACHE_PHRASES:
|
||||
if isinstance(phrase, tuple):
|
||||
if all(part in low for part in phrase):
|
||||
return True
|
||||
elif phrase in low:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def is_os_write_refusal(reason: Optional[str]) -> bool:
|
||||
"""True when *reason* looks like the OS refusing a file operation (a full
|
||||
or removed drive, a read-only folder, an antivirus/cloud-sync lock) rather
|
||||
than a network or format failure. Signature match only; never raises."""
|
||||
low = (reason or "").lower()
|
||||
return any(sig in low for sig in _OS_WRITE_REFUSAL_SIGNATURES)
|
||||
|
||||
|
||||
def describe_path_target(path: str) -> str:
|
||||
"""Observable facts about where we were writing — "the folder does not
|
||||
exist", "the folder is not writable", "1,234 MB free on its drive".
|
||||
|
||||
A bare OS error ("[Errno 22] Invalid argument", "System error.") names
|
||||
neither the target nor the reason, which is what makes those reports
|
||||
un-actionable (#1225). Attaching what we CAN see distinguishes a full
|
||||
drive from a removed one from an antivirus/OneDrive lock. Never raises —
|
||||
diagnosis must never replace the failure being diagnosed.
|
||||
"""
|
||||
facts: list[str] = []
|
||||
try:
|
||||
directory = os.path.dirname(os.path.abspath(path)) or "."
|
||||
if not os.path.isdir(directory):
|
||||
facts.append("the folder does not exist")
|
||||
else:
|
||||
if not os.access(directory, os.W_OK):
|
||||
facts.append("the folder is not writable")
|
||||
try:
|
||||
free_mb = shutil.disk_usage(directory).free / (1024 ** 2)
|
||||
facts.append(f"{free_mb:,.0f} MB free on its drive")
|
||||
except OSError:
|
||||
facts.append("free space could not be read")
|
||||
except Exception:
|
||||
return ""
|
||||
return "; ".join(facts)
|
||||
|
||||
|
||||
#: Exception types whose ``str()`` is a bare VALUE rather than a sentence, so
|
||||
#: showing it alone tells the user nothing about what went wrong.
|
||||
#: ``str(KeyError("mgw39lx3"))`` is ``"'mgw39lx3'"`` — the repr of the key.
|
||||
_VALUE_ONLY_STR_EXCEPTIONS = (KeyError,)
|
||||
|
||||
|
||||
def describe_exception(exc: BaseException) -> str:
|
||||
"""``str(exc)`` in a form a human can act on.
|
||||
|
||||
#1252/#1253: a dub ingest failed with the toast ``ingest: 'mgw39lx3'`` and
|
||||
nothing else — the entire user-facing reason was the repr of a dict key,
|
||||
because ``str(KeyError)`` does not mention that a lookup failed, or that it
|
||||
was an exception at all. The reporter's ``'mgw39lx3'`` was their own job id
|
||||
reflected back at them with no context.
|
||||
|
||||
Naming the class is the floor, not the goal: a failure that reaches here at
|
||||
all is one nobody wrote a message for. It keeps the report diagnosable
|
||||
instead of cryptic while the specific path gets its own handling.
|
||||
"""
|
||||
text = str(exc).strip()
|
||||
if not text:
|
||||
return type(exc).__name__
|
||||
if isinstance(exc, _VALUE_ONLY_STR_EXCEPTIONS):
|
||||
return f"{type(exc).__name__}: {text}"
|
||||
return text
|
||||
|
||||
|
||||
def build_failure(
|
||||
exc_or_msg: Any,
|
||||
*,
|
||||
@@ -424,7 +647,7 @@ def build_failure(
|
||||
"""
|
||||
if isinstance(exc_or_msg, BaseException):
|
||||
error_class = type(exc_or_msg).__name__
|
||||
raw = str(exc_or_msg).strip() or error_class
|
||||
raw = describe_exception(exc_or_msg)
|
||||
else:
|
||||
error_class = "Error"
|
||||
raw = str(exc_or_msg).strip() or "Unknown failure"
|
||||
|
||||
@@ -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}"
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -50,6 +50,7 @@ from __future__ import annotations
|
||||
import io
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
from typing import Any, BinaryIO, Union
|
||||
|
||||
@@ -196,8 +197,61 @@ def _safe_torchaudio_save(
|
||||
fmt, e,
|
||||
)
|
||||
torchaudio.save(path_or_buf, tensor, sample_rate, format=fmt)
|
||||
except Exception as e:
|
||||
# #1221: libsndfile reports OS-level write failures as a bare
|
||||
# "LibsndfileError: System error." — no path, no errno, nothing the
|
||||
# user can act on, and it fell through generation.py's classifier to
|
||||
# "an error OmniVoice doesn't recognize". Name the target and what we
|
||||
# can observe about it (exists / writable / free space) so the message
|
||||
# points at the actual problem: a full disk, a read-only or
|
||||
# antivirus-locked output folder, or a removed drive.
|
||||
raise _describe_write_failure(e, path_or_buf) from e
|
||||
|
||||
|
||||
#: Stable, language-independent marker prefixed onto every enriched audio-write
|
||||
#: failure. ``core.failure.classify`` matches on THIS rather than on generic
|
||||
#: wording like "error opening", which also appears when a model, archive or
|
||||
#: config file fails to open and would hand those failures the audio remedy.
|
||||
AUDIO_WRITE_FAILED_MARKER = "Writing the audio file failed"
|
||||
|
||||
|
||||
def _describe_write_failure(e: Exception, path_or_buf: PathOrBuf) -> Exception:
|
||||
"""``e`` re-raised as a RuntimeError that names the write target, or ``e``
|
||||
itself when there is nothing to add.
|
||||
|
||||
The type is deliberately NOT preserved: ``LibsndfileError.__init__`` takes
|
||||
an integer libsndfile code, so ``type(e)(message)`` builds an exception
|
||||
whose ``str()`` raises. Every caller of ``_safe_torchaudio_save`` catches
|
||||
broadly, and the original stays reachable as ``__cause__``.
|
||||
|
||||
Best-effort — a failure to diagnose must never replace the real error."""
|
||||
try:
|
||||
if not isinstance(path_or_buf, (str, os.PathLike)):
|
||||
return e # in-memory buffer: nothing to inspect
|
||||
path = os.fspath(path_or_buf)
|
||||
if getattr(e, "filename", None) or path in str(e):
|
||||
return e # already self-describing
|
||||
directory = os.path.dirname(os.path.abspath(path)) or "."
|
||||
facts = []
|
||||
if not os.path.isdir(directory):
|
||||
facts.append("the folder does not exist")
|
||||
else:
|
||||
if not os.access(directory, os.W_OK):
|
||||
facts.append("the folder is not writable")
|
||||
try:
|
||||
free_mb = shutil.disk_usage(directory).free / (1024 ** 2)
|
||||
facts.append(f"{free_mb:,.0f} MB free on its drive")
|
||||
except OSError:
|
||||
facts.append("free space could not be read")
|
||||
return RuntimeError(
|
||||
f"{AUDIO_WRITE_FAILED_MARKER}: {type(e).__name__}: {e} — target "
|
||||
f"{path} ({'; '.join(facts)}). An audio write failing at the OS "
|
||||
f"level is usually a full drive, a read-only or removed folder, or "
|
||||
f"antivirus/OneDrive locking the file; add an OmniVoice exclusion "
|
||||
f"if you use one."
|
||||
)
|
||||
except Exception:
|
||||
raise
|
||||
return e
|
||||
|
||||
|
||||
def _safe_soundfile_write(
|
||||
|
||||
@@ -37,6 +37,7 @@ import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from collections import OrderedDict
|
||||
from typing import AsyncIterator, Optional
|
||||
|
||||
import soundfile as sf
|
||||
@@ -67,7 +68,45 @@ logger = logging.getLogger("omnivoice.dub_pipeline")
|
||||
# backward compat during the transition.
|
||||
|
||||
_dub_jobs: dict[str, dict] = {}
|
||||
_dub_jobs_lock = threading.Lock()
|
||||
# Re-entrant: `save_job` takes this lock itself (see below), and the atomic
|
||||
# helpers call it while already holding it.
|
||||
_dub_jobs_lock = threading.RLock()
|
||||
|
||||
#: Ingests currently running. Used only so "clear history" can also sweep a job
|
||||
#: that has no row yet — it would appear in no id list otherwise.
|
||||
_inflight_jobs: set[str] = set()
|
||||
|
||||
#: Recently-deleted job ids, most-recent last. Guarded by ``_dub_jobs_lock``.
|
||||
#:
|
||||
#: Dict membership alone cannot express "the user withdrew this job" (#1252
|
||||
#: review): a job's FIRST persistence creates the entry, so an absent key means
|
||||
#: "not written yet" for a new job and "deleted" for an established one — two
|
||||
#: opposite instructions from one signal.
|
||||
#:
|
||||
#: Scoped to DELETED, not to in-flight ingests. Scoping it to ingests looked
|
||||
#: right and closed nothing that mattered: a dub is imported once and rendered
|
||||
#: many times, so the realistic delete lands during a RENDER, long after its
|
||||
#: ingest ended — and a render's save would then write the row straight back.
|
||||
#:
|
||||
#: Retained rather than cleared on completion, because there is no moment at
|
||||
#: which a delete stops mattering: any operation still holding that job can
|
||||
#: persist it. ``begin_ingest`` drops an id explicitly, since re-importing is a
|
||||
#: deliberate revival.
|
||||
#:
|
||||
#: Expired by AGE, not by count. A count-bounded LRU is evictable by ordinary
|
||||
#: use: ``DELETE /dub/history`` purges every row with no limit, so a user
|
||||
#: clearing a large history mid-render would push the rendering job's own
|
||||
#: marker out and the render would then write it back (#1252 review). Age
|
||||
#: cannot be gamed that way — what matters is how long ago the delete happened,
|
||||
#: not how many others followed it.
|
||||
#:
|
||||
#: The count cap is a memory backstop only, set far above any real history:
|
||||
#: ~4096 short ids is a few hundred KB. Reaching it needs 4096 deletions inside
|
||||
#: one TTL window, at which point the oldest markers are the least likely to
|
||||
#: still be held.
|
||||
_WITHDRAWN_TTL_S = 6 * 3600 # outlives any realistic render or transcribe
|
||||
_WITHDRAWN_MAX = 4096 # memory backstop, not the eviction policy
|
||||
_withdrawn_jobs: "OrderedDict[str, float]" = OrderedDict()
|
||||
|
||||
_DUB_DIR_REAL = os.path.realpath(DUB_DIR)
|
||||
_HASH_BUF_SIZE = 1 << 18 # 256 KB chunks for hashing
|
||||
@@ -197,6 +236,162 @@ def put_job(job_id: str, job: dict) -> None:
|
||||
_dub_jobs[job_id] = job
|
||||
|
||||
|
||||
def merge_job(job_id: str, updates: dict) -> bool:
|
||||
"""Merge *updates* into an existing in-memory job. Does NOT persist.
|
||||
|
||||
Returns ``False`` when the job is gone — which is a real, reachable state,
|
||||
not a defensive nicety: ingest runs for minutes (demucs, scene detection,
|
||||
thumbnailing) and ``DELETE /dub/history/{id}`` pops the entry out from
|
||||
under it. The pipeline used to finish with a bare
|
||||
``_dub_jobs[job_id].update(...)``, so deleting an in-flight dub surfaced as
|
||||
the toast ``ingest: 'mgw39lx3'`` — ``str(KeyError)`` is the repr of the
|
||||
key, nothing more (#1252/#1253). Callers treat ``False`` as "the user
|
||||
withdrew this job" and stop, rather than resurrecting a record that was
|
||||
deliberately deleted.
|
||||
"""
|
||||
with _dub_jobs_lock:
|
||||
job = _dub_jobs.get(job_id)
|
||||
if job is None:
|
||||
return False
|
||||
job.update(updates)
|
||||
return True
|
||||
|
||||
|
||||
def _expire_withdrawn(now: float, protected: int = 0) -> None:
|
||||
"""Drop withdrawal markers that are too old to still matter.
|
||||
|
||||
Caller must hold ``_dub_jobs_lock``. Age first — that is the policy — then
|
||||
a size cap purely so the mapping cannot grow without bound.
|
||||
|
||||
``protected`` is how many markers the current purge just recorded. Those sit
|
||||
at the end (newest) and are never evicted by the size cap: they are the most
|
||||
likely to still be held by a running job, and dropping one is exactly the
|
||||
resurrection this whole mechanism exists to prevent. A single "clear
|
||||
history" larger than the cap would otherwise force us to discard live
|
||||
markers — which is what broke CI. The bound therefore is
|
||||
``cap + one purge``, not ``cap``.
|
||||
"""
|
||||
cutoff = now - _WITHDRAWN_TTL_S
|
||||
while _withdrawn_jobs:
|
||||
_, deleted_at = next(iter(_withdrawn_jobs.items()))
|
||||
if deleted_at >= cutoff:
|
||||
break
|
||||
_withdrawn_jobs.popitem(last=False)
|
||||
floor = max(_WITHDRAWN_MAX, protected)
|
||||
while len(_withdrawn_jobs) > floor:
|
||||
_withdrawn_jobs.popitem(last=False)
|
||||
|
||||
|
||||
def begin_ingest(job_id: str) -> None:
|
||||
"""Mark an ingest as running.
|
||||
|
||||
Re-importing an id is a deliberate revival, so this clears any tombstone —
|
||||
the only thing that legitimately un-deletes a job.
|
||||
"""
|
||||
with _dub_jobs_lock:
|
||||
_inflight_jobs.add(job_id)
|
||||
_withdrawn_jobs.pop(job_id, None)
|
||||
|
||||
|
||||
def end_ingest(job_id: str) -> None:
|
||||
"""Mark an ingest as finished, however it ended.
|
||||
|
||||
Deliberately does NOT clear the tombstone: the ingest ending is not the
|
||||
user un-deleting anything, and a render started before the delete can still
|
||||
be holding that job.
|
||||
"""
|
||||
with _dub_jobs_lock:
|
||||
_inflight_jobs.discard(job_id)
|
||||
|
||||
|
||||
def put_and_save_job(
|
||||
job_id: str,
|
||||
job: dict,
|
||||
*,
|
||||
filename: str = "",
|
||||
duration: float = 0.0,
|
||||
content_hash: str = "",
|
||||
) -> bool:
|
||||
""":func:`put_job` and :func:`save_job` as ONE atomic step.
|
||||
|
||||
Returns ``False`` when the job was withdrawn — the user deleted or cleared
|
||||
its history while this ingest was running — in which case nothing is
|
||||
written. Gating on the tombstone rather than on dict membership is what
|
||||
makes this correct for a job's FIRST write, where an absent key is normal
|
||||
(#1252 review).
|
||||
"""
|
||||
with _dub_jobs_lock:
|
||||
if job_id in _withdrawn_jobs:
|
||||
return False
|
||||
_dub_jobs[job_id] = job
|
||||
save_job(job_id, job, filename, duration, content_hash)
|
||||
return True
|
||||
|
||||
|
||||
def merge_and_save_job(
|
||||
job_id: str,
|
||||
updates: dict,
|
||||
*,
|
||||
filename: str = "",
|
||||
duration: float = 0.0,
|
||||
content_hash: str = "",
|
||||
) -> bool:
|
||||
""":func:`merge_job` and :func:`save_job` as ONE atomic step.
|
||||
|
||||
Splitting them leaves a window that resurrects deleted work (#1252 review):
|
||||
merge succeeds, the user deletes the dub — removing the row *and* the
|
||||
in-memory entry — and the pending ``save_job`` then UPSERTs the row straight
|
||||
back, so a dub the user deleted reappears in history. The delete endpoints
|
||||
take this same lock around their own row-delete + evict, so the two
|
||||
sequences cannot interleave at all.
|
||||
|
||||
Returns ``False`` when the job is already gone; the caller stops there.
|
||||
|
||||
The ``save_job`` write happens INSIDE the lock deliberately. That serialises
|
||||
dub job-state access against one SQLite UPSERT — normally microseconds under
|
||||
WAL, but up to sqlite3's 5 s default busy timeout if another writer is
|
||||
holding the write lock. The alternative — releasing the lock before the
|
||||
write — is the resurrection race this exists to close, so a rare latency
|
||||
blip is the better trade. No locked region here calls another locked
|
||||
function, so the plain (non-reentrant) ``_dub_jobs_lock`` cannot deadlock.
|
||||
"""
|
||||
with _dub_jobs_lock:
|
||||
job = _dub_jobs.get(job_id)
|
||||
if job is None or job_id in _withdrawn_jobs:
|
||||
return False
|
||||
job.update(updates)
|
||||
save_job(job_id, job, filename, duration, content_hash)
|
||||
return True
|
||||
|
||||
|
||||
def purge_jobs(job_ids, *, delete_rows, include_inflight: bool = False) -> None:
|
||||
"""Delete history rows and evict the in-memory records as ONE atomic step.
|
||||
|
||||
``delete_rows`` is called with the lock held, so a concurrent
|
||||
:func:`merge_and_save_job` cannot slip between the row-delete and the
|
||||
evict and write the job straight back (#1252 review). Both delete
|
||||
endpoints go through here; ``DELETE /dub/history`` previously never evicted
|
||||
from memory at all, so an in-flight job survived "clear history" entirely
|
||||
and re-saved itself on completion.
|
||||
"""
|
||||
with _dub_jobs_lock:
|
||||
delete_rows()
|
||||
# Deterministic order, de-duplicated. A `set` here made which markers
|
||||
# the size cap evicts depend on PYTHONHASHSEED — the test for this very
|
||||
# behaviour passed locally and failed in CI for that reason alone.
|
||||
targets = list(dict.fromkeys(job_ids))
|
||||
if include_inflight:
|
||||
# "Clear history" means everything, including a job whose first row
|
||||
# hasn't been written yet — it wouldn't appear in `job_ids` at all.
|
||||
targets += [j for j in sorted(_inflight_jobs) if j not in set(targets)]
|
||||
now = time.monotonic()
|
||||
for job_id in targets:
|
||||
_dub_jobs.pop(job_id, None)
|
||||
_withdrawn_jobs.pop(job_id, None)
|
||||
_withdrawn_jobs[job_id] = now # most-recent last
|
||||
_expire_withdrawn(now, protected=len(targets))
|
||||
|
||||
|
||||
def save_job(job_id: str, job: dict, filename: str = "", duration: float = 0.0, content_hash: str = "") -> None:
|
||||
"""Persist dub job state to SQLite so it survives restarts. Uses UPSERT
|
||||
on `id` so repeated saves in a session keep the latest snapshot.
|
||||
@@ -209,6 +404,22 @@ def save_job(job_id: str, job: dict, filename: str = "", duration: float = 0.0,
|
||||
keys history restore off language_code, so a frozen "" hid finished
|
||||
tracks until the user re-picked a language.
|
||||
"""
|
||||
with _dub_jobs_lock:
|
||||
# The withdrawal gate lives HERE, not in the callers (#1252 review).
|
||||
# Eight call sites across generate / translate / export / core persist
|
||||
# jobs directly, so gating only the ingest helpers left every
|
||||
# post-ingest save able to resurrect a dub the user deleted mid-render.
|
||||
# One choke point closes the class and the ninth caller inherits it.
|
||||
if job_id in _withdrawn_jobs:
|
||||
logger.info(
|
||||
"Dub job %s was deleted while it was still running — not persisting", job_id,
|
||||
)
|
||||
return
|
||||
_persist_job(job_id, job, filename, duration, content_hash)
|
||||
|
||||
|
||||
def _persist_job(job_id: str, job: dict, filename: str, duration: float, content_hash: str) -> None:
|
||||
"""The actual write. Callers go through :func:`save_job`, which gates it."""
|
||||
try:
|
||||
segments = job.get("segments") or []
|
||||
tracks = list((job.get("dubbed_tracks") or {}).keys())
|
||||
@@ -502,6 +713,38 @@ def _ensure_browser_playable_mp4(video_path: str) -> str:
|
||||
_YT_DOWNLOAD_RETRIES = 2 # total attempts = 1 + retries = 3
|
||||
|
||||
|
||||
def _with_target_facts(exc: BaseException, job_dir: str) -> BaseException:
|
||||
"""``exc`` with the download destination described, when the failure looks
|
||||
like the OS refusing a file operation (#1225).
|
||||
|
||||
Returns ``exc`` untouched for network/format failures — their message is
|
||||
already about the remote side, and appending disk facts would just be
|
||||
noise. Never raises."""
|
||||
try:
|
||||
# Shared with failure.classify() so the "is this a disk problem?"
|
||||
# answer can't differ between the class we assign and whether we
|
||||
# bother naming the folder (#1225 review).
|
||||
if not failure.is_os_write_refusal(str(exc)):
|
||||
return exc
|
||||
facts = failure.describe_path_target(os.path.join(job_dir, "original.mp4"))
|
||||
if not facts:
|
||||
return exc
|
||||
msg = (
|
||||
f"{exc} — saving to {job_dir} ({facts}). The OS refused the write, "
|
||||
f"so retrying the same link won't help: check the drive isn't full, "
|
||||
f"the folder is writable, and antivirus or a cloud-sync client "
|
||||
f"(OneDrive, Dropbox) isn't locking it."
|
||||
)
|
||||
try:
|
||||
return type(exc)(msg)
|
||||
except Exception:
|
||||
# Not every exception class takes a plain message (soundfile's
|
||||
# LibsndfileError wants an int code). Keep the text, drop the type.
|
||||
return RuntimeError(msg)
|
||||
except Exception:
|
||||
return exc
|
||||
|
||||
|
||||
def _is_transient_download_error(exc: BaseException) -> bool:
|
||||
"""True when a download failure is worth retrying (broken pipe / net drop).
|
||||
|
||||
@@ -524,10 +767,23 @@ _YT_PLAYER_CLIENTS = ["tv", "android", "web_safari"]
|
||||
|
||||
|
||||
def _is_forbidden_download_error(exc: BaseException) -> bool:
|
||||
"""True for an HTTP 403 — not transient (the same client keeps 403ing), but
|
||||
often fixable by switching the YouTube player client."""
|
||||
"""True for a failure the CURRENT player client can't get past, but another
|
||||
one commonly can.
|
||||
|
||||
A 403 is the original case (#625): extraction worked, the media fetch was
|
||||
refused, and the same client keeps refusing. "This video is DRM protected"
|
||||
(#1254) behaves identically and belongs here for the same reason — YouTube
|
||||
serves a DRM-only format set to *some* player clients for videos that are
|
||||
not actually DRM'd. The reporter saw it fail and then succeed on a plain
|
||||
retry of the same URL, which is exactly what a per-client format set looks
|
||||
like from outside. Escalating the client is the fix; a bare retry only
|
||||
works when the next attempt happens to draw a different one.
|
||||
"""
|
||||
s = str(exc)
|
||||
return "403" in s or "Forbidden" in s
|
||||
if "403" in s or "Forbidden" in s:
|
||||
return True
|
||||
low = s.lower()
|
||||
return "drm protected" in low or "drm-protected" in low
|
||||
|
||||
|
||||
def _cleanup_partial_download(job_dir: str) -> None:
|
||||
@@ -568,6 +824,22 @@ def yt_download_sync(
|
||||
import glob
|
||||
import yt_dlp
|
||||
outtmpl = os.path.join(job_dir, "original.%(ext)s")
|
||||
# #1225: yt-dlp surfaces an OS write rejection as a bare
|
||||
# "Unable to download video: [Errno 22] Invalid argument" — no path, no
|
||||
# reason, and three manual retries all fail identically because nothing
|
||||
# about it is transient. Fail here instead, naming the directory, when we
|
||||
# can already see it won't work.
|
||||
_target_facts = failure.describe_path_target(outtmpl)
|
||||
if "not writable" in _target_facts or "does not exist" in _target_facts:
|
||||
# Worded so classify() places it in the download path: it must carry
|
||||
# both an OS-refusal signature and download context, or the user gets
|
||||
# no hint at all — the failure this PR exists to fix (#1225 review).
|
||||
raise OSError(
|
||||
f"Unable to download video: unable to open for writing in "
|
||||
f"{job_dir} ({_target_facts}). The video downloads into this job "
|
||||
f"folder under your OmniVoice data directory — check it exists, is "
|
||||
f"writable, and isn't locked by antivirus or a cloud-sync client."
|
||||
)
|
||||
ydl_opts: dict = {
|
||||
"outtmpl": outtmpl,
|
||||
# Prefer h264+aac streams so the merged mp4 is natively decodable
|
||||
@@ -658,7 +930,15 @@ def yt_download_sync(
|
||||
)
|
||||
time.sleep(2 * transient_used) # brief, increasing backoff
|
||||
continue
|
||||
raise
|
||||
# #1225: an OS-level rejection (errno 22 / EACCES / ENOSPC) tells
|
||||
# the user nothing on its own. Attach what we can observe about
|
||||
# the destination so the message identifies a full drive, a
|
||||
# removed folder, or an antivirus/cloud-sync lock. Wording keeps
|
||||
# the yt-dlp text so classify() still sees the download context.
|
||||
described = _with_target_facts(exc, job_dir)
|
||||
if described is exc:
|
||||
raise
|
||||
raise described from exc
|
||||
root, _ = os.path.splitext(path)
|
||||
mp4 = root + ".mp4"
|
||||
if os.path.exists(mp4):
|
||||
@@ -781,6 +1061,9 @@ async def ingest_pipeline(
|
||||
# Audio-only jobs (#119) skip scene detection + thumbnailing below; the
|
||||
# transcribe → translate → TTS core is identical.
|
||||
input_type = (source.get("input_type") or "video").lower()
|
||||
# Declare the run so a "clear history" arriving before this job's first
|
||||
# persistence can still withdraw it (#1252 review).
|
||||
begin_ingest(job_id)
|
||||
try:
|
||||
if source.get("kind") == "url":
|
||||
url = source["url"]
|
||||
@@ -949,8 +1232,12 @@ async def ingest_pipeline(
|
||||
"youtube_subs": youtube_subs_by_lang or None,
|
||||
"input_type": input_type,
|
||||
}
|
||||
put_job(job_id, full_job)
|
||||
save_job(job_id, full_job, filename, dur, content_hash)
|
||||
if not put_and_save_job(
|
||||
job_id, full_job, filename=filename, duration=dur, content_hash=content_hash,
|
||||
):
|
||||
logger.info("Dub job %s was deleted during ingest — discarding its result", job_id)
|
||||
yield prep_event("cancelled")
|
||||
return
|
||||
yield prep_event("extract_done", job_id=job_id, duration=round(dur, 2), filename=filename)
|
||||
yield prep_event("cached",
|
||||
has_bg=bool(no_vocals_path and os.path.exists(no_vocals_path)),
|
||||
@@ -973,8 +1260,12 @@ async def ingest_pipeline(
|
||||
"youtube_subs": youtube_subs_by_lang or None,
|
||||
"input_type": input_type,
|
||||
}
|
||||
put_job(job_id, partial)
|
||||
save_job(job_id, partial, filename, dur, content_hash)
|
||||
if not put_and_save_job(
|
||||
job_id, partial, filename=filename, duration=dur, content_hash=content_hash,
|
||||
):
|
||||
logger.info("Dub job %s was deleted during ingest — discarding its result", job_id)
|
||||
yield prep_event("cancelled")
|
||||
return
|
||||
yield prep_event("extract_done", job_id=job_id, duration=round(dur, 2), filename=filename)
|
||||
|
||||
vocals_path = os.path.join(job_dir, "vocals.wav")
|
||||
@@ -1066,13 +1357,30 @@ async def ingest_pipeline(
|
||||
logger.warning("Thumbnail extraction failed for %s: %s", job_id, e)
|
||||
yield prep_event("warning", **failure.build_failure(e, stage="thumbnail", include_diagnostic=False))
|
||||
|
||||
_dub_jobs[job_id].update({
|
||||
"vocals_path": vocals_path,
|
||||
"no_vocals_path": no_vocals_path,
|
||||
"thumb_path": thumb_path if (thumb_path and os.path.exists(thumb_path)) else None,
|
||||
"scene_cuts": scene_cuts,
|
||||
})
|
||||
save_job(job_id, _dub_jobs[job_id], filename, dur, content_hash)
|
||||
# The job can legitimately be gone by now — everything above takes
|
||||
# minutes and `DELETE /dub/history/{id}` pops the record. Deleting
|
||||
# an in-flight dub used to raise KeyError here and surface as the
|
||||
# toast `ingest: 'mgw39lx3'` (#1252/#1253). A withdrawn job is not
|
||||
# an error: stop quietly rather than re-persisting what the user
|
||||
# just deleted.
|
||||
# Merge and persist as one step: a delete landing BETWEEN them
|
||||
# would remove the row and then have it written straight back, so
|
||||
# the dub the user deleted reappears in history (#1252 review).
|
||||
if not merge_and_save_job(
|
||||
job_id,
|
||||
{
|
||||
"vocals_path": vocals_path,
|
||||
"no_vocals_path": no_vocals_path,
|
||||
"thumb_path": thumb_path if (thumb_path and os.path.exists(thumb_path)) else None,
|
||||
"scene_cuts": scene_cuts,
|
||||
},
|
||||
filename=filename,
|
||||
duration=dur,
|
||||
content_hash=content_hash,
|
||||
):
|
||||
logger.info("Dub job %s was deleted during ingest — discarding its result", job_id)
|
||||
yield prep_event("cancelled")
|
||||
return
|
||||
yield prep_event("ready", job_id=job_id, duration=round(dur, 2), filename=filename)
|
||||
|
||||
except asyncio.CancelledError:
|
||||
@@ -1092,5 +1400,6 @@ async def ingest_pipeline(
|
||||
yield prep_event("error", **failure.build_failure(e, stage="ingest"))
|
||||
return
|
||||
finally:
|
||||
end_ingest(job_id)
|
||||
with _active_procs_lock:
|
||||
_active_procs.pop(job_id, None)
|
||||
|
||||
@@ -58,14 +58,16 @@ def _force_compile_requested() -> bool:
|
||||
|
||||
|
||||
def _cuda_arch_supported_for_compile() -> "tuple[bool, str]":
|
||||
"""Check the GPU's compute capability against this torch build's arch list.
|
||||
"""Check the GPU's architecture against this torch build's arch list.
|
||||
|
||||
New GPU architectures (e.g. Blackwell sm_120, issue #278) routinely break
|
||||
torch.compile/Triton before upstream support lands: the eager model runs
|
||||
via PTX forward-compat, but Inductor/Triton kernel compilation targets the
|
||||
new arch directly and fails mid-generation. If the device's ``sm_XY`` tag
|
||||
is absent from ``torch.cuda.get_arch_list()`` we treat compile as
|
||||
unsupported and use eager.
|
||||
new arch directly and fails mid-generation. If the device's arch tag is
|
||||
absent from this build's arch list we treat compile as unsupported and use
|
||||
eager. The comparison is delegated to ``core.device_caps.arch_unsupported``
|
||||
so it stays CUDA/ROCm-aware — a ROCm build lists ``gfx…`` names, and the
|
||||
old ``sm_`` comparison here disabled compile on every AMD host (#1228).
|
||||
|
||||
Returns ``(supported, reason)``. Fails open — any probe error returns
|
||||
``(True, "")`` so a weird torch build never silently loses the
|
||||
@@ -75,22 +77,21 @@ def _cuda_arch_supported_for_compile() -> "tuple[bool, str]":
|
||||
try:
|
||||
import torch
|
||||
|
||||
from core.device_caps import arch_unsupported
|
||||
|
||||
if not torch.cuda.is_available():
|
||||
return True, ""
|
||||
major, minor = torch.cuda.get_device_capability(0)
|
||||
arch_list = list(getattr(torch.cuda, "get_arch_list", lambda: [])() or [])
|
||||
if not arch_list:
|
||||
return True, ""
|
||||
sm_tag = f"sm_{major}{minor}"
|
||||
if sm_tag in arch_list or f"compute_{major}{minor}" in arch_list:
|
||||
mismatch = arch_unsupported(torch)
|
||||
if mismatch is None:
|
||||
return True, ""
|
||||
device_arch, arch_list = mismatch
|
||||
try:
|
||||
device_name = torch.cuda.get_device_name(0)
|
||||
except Exception:
|
||||
device_name = "GPU"
|
||||
return False, (
|
||||
f"{device_name} (compute capability {major}.{minor} / {sm_tag}) is not "
|
||||
f"in this PyTorch build's supported arch list ({', '.join(arch_list)})"
|
||||
f"{device_name} ({device_arch}) is not in this PyTorch build's "
|
||||
f"supported arch list ({', '.join(arch_list)})"
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("CUDA arch probe for torch.compile failed; assuming supported", exc_info=True)
|
||||
|
||||
@@ -31,19 +31,57 @@ class RoutingResult(TypedDict):
|
||||
routing_reason: str | None # raw, pre-scrub
|
||||
|
||||
|
||||
def _caveat(caps: HostCaps) -> str | None:
|
||||
"""A kernel-risk caveat string for an otherwise-accelerated host, or None.
|
||||
Advisory notes (multi-GPU, VRAM-query-failed, DirectML) never qualify."""
|
||||
def _caveat(caps: HostCaps, min_vram_gb: float = 0.0) -> str | None:
|
||||
"""A caveat string for an otherwise-accelerated host, or None.
|
||||
|
||||
Two kinds, kernel risk first (it's the more severe):
|
||||
|
||||
* a driver/arch mismatch that may fail at kernel launch;
|
||||
* (#1226/#1222) a GPU that will run, but has less VRAM than the engine
|
||||
declares it needs. Two users on 4 GB cards ran the ``omnivoice`` engine
|
||||
and only learned their hardware was under-provisioned AFTER waiting out
|
||||
the full compute budget and being told the job "was too heavy". Routing
|
||||
showed a clean green "accelerated" throughout, because family membership
|
||||
was the only thing checked. Advisory, not blocking — the driver can page
|
||||
to system RAM, and short inputs fit where long ones don't.
|
||||
|
||||
Advisory probe notes (multi-GPU, VRAM-query-failed, DirectML) never
|
||||
qualify. A VRAM figure of 0 means the probe failed; don't guess from it.
|
||||
"""
|
||||
for note in caps.notes:
|
||||
if KERNEL_RISK_MARKER in note:
|
||||
return f"{caps.family.upper()} selected, but: {note}"
|
||||
# Dedicated-VRAM families ONLY. On MPS, HostCaps.vram_gb is a heuristic
|
||||
# (system RAM / 2, see device_caps) for a UNIFIED memory pool — comparing
|
||||
# it against a floor measured on discrete CUDA hardware would tell every
|
||||
# 8 GB Mac its 4 GB "VRAM" is too small for an engine that runs fine there.
|
||||
# Different memory model, different (unmeasured) floor; don't guess.
|
||||
if (
|
||||
caps.family in ("cuda", "rocm")
|
||||
and min_vram_gb > 0
|
||||
and 0 < caps.vram_gb < min_vram_gb
|
||||
):
|
||||
device = caps.device_name or caps.family.upper()
|
||||
return (
|
||||
f"{device} has {caps.vram_gb:.1f} GB VRAM; this engine wants about "
|
||||
f"{min_vram_gb:.0f} GB. It will run, but expect slow generations "
|
||||
f"that may time out. Unload other models before generating, keep "
|
||||
f"the text short, or pick a lighter engine."
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def resolve_routing(gpu_compat: tuple[str, ...], caps: HostCaps) -> RoutingResult:
|
||||
def resolve_routing(
|
||||
gpu_compat: tuple[str, ...],
|
||||
caps: HostCaps,
|
||||
min_vram_gb: float = 0.0,
|
||||
) -> RoutingResult:
|
||||
"""Resolve the effective device + status for an engine on this host.
|
||||
|
||||
Rules are evaluated in order; the first match wins (see spec §2)."""
|
||||
Rules are evaluated in order; the first match wins (see spec §2).
|
||||
``min_vram_gb`` is the engine's declared VRAM floor (``TTSBackend
|
||||
.min_vram_gb``); 0 disables the under-provisioned-GPU caveat. Optional so
|
||||
every existing caller keeps its exact behaviour."""
|
||||
targets = tuple(gpu_compat or ())
|
||||
fam = caps.family
|
||||
|
||||
@@ -60,7 +98,7 @@ def resolve_routing(gpu_compat: tuple[str, ...], caps: HostCaps) -> RoutingResul
|
||||
return {
|
||||
"effective_device": fam,
|
||||
"routing_status": "accelerated",
|
||||
"routing_reason": _caveat(caps),
|
||||
"routing_reason": _caveat(caps, min_vram_gb),
|
||||
}
|
||||
|
||||
# 3. CPU-native engine (declares ONLY cpu) has nothing to fall back FROM,
|
||||
@@ -143,7 +181,11 @@ def header_safe_reason(reason: str | None) -> str | None:
|
||||
return cleaned[:256] or None
|
||||
|
||||
|
||||
def routing_fields(gpu_compat: tuple[str, ...], caps: HostCaps) -> dict:
|
||||
def routing_fields(
|
||||
gpu_compat: tuple[str, ...],
|
||||
caps: HostCaps,
|
||||
min_vram_gb: float = 0.0,
|
||||
) -> dict:
|
||||
"""The three serialization-ready routing keys for a ``list_backends`` entry.
|
||||
|
||||
Resolves routing and applies the redaction contract: ``routing_reason`` is
|
||||
@@ -155,7 +197,7 @@ def routing_fields(gpu_compat: tuple[str, ...], caps: HostCaps) -> dict:
|
||||
"""
|
||||
from core.scrub import scrub_text
|
||||
|
||||
r = resolve_routing(tuple(gpu_compat or ()), caps)
|
||||
r = resolve_routing(tuple(gpu_compat or ()), caps, min_vram_gb)
|
||||
reason = r["routing_reason"]
|
||||
return {
|
||||
"effective_device": r["effective_device"],
|
||||
|
||||
@@ -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)."""
|
||||
|
||||
@@ -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}")
|
||||
|
||||
|
||||
|
||||
@@ -418,7 +418,8 @@ def _swallow_abandoned(fut) -> None:
|
||||
async def run_on_gpu_pool_guarded(fn, *, what: str = "GPU job",
|
||||
timeout: "float | None" = None,
|
||||
executor=None,
|
||||
queue_timeout: "float | None" = None):
|
||||
queue_timeout: "float | None" = None,
|
||||
min_vram_gb: float = 0.0):
|
||||
"""Run blocking ``fn`` on the GPU pool, bounding **execution** — not the
|
||||
wait for a free worker.
|
||||
|
||||
@@ -440,6 +441,12 @@ async def run_on_gpu_pool_guarded(fn, *, what: str = "GPU job",
|
||||
``fn`` must be a zero-arg callable — wrap args with ``functools.partial``.
|
||||
Executors without ``reset`` (a plain ThreadPoolExecutor in tests) still get
|
||||
both bounds; only the reset step is skipped.
|
||||
|
||||
``min_vram_gb`` is the declared VRAM floor of the engine this job belongs
|
||||
to (``TTSBackend.min_vram_gb``); it only shapes the timeout MESSAGE. Left
|
||||
at 0 — the default, and correct for every non-TTS job on this pool
|
||||
(reference transcribe, watermarking, dub steps) — the under-provisioned-GPU
|
||||
wording is never used, because nothing measured says it applies (#1226).
|
||||
"""
|
||||
loop = asyncio.get_running_loop()
|
||||
ex = executor if executor is not None else _get_gpu_pool()
|
||||
@@ -504,7 +511,7 @@ async def run_on_gpu_pool_guarded(fn, *, what: str = "GPU job",
|
||||
# Phase 2 — execution. The clock starts here: this job owns a worker.
|
||||
try:
|
||||
return await asyncio.wait_for(fut, timeout=timeout)
|
||||
except asyncio.TimeoutError:
|
||||
except asyncio.TimeoutError as timeout_exc:
|
||||
# wait_for already cancelled the asyncio wrapper; the worker thread
|
||||
# keeps going regardless. Consume whatever it eventually produces.
|
||||
fut.add_done_callback(_swallow_abandoned)
|
||||
@@ -521,10 +528,12 @@ async def run_on_gpu_pool_guarded(fn, *, what: str = "GPU job",
|
||||
except Exception:
|
||||
logger.exception("GPU pool reset after %s timeout failed",
|
||||
_log_safe(what))
|
||||
raise GpuJobTimeoutError(_timeout_guidance(what, timeout))
|
||||
raise GpuJobTimeoutError(
|
||||
_timeout_guidance(what, timeout, min_vram_gb)
|
||||
) from timeout_exc
|
||||
|
||||
|
||||
def _timeout_guidance(what: str, timeout: float) -> str:
|
||||
def _timeout_guidance(what: str, timeout: float, min_vram_gb: float = 0.0) -> str:
|
||||
"""Device-aware timeout message (#896): a CPU-only host must never be told
|
||||
to "set the engine to CPU" or blamed on VRAM — on CPU the job is simply
|
||||
compute-bound. GPU hosts keep the VRAM-contention guidance.
|
||||
@@ -538,9 +547,12 @@ def _timeout_guidance(what: str, timeout: float) -> str:
|
||||
callers something to do about it.
|
||||
"""
|
||||
family = "cuda" # conservative default: GPU wording if the probe fails
|
||||
device_name, vram_gb = "", 0.0
|
||||
try:
|
||||
from core.device_caps import detect_host_caps
|
||||
family = detect_host_caps().family
|
||||
_caps = detect_host_caps()
|
||||
family = _caps.family
|
||||
device_name, vram_gb = _caps.device_name, _caps.vram_gb
|
||||
except Exception: # noqa: BLE001 — guidance must never mask the timeout
|
||||
pass
|
||||
common = (
|
||||
@@ -559,6 +571,32 @@ def _timeout_guidance(what: str, timeout: float) -> str:
|
||||
"expect very long single generations, raise "
|
||||
"OMNIVOICE_GENERATE_TIMEOUT_S."
|
||||
)
|
||||
# #1226/#1222: two users on 4 GB cards were told, generically, that the GPU
|
||||
# "is VRAM-starved" — true, but it read as a transient contention problem
|
||||
# they could flush their way out of, when their card was simply too small
|
||||
# for the engine they had selected. Say so instead — but ONLY when the
|
||||
# caller passed the engine's measured floor and the host is a dedicated-
|
||||
# VRAM family below it. This function serves every GPU-pool job (reference
|
||||
# transcribe, watermarking, dub steps, CPU-only engines on a GPU host), so
|
||||
# a threshold applied without knowing whose job it is would confidently
|
||||
# misdiagnose most of them. And on MPS `vram_gb` is a unified-memory
|
||||
# heuristic (RAM/2), not a dedicated pool to compare against.
|
||||
if (
|
||||
min_vram_gb > 0
|
||||
and family in ("cuda", "rocm")
|
||||
and 0 < vram_gb < min_vram_gb
|
||||
):
|
||||
return common + (
|
||||
f"{device_name or 'this GPU'} has {vram_gb:.1f} GB of VRAM and "
|
||||
f"this engine wants about {min_vram_gb:.0f} GB — generations here "
|
||||
f"are slow enough to hit the limit even with nothing else loaded. "
|
||||
f"The durable fix is a lighter engine (OmniVoice GGUF and "
|
||||
f"Supertonic-3 are tuned for small/no GPU) or shorter text; "
|
||||
f"Flush caches / Unload the resident model (top toolbar or "
|
||||
f"Settings → Models) frees what little headroom there is. (Raise "
|
||||
f"OMNIVOICE_GENERATE_TIMEOUT_S if you'd rather let long "
|
||||
f"generations run.)"
|
||||
)
|
||||
return common + (
|
||||
"most often the GPU is VRAM-starved (a resident model and this job "
|
||||
"contend for memory). For a durable fix, Flush caches / Unload the "
|
||||
@@ -612,27 +650,26 @@ _loading_detail: dict = {
|
||||
"progress": None, # 0-100 percentage (None = indeterminate)
|
||||
}
|
||||
|
||||
# ── ROCm GFX version overrides ───────────────────────────────────────
|
||||
# AMD GPUs on ROCm report through torch.cuda but may need
|
||||
# HSA_OVERRIDE_GFX_VERSION for unsupported GFX IDs.
|
||||
_ROCM_GFX_OVERRIDES = {
|
||||
# RDNA 3 (RX 7000 series) — override to gfx1100
|
||||
"gfx1101": "11.0.0", "gfx1102": "11.0.0", "gfx1103": "11.0.0",
|
||||
# RDNA 2 (RX 6000 series) — override to gfx1030
|
||||
"gfx1031": "10.3.0", "gfx1032": "10.3.0", "gfx1034": "10.3.0",
|
||||
# Vega (RX Vega / Radeon VII) — override to gfx900
|
||||
"gfx902": "9.0.0", "gfx906": "9.0.6",
|
||||
}
|
||||
|
||||
|
||||
def _configure_rocm_if_needed(torch):
|
||||
"""Auto-set HSA_OVERRIDE_GFX_VERSION for AMD GPUs on ROCm.
|
||||
|
||||
ROCm-enabled PyTorch reports `torch.cuda.is_available() == True` but
|
||||
some consumer AMD GPUs have GFX IDs not in the official support matrix.
|
||||
Setting HSA_OVERRIDE_GFX_VERSION lets them run with the closest
|
||||
some consumer AMD GPUs have GFX IDs the installed build wasn't compiled
|
||||
for. Setting HSA_OVERRIDE_GFX_VERSION lets them run with the closest
|
||||
supported architecture.
|
||||
|
||||
The override is applied **only when the native gfx is genuinely absent
|
||||
from this build's arch list**. Newer ROCm wheels support parts that used
|
||||
to need remapping (gfx1151/Strix Halo is native from ROCm 7.x), and
|
||||
overriding a natively-supported GPU forces it onto foreign kernels for no
|
||||
reason — so the map is a fallback, not an unconditional rewrite.
|
||||
"""
|
||||
from core.device_caps import (
|
||||
ROCM_GFX_OVERRIDES,
|
||||
build_arch_list,
|
||||
hsa_override_for,
|
||||
)
|
||||
|
||||
if os.environ.get("HSA_OVERRIDE_GFX_VERSION"):
|
||||
return # User already set it manually
|
||||
try:
|
||||
@@ -644,41 +681,77 @@ def _configure_rocm_if_needed(torch):
|
||||
props = torch.cuda.get_device_properties(0)
|
||||
gcn_arch = getattr(props, "gcnArchName", "") or ""
|
||||
gfx_id = gcn_arch.split(":")[0].strip().lower()
|
||||
if gfx_id in _ROCM_GFX_OVERRIDES:
|
||||
override = _ROCM_GFX_OVERRIDES[gfx_id]
|
||||
os.environ["HSA_OVERRIDE_GFX_VERSION"] = override
|
||||
logger.info("ROCm: auto-set HSA_OVERRIDE_GFX_VERSION=%s for %s (%s)",
|
||||
override, device_name, gfx_id)
|
||||
target = ROCM_GFX_OVERRIDES.get(gfx_id)
|
||||
if not target:
|
||||
return
|
||||
arch_list = {a.split(":")[0].strip().lower() for a in build_arch_list(torch)}
|
||||
if not arch_list:
|
||||
# Metadata unavailable — an UNKNOWN build, not a confirmed
|
||||
# mismatch. Remapping on a guess could push a natively-supported
|
||||
# GPU onto foreign kernels, so fail open and change nothing.
|
||||
logger.debug(
|
||||
"ROCm: no arch list from this torch build; leaving "
|
||||
"HSA_OVERRIDE_GFX_VERSION unset for %s (%s)", device_name, gfx_id,
|
||||
)
|
||||
return
|
||||
if gfx_id in arch_list:
|
||||
logger.info("ROCm: %s (%s) is natively supported by this build; "
|
||||
"no HSA_OVERRIDE_GFX_VERSION needed", device_name, gfx_id)
|
||||
return
|
||||
if target not in arch_list:
|
||||
# The remap target isn't in this build either — setting the
|
||||
# override would only change WHICH kernel is missing. Leave it
|
||||
# unset so check_device_compatibility() reports the real mismatch
|
||||
# and the CPU fallback engages.
|
||||
logger.warning(
|
||||
"ROCm: %s (%s) is unsupported by this build and its remap "
|
||||
"target %s is missing too — not setting "
|
||||
"HSA_OVERRIDE_GFX_VERSION.", device_name, gfx_id, target,
|
||||
)
|
||||
return
|
||||
override = hsa_override_for(target)
|
||||
os.environ["HSA_OVERRIDE_GFX_VERSION"] = override
|
||||
logger.info("ROCm: auto-set HSA_OVERRIDE_GFX_VERSION=%s (%s) for %s (%s)",
|
||||
override, target, device_name, gfx_id)
|
||||
except Exception as e:
|
||||
logger.debug("ROCm GFX auto-config skipped: %s", e)
|
||||
|
||||
|
||||
def check_device_compatibility():
|
||||
"""Check if PyTorch supports the current GPU's compute capability.
|
||||
"""Check if PyTorch supports the current GPU's architecture.
|
||||
|
||||
Returns (compatible, warning_message). Compatible is True if OK or
|
||||
no discrete GPU is present.
|
||||
no discrete GPU is present. The arch comparison itself lives in
|
||||
``core.device_caps.arch_unsupported()`` — shared with the probe, and
|
||||
CUDA/ROCm-aware (a ROCm build lists ``gfx…``, not ``sm_…`` — #1228).
|
||||
"""
|
||||
from core.device_caps import arch_unsupported
|
||||
|
||||
torch = _lazy_torch()
|
||||
if not torch.cuda.is_available():
|
||||
return True, None
|
||||
mismatch = arch_unsupported(torch)
|
||||
if mismatch is None:
|
||||
return True, None
|
||||
device_arch, arch_list = mismatch
|
||||
try:
|
||||
major, minor = torch.cuda.get_device_capability(0)
|
||||
device_name = torch.cuda.get_device_name(0)
|
||||
sm_tag = f"sm_{major}{minor}"
|
||||
arch_list = getattr(torch.cuda, "_get_arch_list", lambda: [])()
|
||||
if arch_list:
|
||||
compute_tag = f"compute_{major}{minor}"
|
||||
if sm_tag not in arch_list and compute_tag not in arch_list:
|
||||
return False, (
|
||||
f"{device_name} (compute capability {major}.{minor} / {sm_tag}) "
|
||||
f"is not supported by this PyTorch build. "
|
||||
f"Supported architectures: {', '.join(arch_list)}. "
|
||||
f"Try: pip install torch --index-url https://download.pytorch.org/whl/nightly/cu128"
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return True, None
|
||||
device_name = "GPU"
|
||||
if getattr(getattr(torch, "version", None), "hip", None) is not None:
|
||||
return False, (
|
||||
f"{device_name} ({device_arch}) is not supported by this ROCm "
|
||||
f"PyTorch build. Supported architectures: {', '.join(arch_list)}. "
|
||||
f"Set HSA_OVERRIDE_GFX_VERSION to the closest supported target "
|
||||
f"(e.g. 11.0.0 for a gfx11xx card) or install a ROCm build that "
|
||||
f"lists {device_arch}."
|
||||
)
|
||||
return False, (
|
||||
f"{device_name} ({device_arch}) is not supported by this PyTorch build. "
|
||||
f"Supported architectures: {', '.join(arch_list)}. "
|
||||
f"Try: pip install torch --index-url "
|
||||
f"https://download.pytorch.org/whl/nightly/cu128"
|
||||
)
|
||||
|
||||
|
||||
def get_best_device():
|
||||
@@ -951,14 +1024,23 @@ def should_preload_tts_asr() -> bool:
|
||||
|
||||
|
||||
def _is_incomplete_cache_error(exc: BaseException) -> bool:
|
||||
"""True when `exc` is the truncated-HF-cache class (#352 / #581).
|
||||
"""True when `exc` is the truncated-HF-cache class (#352 / #581 / #1273).
|
||||
|
||||
transformers raises an OSError whose message contains "does not appear to
|
||||
have a file named …" when the on-disk snapshot has config/tokenizer files
|
||||
but no weight shard — the signature of an interrupted download. We match on
|
||||
that phrase (stable across transformers 4.x/5.x) rather than the error type,
|
||||
since the same OSError type covers unrelated I/O failures."""
|
||||
return "does not appear to have a file named" in str(exc)
|
||||
transformers raises an OSError when the on-disk snapshot has config and
|
||||
tokenizer files but no weight shard — the signature of an interrupted
|
||||
download. We match on the message (stable across transformers 4.x/5.x)
|
||||
rather than the error type, since the same OSError type covers unrelated
|
||||
I/O failures.
|
||||
|
||||
There are TWO wordings, and this used to match only the first, so a
|
||||
half-written repo whose *subfolder* failed to load (#1273:
|
||||
"Error no file named model.safetensors, … found in directory
|
||||
…/snapshots/<rev>/audio_tokenizer") got neither the automatic repair nor
|
||||
an actionable message — just a raw 500. `core.failure` owns the phrase
|
||||
list so the heal and the error text can't drift apart."""
|
||||
from core.failure import is_incomplete_cache_message
|
||||
|
||||
return is_incomplete_cache_message(str(exc))
|
||||
|
||||
|
||||
def _hf_offline() -> bool:
|
||||
|
||||
+121
-22
@@ -106,27 +106,87 @@ def _is_closed_client_error(e) -> bool:
|
||||
|
||||
def _retry_once_with_fresh_hf_client(loader, what: str):
|
||||
"""Run ``loader()`` — a model constructor that may download from the HF
|
||||
Hub on first use. On the specific closed-client failure above, reset the
|
||||
hub's shared client and retry exactly ONCE. Any other failure (and a
|
||||
repeat closed-client failure) propagates untouched, where the generation
|
||||
error classifier labels it as a network problem (#880)."""
|
||||
try:
|
||||
return loader()
|
||||
except Exception as e:
|
||||
if not _is_closed_client_error(e):
|
||||
raise
|
||||
logger.warning(
|
||||
"%s: HF Hub httpx client was closed mid-download (%s); "
|
||||
"retrying once with a fresh client.", what, e,
|
||||
)
|
||||
Hub on first use — retrying transient download failures.
|
||||
|
||||
Two failure shapes are retried, with deliberately different budgets:
|
||||
|
||||
* the httpx **closed-client** lifecycle error (#880) — retried exactly
|
||||
ONCE, after resetting the hub's shared session. It's a client-state bug,
|
||||
not a network condition: if a fresh session hits it again, repeating
|
||||
won't help, and #880 chose to surface it rather than loop.
|
||||
* any **transient download** failure ``core.failure
|
||||
.is_hf_connectivity_error`` recognises — refused/reset connections, DNS,
|
||||
timeouts, and (#1224) a truncated body ("peer closed connection without
|
||||
sending complete message body"). A multi-GB model that dies at 90% is
|
||||
the single most retry-worthy failure in the path, so this gets the full
|
||||
bounded budget. The HF cache is resumable (correctly-sized blobs are
|
||||
skipped by hash), so each retry continues rather than restarting.
|
||||
|
||||
Anything unrecognised propagates untouched, where the generation error
|
||||
classifier labels it.
|
||||
"""
|
||||
from core.failure import is_hf_connectivity_error
|
||||
|
||||
attempts = max(1, _int_env("OMNIVOICE_MODEL_LOAD_RETRIES", 3))
|
||||
backoff = max(0.0, _float_env("OMNIVOICE_MODEL_LOAD_BACKOFF_S", 2.0))
|
||||
client_reset_used = False
|
||||
attempt = 0
|
||||
while True:
|
||||
try:
|
||||
from huggingface_hub.utils import close_session
|
||||
close_session()
|
||||
except Exception: # pragma: no cover — hub too old / API renamed
|
||||
return loader()
|
||||
except Exception as e:
|
||||
if _is_closed_client_error(e):
|
||||
if client_reset_used:
|
||||
raise # #880: single-shot — a second one is not transient
|
||||
client_reset_used = True
|
||||
logger.warning(
|
||||
"%s: HF Hub httpx client was closed mid-download (%s); "
|
||||
"retrying once with a fresh client.", what, e,
|
||||
)
|
||||
try:
|
||||
from huggingface_hub.utils import close_session
|
||||
close_session()
|
||||
except Exception: # pragma: no cover — hub too old / renamed
|
||||
logger.warning(
|
||||
"%s: couldn't reset the HF Hub client; retrying anyway.",
|
||||
what,
|
||||
)
|
||||
# Deliberately does NOT consume a download attempt: the two
|
||||
# budgets are independent, and letting the session reset eat
|
||||
# one left a resumable multi-GB download a retry short of its
|
||||
# configured budget (#1224 review).
|
||||
continue # immediate — nothing to back off from
|
||||
attempt += 1
|
||||
if not is_hf_connectivity_error(str(e)) or attempt >= attempts:
|
||||
raise
|
||||
logger.warning(
|
||||
"%s: couldn't reset the HF Hub client; retrying anyway.", what,
|
||||
"%s: model download failed (%s); retrying (attempt %d/%d). "
|
||||
"Already-downloaded files are reused.",
|
||||
what, e, attempt, attempts,
|
||||
)
|
||||
return loader()
|
||||
if backoff:
|
||||
import time as _time
|
||||
_time.sleep(backoff * attempt)
|
||||
|
||||
|
||||
def _int_env(name: str, default: int) -> int:
|
||||
try:
|
||||
return int(os.environ.get(name, default))
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def _float_env(name: str, default: float) -> float:
|
||||
try:
|
||||
value = float(os.environ.get(name, default))
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
# inf/nan parse fine and then poison the caller: `sleep(inf)` raises
|
||||
# OverflowError, turning a retryable download failure into an unrelated
|
||||
# crash that hides the original error (#1224 review).
|
||||
if value != value or value in (float("inf"), float("-inf")):
|
||||
return default
|
||||
return value
|
||||
|
||||
|
||||
# ── Protocol ────────────────────────────────────────────────────────────────
|
||||
@@ -222,6 +282,22 @@ class TTSBackend(ABC):
|
||||
#: not enforced — actual device selection lives in the engine's loader.
|
||||
gpu_compat: tuple[str, ...] = ("cpu",)
|
||||
|
||||
#: Approximate VRAM (GB) the engine needs to render comfortably on a
|
||||
#: dedicated GPU. Metadata, like ``gpu_compat`` — never enforced, because a
|
||||
#: hard refuse would block hosts that would actually cope (drivers page to
|
||||
#: system RAM, and a short input can fit where a long one won't).
|
||||
#:
|
||||
#: What it IS for: telling the user BEFORE they wait (#1226/#1222). Two
|
||||
#: users on 4 GB cards (GTX 1650 Ti, Quadro P2000) ran the `omnivoice`
|
||||
#: engine, waited out the full compute budget, and were told the job "was
|
||||
#: too heavy for the available compute" — after the fact, with no hint
|
||||
#: that their card was under-provisioned for the engine they'd picked.
|
||||
#: Routing showed a clean green "accelerated" the whole time, because
|
||||
#: family membership was the only thing anything checked.
|
||||
#:
|
||||
#: 0 means "no meaningful floor" (CPU-class engines) and never warns.
|
||||
min_vram_gb: float = 0.0
|
||||
|
||||
@abstractmethod
|
||||
def generate(
|
||||
self,
|
||||
@@ -439,6 +515,15 @@ class OmniVoiceBackend(TTSBackend):
|
||||
id = "omnivoice"
|
||||
display_name = "OmniVoice (600 languages, zero-shot)"
|
||||
gpu_compat = ("cuda", "mps", "cpu")
|
||||
# Derived from the pool's own per-job budget (_GPU_VRAM_PER_JOB_GB = 5.0 in
|
||||
# model_manager, itself measured from the ~1.6 GB forward + autoregressive
|
||||
# decode and the co-loaded WhisperX on the clone path), plus room for the
|
||||
# resident weights. Below this the driver pages to system RAM and a render
|
||||
# that should take seconds runs for minutes — which is precisely what the
|
||||
# 4 GB reporters in #1226/#1222 hit. Deliberately the only engine with a
|
||||
# floor: the rest have no measured figure, and inventing one would put a
|
||||
# confident number in the UI that nothing backs.
|
||||
min_vram_gb = 6.0
|
||||
|
||||
def __init__(self, model=None):
|
||||
# The live OmniVoice instance. Reuses the singleton owned by
|
||||
@@ -749,7 +834,12 @@ class VoxCPM2Backend(TTSBackend):
|
||||
from voxcpm import VoxCPM # type: ignore[import-not-found]
|
||||
checkpoint = os.environ.get("OMNIVOICE_VOXCPM_MODEL", "openbmb/VoxCPM2")
|
||||
logger.info("Loading VoxCPM2 from %s", checkpoint)
|
||||
self._model = VoxCPM.from_pretrained(checkpoint, load_denoiser=False)
|
||||
# #1224: this first-use download is multi-GB. Unretried, a truncated
|
||||
# body at 90% aborted the load outright.
|
||||
self._model = _retry_once_with_fresh_hf_client(
|
||||
lambda: VoxCPM.from_pretrained(checkpoint, load_denoiser=False),
|
||||
"VoxCPM2",
|
||||
)
|
||||
|
||||
def generate(self, text, **kw) -> torch.Tensor:
|
||||
self._ensure_loaded()
|
||||
@@ -884,7 +974,10 @@ class MossTTSNanoBackend(TTSBackend):
|
||||
"OMNIVOICE_MOSS_TTS_MODEL", "OpenMOSS-Team/MOSS-TTS-Nano"
|
||||
)
|
||||
logger.info("Loading MOSS-TTS-Nano from %s", checkpoint)
|
||||
self._model = MossTTSNano.from_pretrained(checkpoint, trust_remote_code=True)
|
||||
self._model = _retry_once_with_fresh_hf_client(
|
||||
lambda: MossTTSNano.from_pretrained(checkpoint, trust_remote_code=True),
|
||||
"MOSS-TTS-Nano",
|
||||
)
|
||||
|
||||
def generate(self, text, **kw) -> torch.Tensor:
|
||||
self._ensure_loaded()
|
||||
@@ -2020,7 +2113,10 @@ def list_backends() -> list[dict]:
|
||||
"isolation_mode": isolation,
|
||||
"gpu_compat": list(gpu_compat),
|
||||
# effective_device / routing_status / routing_reason (scrubbed):
|
||||
**routing_fields(gpu_compat, caps),
|
||||
"min_vram_gb": getattr(cls, "min_vram_gb", 0.0) or None,
|
||||
# effective_device / routing_status / routing_reason (scrubbed);
|
||||
# the reason now also carries the under-provisioned-GPU caveat.
|
||||
**routing_fields(gpu_compat, caps, getattr(cls, "min_vram_gb", 0.0)),
|
||||
})
|
||||
# #981: mlx-audio multiplexes 7+ curated models behind one backend id
|
||||
# — surface the roster + the currently-active pick so Settings can
|
||||
@@ -2266,7 +2362,10 @@ async def resolve_generation_backend(
|
||||
|
||||
from core.device_caps import detect_host_caps
|
||||
from services.engine_routing import resolve_routing
|
||||
routing = resolve_routing(getattr(backend_cls, "gpu_compat", ("cpu",)), detect_host_caps())
|
||||
routing = resolve_routing(
|
||||
getattr(backend_cls, "gpu_compat", ("cpu",)), detect_host_caps(),
|
||||
getattr(backend_cls, "min_vram_gb", 0.0),
|
||||
)
|
||||
if routing["routing_status"] == "unavailable":
|
||||
raise ValueError(routing["routing_reason"])
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -90,12 +90,12 @@ There's also a Compose file in the repo with `cpu` / `gpu` / `rocm` profiles
|
||||
|-----|--------------|
|
||||
| `:latest` | **Rolling preview** — latest commit on `main`, at or ahead of the last release. This is the preview channel; pin `:stable` for production. |
|
||||
| `:stable` | Most recent versioned release (updated on every `v*` git tag) |
|
||||
| `:0.3.22` | Exact release version |
|
||||
| `:0.3` | Latest patch within the `0.3` minor |
|
||||
| `:0.4.1` | Exact release version |
|
||||
| `:0.4` | Latest patch within the `0.4` minor |
|
||||
| `:main` | Alias of the same rolling `main` build as `:latest` |
|
||||
| `:sha-xxxxxxx` | A specific commit (produced by manual workflow dispatch) |
|
||||
| `:rocm` | **AMD GPU (ROCm) build** of the rolling preview — the ROCm analogue of `:latest` |
|
||||
| `:stable-rocm`, `:0.3.22-rocm`, `:0.3-rocm`, `:sha-xxxxxxx-rocm` | ROCm builds of the corresponding tags above |
|
||||
| `:stable-rocm`, `:0.4.1-rocm`, `:0.4-rocm`, `:sha-xxxxxxx-rocm` | ROCm builds of the corresponding tags above |
|
||||
|
||||
Preview builds always come from `main` and never version-sort below `:stable`,
|
||||
so upgrades flow naturally. The same images and tags
|
||||
|
||||
+1
-1
@@ -95,7 +95,7 @@ bug to fix immediately, not backlog.
|
||||
|
||||
| Channel | Source | Produced by | How to verify |
|
||||
|---|---|---|---|
|
||||
| GitHub Release: installers + signed `latest.json` (**Stable** updater channel) | the `vX.Y.Z` tag | `release.yml` on tag push | Release page has dmg (arm+intel), msi/exe, AppImage/deb, `latest.json`; body = the CHANGELOG section, not the auto-generated fallback |
|
||||
| GitHub Release: installers + signed `latest.json` (**Stable** updater channel) | the `vX.Y.Z` tag | `release.yml` on tag push | Release page has dmg (arm+intel), msi/exe, AppImage/deb, `latest.json`; body = the CHANGELOG section (not the auto-generated fallback), followed by per-platform checksums and a **Contributors** avatar strip (owner + every PR author for the tag — the `contributors-strip` job) |
|
||||
| **Preview** updater channel (rolling `preview` prerelease) | **`main` only** | `release.yml` nightly cron / manual dispatch | preview `latest.json` stamps `X.Y.Z-N` and semver-sorts above stable |
|
||||
| GHCR CUDA image: `:X.Y.Z`, `:X.Y`, `:stable` | the tag | `docker.yml` on tag push | `docker manifest inspect ghcr.io/debpalash/omnivoice-studio:X.Y.Z` |
|
||||
| GHCR ROCm image: `:X.Y.Z-rocm`, `:X.Y-rocm`, `:stable-rocm` | the tag | `docker.yml` on tag push | same, with `-rocm` suffix |
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 51 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 145 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 123 KiB |
+20
-11
@@ -13,12 +13,12 @@ and [`palashdeb/omnivoice-studio` on Docker Hub](https://hub.docker.com/r/palash
|
||||
> |-----|--------------|
|
||||
> | `:latest` | **Rolling preview** — latest commit on `main`, at or ahead of the last release. This is the preview channel; pin `:stable` for production. |
|
||||
> | `:stable` | Most recent versioned release (updated on every `v*` git tag) |
|
||||
> | `:0.3.22` | Exact release version |
|
||||
> | `:0.3` | Latest patch within the 0.3 minor |
|
||||
> | `:0.4.1` | Exact release version |
|
||||
> | `:0.4` | Latest patch within the 0.4 minor |
|
||||
> | `:main` | Alias of the same rolling `main` build as `:latest` |
|
||||
> | `:sha-xxxxxxx` | Specific commit (produced by manual workflow dispatch) |
|
||||
> | `:rocm` | **AMD GPU (ROCm) build** of the rolling preview — the ROCm analogue of `:latest` |
|
||||
> | `:stable-rocm`, `:0.3.22-rocm`, `:0.3-rocm`, `:sha-xxxxxxx-rocm` | ROCm builds of the corresponding CUDA tags above |
|
||||
> | `:stable-rocm`, `:0.4.1-rocm`, `:0.4-rocm`, `:sha-xxxxxxx-rocm` | ROCm builds of the corresponding CUDA tags above |
|
||||
>
|
||||
> Versioning rule: preview builds always come from `main` and never
|
||||
> version-sort below `:stable` — upgrades flow naturally.
|
||||
@@ -89,15 +89,22 @@ PublishPort=127.0.0.1:3900:3900
|
||||
Volume=omnivoice-data:/app/omnivoice_data
|
||||
```
|
||||
|
||||
Release pins exist too: `:stable-rocm`, `:0.3.22-rocm`, `:0.3-rocm` mirror
|
||||
Release pins exist too: `:stable-rocm`, `:0.4.1-rocm`, `:0.4-rocm` mirror
|
||||
the CUDA tags exactly.
|
||||
|
||||
> **RDNA3 consumer cards (RX 7900 XTX/XT, gfx1100):** the backend auto-sets
|
||||
> `HSA_OVERRIDE_GFX_VERSION` for consumer GFX IDs missing from ROCm's official
|
||||
> support matrix, so try without any override first. If the GPU still isn't
|
||||
> detected, force it explicitly with `-e HSA_OVERRIDE_GFX_VERSION=11.0.0`
|
||||
> **Consumer cards and APUs (RX 6000/7000, Strix Point/Halo):** the backend
|
||||
> auto-sets `HSA_OVERRIDE_GFX_VERSION` when — and only when — your card's GFX
|
||||
> ID is missing from the shipped ROCm build's architecture list, so try
|
||||
> without any override first. Overriding a natively-supported GPU (gfx1151 on
|
||||
> ROCm 7.x, for example) only forces it onto foreign kernels. If the GPU still
|
||||
> isn't used, force it explicitly with `-e HSA_OVERRIDE_GFX_VERSION=11.0.0`
|
||||
> (user-set on the container — it is deliberately **not** baked into the
|
||||
> image, because the right value depends on your card).
|
||||
> image, because the right value depends on your card); a value you set is
|
||||
> always respected as-is.
|
||||
>
|
||||
> **Rootless / non-root hosts:** if `/dev/kfd` is group-owned, the container
|
||||
> user needs those groups too — add `--group-add` for your host's `render` and
|
||||
> `video` GIDs (`getent group render video`).
|
||||
|
||||
Verify the container sees the GPU:
|
||||
|
||||
@@ -107,8 +114,10 @@ docker exec omnivoice python3 -c \
|
||||
```
|
||||
|
||||
(ROCm-built PyTorch reports through `torch.cuda.*` — `True` plus your card's
|
||||
name means GPU acceleration is active. The Settings → System panel shows the
|
||||
same device.)
|
||||
name means torch can see the GPU.) That check alone isn't proof the app is
|
||||
using it: **Settings → System** shows the device OmniVoice actually resolved.
|
||||
If it reads `cpu` while the command above prints `True`, the backend log line
|
||||
starting `Falling back to CPU:` names the architecture mismatch it hit.
|
||||
|
||||
## Docker Compose (recommended)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -170,5 +170,5 @@ Setup questions get answered in
|
||||
[Discord](https://discord.gg/bzQavDfVV9) (usually within hours), bugs
|
||||
go to
|
||||
[GitHub Issues](https://github.com/debpalash/OmniVoice-Studio/issues)
|
||||
— see [SUPPORT.md](../../SUPPORT.md) for what to include. Welcome
|
||||
— see [SUPPORT.md](../../.github/SUPPORT.md) for what to include. Welcome
|
||||
over.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "omnivoice-studio",
|
||||
"version": "0.4.0",
|
||||
"version": "0.4.2",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0-only",
|
||||
"type": "module",
|
||||
|
||||
Generated
+1
-1
@@ -2941,7 +2941,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "omnivoice-studio"
|
||||
version = "0.4.0"
|
||||
version = "0.4.2"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"dirs-next",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -52,6 +52,11 @@ interface EngineBackend {
|
||||
last_error?: string | null;
|
||||
isolation_mode?: 'in-process' | 'subprocess';
|
||||
gpu_compat?: GPUTarget[];
|
||||
// Approximate VRAM (GB) the engine wants on a dedicated GPU; null when the
|
||||
// engine declares no meaningful floor (#1226). Advisory metadata — when the
|
||||
// host has less, `routing_reason` carries the caveat and the matrix renders
|
||||
// it under an otherwise-accelerated row.
|
||||
min_vram_gb?: number | null;
|
||||
// Routing (#21) — the device this engine uses on this machine + why.
|
||||
effective_device?: EffectiveDevice;
|
||||
routing_status?: RoutingStatus;
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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": "جرّب \"إعادة المحاولة\" من الإعدادات → السجلات → الواجهة الخلفية. وإذا فشلت مجددًا، فإن \"تنظيف وإعادة المحاولة\" يعيد بناء البيئة من الصفر."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 l’application 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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "सेटिंग्स → लॉग → बैकएंड में \"पुनः प्रयास\" आज़माएँ। फिर भी विफल हो तो \"साफ़ करें और पुनः प्रयास\" वातावरण को नए सिरे से बनाता है।"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 l’app 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 l’aggiornamento.",
|
||||
"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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "設定 → ログ → バックエンド の「再試行」をお試しください。再度失敗する場合は「クリーンして再試行」で環境を一から再構築します。"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "설정 → 로그 → 백엔드에서 \"다시 시도\"를 눌러 보세요. 다시 실패하면 \"정리 후 다시 시도\"가 환경을 처음부터 다시 구성합니다."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "Попробуйте «Повторить» в Настройки → Журналы → Бэкенд. Если снова не выйдет, «Очистить и повторить» пересоберёт окружение с нуля."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "ลอง \"ลองใหม่\" ใน การตั้งค่า → บันทึก → แบ็กเอนด์ หากยังล้มเหลว \"ล้างและลองใหม่\" จะสร้างสภาพแวดล้อมขึ้นใหม่ทั้งหมด"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "Спробуйте «Повторити» в Налаштування → Журнали → Бекенд. Якщо знову не вдасться, «Очистити й повторити» перебудує середовище з нуля."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "请在 设置 → 日志 → 后端 中尝试“重试”。若再次失败,“清理并重试”会从头重建运行环境。"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "請在 設定 → 記錄 → 後端 中嘗試「重試」。若再次失敗,「清理並重試」會從頭重建執行環境。"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,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`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;/);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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*=/);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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 ' +
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
@@ -154,12 +154,23 @@
|
||||
"os.makedirs(os.path.join(REPO_DIR, \".venv\"), exist_ok=True)\n",
|
||||
"run([sys.executable, os.path.join(REPO_DIR, \"scripts\", \"setup.py\")], what=\"cuDNN 8 compat setup\")\n",
|
||||
"\n",
|
||||
"# 2g. Sanity check in a fresh interpreter (this kernel may hold a stale torch)\n",
|
||||
"# 2g. Sanity check in a fresh interpreter (this kernel may hold a stale torch).\n",
|
||||
"# Imports the backend's own model stack, not just torch — Colab's system\n",
|
||||
"# Python mixes preinstalled and freshly-resolved wheels, and a torchaudio or\n",
|
||||
"# transformers that can't load together only shows up when the model module is\n",
|
||||
"# imported (#1229). Catching it here beats a 5-minute health timeout in cell 5.\n",
|
||||
"run([sys.executable, \"-c\",\n",
|
||||
" \"import torch, torchaudio, uvicorn, fastapi; \"\n",
|
||||
" \"print(f'Install OK - torch {torch.__version__}, \"\n",
|
||||
" \"CUDA available: {torch.cuda.is_available()}')\"],\n",
|
||||
" what=\"import sanity check\")\n"
|
||||
" # Versions FIRST: if the model-stack import below fails, the cell output\n",
|
||||
" # still shows what was actually installed, which is the single most\n",
|
||||
" # useful line for diagnosing a Colab environment (#1229).\n",
|
||||
" \"import torch, torchaudio, uvicorn, fastapi, transformers; \"\n",
|
||||
" \"print(f'torch {torch.__version__}, torchaudio {torchaudio.__version__}, \"\n",
|
||||
" \"transformers {transformers.__version__}, \"\n",
|
||||
" \"CUDA available: {torch.cuda.is_available()}'); \"\n",
|
||||
" \"from omnivoice.models.omnivoice import OmniVoice; \"\n",
|
||||
" \"from transformers import HiggsAudioV2TokenizerModel; \"\n",
|
||||
" \"print('Install OK - backend model stack imports cleanly')\"],\n",
|
||||
" cwd=REPO_DIR, what=\"import sanity check\")\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
+27
-6
@@ -19,10 +19,31 @@ try:
|
||||
except PackageNotFoundError:
|
||||
__version__ = "0.0.0"
|
||||
|
||||
from omnivoice.models.omnivoice import (
|
||||
OmniVoice,
|
||||
OmniVoiceConfig,
|
||||
OmniVoiceGenerationConfig,
|
||||
)
|
||||
|
||||
__all__ = ["OmniVoice", "OmniVoiceConfig", "OmniVoiceGenerationConfig"]
|
||||
|
||||
# The model exports are resolved lazily (PEP 562). Importing them here made
|
||||
# `omnivoice` an all-or-nothing package: `backend/api/routers/profiles.py` asks
|
||||
# only for two pure-stdlib helpers from `omnivoice.utils.voice_design`, and got
|
||||
# torch + torchaudio + transformers + the full model definition as a side
|
||||
# effect. Any breakage in that stack — a torchaudio transformers can't detect,
|
||||
# a flex_attention symbol a torch version doesn't have — then killed the entire
|
||||
# backend at import time, before FastAPI existed to classify the error: TTS,
|
||||
# dubbing, ASR and Settings all dead, with only a uvicorn traceback to go on
|
||||
# (#1229). Deferred, the same breakage surfaces inside the request that
|
||||
# actually needs a model, where `core.failure.classify()` attaches a repair
|
||||
# hint and everything else keeps working.
|
||||
#
|
||||
# `from omnivoice import OmniVoice` and `omnivoice.OmniVoice` behave exactly as
|
||||
# before; only the *timing* of the heavy import changes.
|
||||
|
||||
|
||||
def __getattr__(name):
|
||||
if name in __all__:
|
||||
from omnivoice.models import omnivoice as _m
|
||||
|
||||
return getattr(_m, name)
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
|
||||
|
||||
def __dir__():
|
||||
return sorted([*globals(), *__all__])
|
||||
|
||||
@@ -46,7 +46,6 @@ from transformers import (
|
||||
AutoFeatureExtractor,
|
||||
AutoModel,
|
||||
AutoTokenizer,
|
||||
HiggsAudioV2TokenizerModel,
|
||||
PretrainedConfig,
|
||||
PreTrainedModel,
|
||||
)
|
||||
@@ -183,6 +182,40 @@ class OmniVoiceConfig(PretrainedConfig):
|
||||
self.audio_codebook_weights = audio_codebook_weights
|
||||
|
||||
|
||||
def _audio_tokenizer_cls():
|
||||
"""Resolve ``transformers.HiggsAudioV2TokenizerModel`` at the point of use.
|
||||
|
||||
transformers exposes this class through its lazy module and gates it on the
|
||||
``torchaudio`` backend, so the *attribute access* — not the `transformers`
|
||||
import — is what raises when torchaudio is missing, ABI-mismatched with
|
||||
torch, or installed without discoverable distribution metadata (Colab's
|
||||
system Python, an interrupted `uv pip install`). Resolving it at module
|
||||
scope made that a fatal import error for the WHOLE backend: `backend/main.py`
|
||||
imports the profiles router → `omnivoice` → this module, so one optional
|
||||
audio tokenizer took down TTS, dubbing, ASR and Settings alike, before
|
||||
FastAPI existed to classify it. The user saw only uvicorn's traceback and a
|
||||
"Backend did not become healthy within 5 minutes" timeout (#1229).
|
||||
|
||||
Deferred here, the failure lands inside a request instead, where
|
||||
``core.failure.classify()`` maps it to ``TRANSFORMERS_IMPORT`` and attaches
|
||||
a repair hint — and every feature that doesn't need this tokenizer keeps
|
||||
working.
|
||||
"""
|
||||
try:
|
||||
from transformers import HiggsAudioV2TokenizerModel
|
||||
except Exception as e:
|
||||
raise ImportError(
|
||||
"Could not import module 'HiggsAudioV2TokenizerModel' — OmniVoice's "
|
||||
"audio tokenizer. transformers gates it on torchaudio, so this is "
|
||||
"almost always a torchaudio that is missing, broken, or mismatched "
|
||||
"with the installed torch/transformers. Reinstall them together "
|
||||
"(`uv pip install --reinstall torch torchaudio transformers`; add "
|
||||
"--system on Colab), then restart the backend. Underlying error: "
|
||||
f"{type(e).__name__}: {e}"
|
||||
) from e
|
||||
return HiggsAudioV2TokenizerModel
|
||||
|
||||
|
||||
def _resolve_snapshot_dir(checkpoint) -> str:
|
||||
"""Local snapshot directory for ``checkpoint`` (a local dir or a HF repo id).
|
||||
|
||||
@@ -318,7 +351,7 @@ class OmniVoice(PreTrainedModel):
|
||||
tokenizer_device = (
|
||||
"cpu" if str(model.device).startswith("mps") else model.device
|
||||
)
|
||||
model.audio_tokenizer = HiggsAudioV2TokenizerModel.from_pretrained(
|
||||
model.audio_tokenizer = _audio_tokenizer_cls().from_pretrained(
|
||||
audio_tokenizer_path, device_map=tokenizer_device
|
||||
)
|
||||
model.feature_extractor = AutoFeatureExtractor.from_pretrained(
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "omnivoice"
|
||||
version = "0.4.0"
|
||||
version = "0.4.2"
|
||||
description = "OmniVoice: Towards Omnilingual Zero-Shot Text-to-Speech with Diffusion Language Models"
|
||||
readme = "README.md"
|
||||
# Free and open-source under the GNU Affero General Public License v3 (see
|
||||
|
||||
@@ -168,6 +168,11 @@ def test_list_backends_shape(registry_sandbox):
|
||||
# True when services.sidecar_install can provision the engine in-app
|
||||
# (the Settings Install button keys off this).
|
||||
"one_click_install",
|
||||
# Approximate VRAM (GB) the engine wants on a dedicated GPU, or None
|
||||
# when it declares no measured floor (#1226). Advisory metadata: a host
|
||||
# below the floor gets a caveat in `routing_reason` BEFORE it spends
|
||||
# the full compute budget finding out its card is too small.
|
||||
"min_vram_gb",
|
||||
}
|
||||
mlx_audio_extra = {"curated_models", "active_model_id"}
|
||||
for entry in out:
|
||||
|
||||
@@ -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ébastien’s 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
|
||||
@@ -0,0 +1,192 @@
|
||||
"""#1225: `download: Unable to download video: [Errno 22] Invalid argument`.
|
||||
|
||||
A Windows user hit this on three consecutive URL ingests. Two things made it a
|
||||
dead end:
|
||||
|
||||
* `classify()` matched the generic `"errno 22"` rule (#763, written for the
|
||||
ASR path) BEFORE any download rule, so the attached hint told the user to
|
||||
check their system TEMP folder — while the failing directory is the job
|
||||
folder under the OmniVoice data dir. The one actionable instruction pointed
|
||||
at the wrong place.
|
||||
* The message named neither the target nor the reason, so nothing in it could
|
||||
distinguish a full drive from a read-only folder from an antivirus lock.
|
||||
|
||||
These tests pin the download-specific class, that the ASR path keeps its own,
|
||||
and the destination facts now attached to the failure.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
from core.failure import _HINTS, build_failure, classify, describe_path_target
|
||||
from services import dub_pipeline
|
||||
|
||||
|
||||
class _ReachedYtDlp(Exception):
|
||||
"""Raised in place of any real yt-dlp call — no test here may hit the
|
||||
network, and a test that silently does is a test that stopped testing."""
|
||||
|
||||
|
||||
def _block_network(monkeypatch):
|
||||
import yt_dlp
|
||||
|
||||
def _explode(*_a, **_k):
|
||||
raise _ReachedYtDlp("yt-dlp was invoked")
|
||||
|
||||
monkeypatch.setattr(dub_pipeline, "find_ffmpeg", lambda: None)
|
||||
monkeypatch.setattr(yt_dlp, "YoutubeDL", _explode)
|
||||
|
||||
|
||||
# ── classification ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_download_errno22_is_not_given_the_transcribe_hint():
|
||||
reason = "Unable to download video: [Errno 22] Invalid argument"
|
||||
assert classify(reason) == "VIDEO_DOWNLOAD_OS_ERROR"
|
||||
|
||||
fields = build_failure(OSError(reason), stage="download")
|
||||
assert "TEMP" not in fields["hint"]
|
||||
assert "data directory" in fields["hint"]
|
||||
|
||||
|
||||
def test_transcribe_errno22_keeps_its_own_class():
|
||||
"""#763's class must not be swallowed by the new download rule."""
|
||||
assert classify("[Errno 22] Invalid argument") == "OS_INVALID_ARGUMENT"
|
||||
assert "TEMP" in _HINTS["OS_INVALID_ARGUMENT"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"reason",
|
||||
[
|
||||
"ERROR: unable to open for writing: [Errno 13] Permission denied",
|
||||
"yt_dlp.utils.DownloadError: unable to rename file: [Errno 22] Invalid argument",
|
||||
],
|
||||
)
|
||||
def test_other_download_write_failures_share_the_class(reason):
|
||||
assert classify(reason) == "VIDEO_DOWNLOAD_OS_ERROR"
|
||||
|
||||
|
||||
def test_a_network_download_failure_is_still_network():
|
||||
"""The new rule must not steal transient failures — those DO retry."""
|
||||
reason = "Unable to download video: Connection reset by peer"
|
||||
assert classify(reason) == "VIDEO_DOWNLOAD_NETWORK"
|
||||
assert dub_pipeline._is_transient_download_error(OSError(reason))
|
||||
|
||||
|
||||
# ── destination diagnosis ────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_describe_path_target_reports_free_space(tmp_path):
|
||||
facts = describe_path_target(str(tmp_path / "original.mp4"))
|
||||
assert "MB free" in facts
|
||||
assert "does not exist" not in facts
|
||||
|
||||
|
||||
def test_describe_path_target_flags_a_missing_folder(tmp_path):
|
||||
facts = describe_path_target(str(tmp_path / "gone" / "original.mp4"))
|
||||
assert "does not exist" in facts
|
||||
|
||||
|
||||
def test_describe_path_target_never_raises():
|
||||
assert isinstance(describe_path_target("\x00not-a-path"), str)
|
||||
|
||||
|
||||
# ── the failure carries the destination ─────────────────────────────────
|
||||
|
||||
|
||||
def test_os_error_gains_the_destination_facts(tmp_path):
|
||||
exc = OSError("Unable to download video: [Errno 22] Invalid argument")
|
||||
described = dub_pipeline._with_target_facts(exc, str(tmp_path))
|
||||
|
||||
msg = str(described)
|
||||
assert str(tmp_path) in msg
|
||||
assert "MB free" in msg
|
||||
assert "retrying the same link won't help" in msg
|
||||
# Still classifies as the download class — the yt-dlp wording is preserved.
|
||||
assert classify(msg) == "VIDEO_DOWNLOAD_OS_ERROR"
|
||||
|
||||
|
||||
def test_network_failures_are_left_alone(tmp_path):
|
||||
exc = OSError("Unable to download video: Connection reset by peer")
|
||||
assert dub_pipeline._with_target_facts(exc, str(tmp_path)) is exc
|
||||
|
||||
|
||||
def test_exception_types_that_reject_a_message_still_get_the_text(tmp_path):
|
||||
import soundfile as sf
|
||||
|
||||
described = dub_pipeline._with_target_facts(
|
||||
sf.LibsndfileError(1), str(tmp_path)
|
||||
)
|
||||
# LibsndfileError takes an int code — the helper must not build one whose
|
||||
# str() raises. Either it declined to enrich, or it fell back to a type
|
||||
# that works; both are fine, an unprintable exception is not.
|
||||
assert isinstance(str(described), str)
|
||||
|
||||
|
||||
def test_enoent_download_failure_also_gets_the_destination_facts(tmp_path):
|
||||
"""Review finding (#1225): classify() covered ENOENT but the enrichment
|
||||
gate did not, so a job folder that vanished after preflight produced a
|
||||
disk-classified error that never named the folder — the one fact that
|
||||
makes it actionable. Both now read the same signature list."""
|
||||
exc = OSError("Unable to download video: [Errno 2] No such file or directory")
|
||||
described = dub_pipeline._with_target_facts(exc, str(tmp_path))
|
||||
assert str(tmp_path) in str(described)
|
||||
assert classify(str(described)) == "VIDEO_DOWNLOAD_OS_ERROR"
|
||||
|
||||
|
||||
def test_the_two_consumers_share_one_signature_list():
|
||||
"""A drift between "is this a disk problem?" (classify) and "should we name
|
||||
the folder?" (_with_target_facts) is what produced the finding above."""
|
||||
from core.failure import is_os_write_refusal
|
||||
|
||||
for reason in (
|
||||
"Unable to download video: [Errno 2] No such file or directory",
|
||||
"Unable to download video: [Errno 22] Invalid argument",
|
||||
"ERROR: unable to open for writing: [Errno 13] Permission denied",
|
||||
"Unable to download video: [Errno 28] No space left on device",
|
||||
):
|
||||
assert is_os_write_refusal(reason), reason
|
||||
assert classify(reason) == "VIDEO_DOWNLOAD_OS_ERROR", reason
|
||||
assert not is_os_write_refusal("Unable to download video: Connection reset by peer")
|
||||
|
||||
|
||||
def test_unwritable_destination_fails_before_yt_dlp_runs(tmp_path, monkeypatch):
|
||||
"""The preflight: don't start a download into a folder we can already see
|
||||
won't take the file."""
|
||||
job_dir = tmp_path / "job"
|
||||
job_dir.mkdir()
|
||||
# Patch the module object dub_pipeline actually holds — a string-path
|
||||
# patch of "core.failure…" misses it if anything in the suite reloaded
|
||||
# the module, and the test then falls through to a REAL network call.
|
||||
monkeypatch.setattr(
|
||||
dub_pipeline.failure, "describe_path_target",
|
||||
lambda _p: "the folder is not writable",
|
||||
)
|
||||
_block_network(monkeypatch)
|
||||
|
||||
with pytest.raises(OSError) as excinfo:
|
||||
dub_pipeline.yt_download_sync("https://example.com/v", str(job_dir))
|
||||
|
||||
msg = str(excinfo.value)
|
||||
assert str(job_dir) in msg
|
||||
assert "not writable" in msg
|
||||
# Review finding (#1225): the preflight message classified as NOTHING, so
|
||||
# the user got no hint at all — the very failure mode this PR fixes. It
|
||||
# must carry both an OS-refusal signature and download context.
|
||||
assert classify(msg) == "VIDEO_DOWNLOAD_OS_ERROR"
|
||||
assert "data directory" in build_failure(excinfo.value, stage="download")["hint"]
|
||||
|
||||
|
||||
def test_preflight_lets_a_healthy_folder_through(tmp_path, monkeypatch):
|
||||
"""A writable folder must not be blocked — the preflight only rejects what
|
||||
it can positively see is broken."""
|
||||
job_dir = tmp_path / "job"
|
||||
job_dir.mkdir()
|
||||
_block_network(monkeypatch)
|
||||
|
||||
# Reaching yt-dlp IS the pass condition: the preflight let it through.
|
||||
with pytest.raises(_ReachedYtDlp):
|
||||
dub_pipeline.yt_download_sync("https://example.com/v", str(job_dir))
|
||||
assert os.path.isdir(job_dir)
|
||||
@@ -0,0 +1,540 @@
|
||||
"""#1252/#1253: a dub ingest failed with the toast ``ingest: 'mgw39lx3'``.
|
||||
|
||||
Two reports, same reporter, same session, four `dub:upload` actions in a row.
|
||||
The entire user-facing error was eight characters of their own job id:
|
||||
|
||||
ingest: 'mgw39lx3'
|
||||
Error: ingest: 'mgw39lx3'
|
||||
|
||||
That is ``str(KeyError("mgw39lx3"))`` — the repr of a dict key. ``KeyError``
|
||||
does not put "a lookup failed" in its message, so `build_failure` faithfully
|
||||
reported a value with no explanation attached to it.
|
||||
|
||||
The lookup that failed: ``ingest_pipeline`` finished with a bare
|
||||
``_dub_jobs[job_id].update(...)``. Everything before it — demucs, scene
|
||||
detection, thumbnailing — takes minutes, and ``DELETE /dub/history/{id}`` pops
|
||||
the entry. Deleting an in-flight dub therefore raised ``KeyError`` from a
|
||||
pipeline that was, by then, doing exactly what it was told.
|
||||
|
||||
Both halves are covered: the crash no longer happens, and no exception whose
|
||||
``str()`` is a bare value can present itself to a user that way again.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
import time
|
||||
|
||||
from core.failure import build_failure, describe_exception
|
||||
from services import dub_pipeline
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clean_jobs():
|
||||
dub_pipeline._dub_jobs.clear()
|
||||
yield
|
||||
dub_pipeline._dub_jobs.clear()
|
||||
|
||||
|
||||
# ── the crash ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_merging_into_a_live_job_updates_it():
|
||||
dub_pipeline.put_job("job1", {"filename": "a.mp4", "scene_cuts": []})
|
||||
|
||||
assert dub_pipeline.merge_job("job1", {"scene_cuts": [1.0, 2.0]}) is True
|
||||
assert dub_pipeline._dub_jobs["job1"]["scene_cuts"] == [1.0, 2.0]
|
||||
assert dub_pipeline._dub_jobs["job1"]["filename"] == "a.mp4", "a merge, not a replace"
|
||||
|
||||
|
||||
def test_merging_into_a_deleted_job_reports_it_instead_of_raising():
|
||||
"""The #1252 moment: the user deleted the dub while it was still ingesting."""
|
||||
dub_pipeline.put_job("mgw39lx3", {"filename": "a.mp4"})
|
||||
dub_pipeline._dub_jobs.pop("mgw39lx3") # DELETE /dub/history/{id}
|
||||
|
||||
assert dub_pipeline.merge_job("mgw39lx3", {"scene_cuts": []}) is False
|
||||
|
||||
|
||||
def test_a_deleted_job_is_not_resurrected():
|
||||
"""`False` must mean "stop", not "insert it back" — the user deleted this
|
||||
on purpose, and re-adding it would put a phantom row back in history."""
|
||||
assert dub_pipeline.merge_job("gone", {"scene_cuts": []}) is False
|
||||
assert "gone" not in dub_pipeline._dub_jobs
|
||||
|
||||
|
||||
def test_the_ingest_pipeline_no_longer_blind_subscripts_the_job():
|
||||
"""The call site itself. A direct `_dub_jobs[job_id].update(` anywhere in
|
||||
the pipeline reintroduces exactly this KeyError."""
|
||||
import inspect
|
||||
|
||||
src = inspect.getsource(dub_pipeline.ingest_pipeline)
|
||||
assert "_dub_jobs[job_id].update(" not in src
|
||||
assert "merge_and_save_job(" in src
|
||||
|
||||
|
||||
# ── the message ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_a_keyerror_no_longer_presents_as_a_bare_key():
|
||||
"""The exact reason string the reporter saw."""
|
||||
failure = build_failure(KeyError("mgw39lx3"), stage="ingest")
|
||||
|
||||
assert failure["reason"] != "'mgw39lx3'"
|
||||
assert failure["error_class"] == "KeyError"
|
||||
assert "KeyError" in failure["reason"], "name what happened, not just the value"
|
||||
assert "mgw39lx3" in failure["reason"], "but keep the value — it is the only clue"
|
||||
|
||||
|
||||
def test_an_exception_with_no_message_at_all_still_names_itself():
|
||||
assert describe_exception(RuntimeError()) == "RuntimeError"
|
||||
assert describe_exception(ValueError(" ")) == "ValueError"
|
||||
|
||||
|
||||
def test_a_real_message_is_left_exactly_alone():
|
||||
"""The fix must not prefix class names onto errors that already read fine —
|
||||
every existing hint and classification matches on message text."""
|
||||
assert describe_exception(RuntimeError("CUDA out of memory")) == "CUDA out of memory"
|
||||
assert describe_exception(
|
||||
OSError("[Errno 28] No space left on device")
|
||||
) == "[Errno 28] No space left on device"
|
||||
|
||||
|
||||
def test_classification_still_works_through_the_wrapper():
|
||||
"""`build_failure` classifies on the raw text; adding a class prefix must
|
||||
not break the hint lookup for messages that do classify."""
|
||||
failure = build_failure(RuntimeError("No module named 'omnivoice'"), stage="startup")
|
||||
assert failure["docs_topic"] == "BROKEN_VENV"
|
||||
assert failure["hint"]
|
||||
|
||||
|
||||
# ── the delete race the first fix left open ──────────────────────────────
|
||||
|
||||
|
||||
def test_merge_and_save_are_one_step(monkeypatch):
|
||||
"""Review finding (#1252): merging and persisting as two steps leaves a
|
||||
window where the user deletes the dub in between — and the pending save
|
||||
then UPSERTs the row straight back, so a dub they deleted reappears."""
|
||||
saved = []
|
||||
monkeypatch.setattr(
|
||||
dub_pipeline, "save_job",
|
||||
lambda job_id, job, *a, **kw: saved.append(job_id),
|
||||
)
|
||||
dub_pipeline.put_job("job1", {"filename": "a.mp4"})
|
||||
|
||||
assert dub_pipeline.merge_and_save_job("job1", {"scene_cuts": [1.0]}) is True
|
||||
assert saved == ["job1"]
|
||||
assert dub_pipeline._dub_jobs["job1"]["scene_cuts"] == [1.0]
|
||||
|
||||
|
||||
def test_a_deleted_job_is_never_persisted(monkeypatch):
|
||||
saved = []
|
||||
monkeypatch.setattr(
|
||||
dub_pipeline, "save_job",
|
||||
lambda job_id, job, *a, **kw: saved.append(job_id),
|
||||
)
|
||||
|
||||
assert dub_pipeline.merge_and_save_job("gone", {"scene_cuts": []}) is False
|
||||
assert saved == [], "a withdrawn job must not reach the database"
|
||||
|
||||
|
||||
def test_a_delete_landing_mid_save_cannot_be_overtaken(monkeypatch):
|
||||
"""The race itself — asserted on ORDER, which is the only thing that
|
||||
distinguishes it.
|
||||
|
||||
Two earlier versions of this test were not tests. The first deleted the job
|
||||
before calling `merge_and_save_job`, so it merely re-checked the absent-job
|
||||
case. The second interleaved a real thread but asserted only *what*
|
||||
happened, not *when* — so splitting merge from save (the exact bug) still
|
||||
passed, because a save landing AFTER the delete looks identical to one
|
||||
landing before if you only check that both occurred.
|
||||
|
||||
The resurrection is precisely `save` completing after `delete`. So record
|
||||
the order and assert on it: with merge+save atomic under the lock, the
|
||||
purge cannot start until the save has finished, and the sequence is always
|
||||
save-then-delete. Split them and the purge is free to run first.
|
||||
"""
|
||||
import threading
|
||||
|
||||
order = []
|
||||
order_lock = threading.Lock()
|
||||
entered_save = threading.Event()
|
||||
purge_attempted = threading.Event()
|
||||
|
||||
def _slow_save(job_id, job, *a, **kw):
|
||||
entered_save.set()
|
||||
# Wait until the purge thread has actually reached its purge call, so
|
||||
# the two are genuinely in flight together, then hold a moment.
|
||||
purge_attempted.wait(timeout=2.0)
|
||||
time.sleep(0.05)
|
||||
with order_lock:
|
||||
order.append("save")
|
||||
|
||||
monkeypatch.setattr(dub_pipeline, "save_job", _slow_save)
|
||||
dub_pipeline.put_job("job1", {"filename": "a.mp4"})
|
||||
|
||||
def _delete_rows():
|
||||
with order_lock:
|
||||
order.append("delete")
|
||||
|
||||
def _purge():
|
||||
entered_save.wait(timeout=2.0)
|
||||
purge_attempted.set()
|
||||
dub_pipeline.purge_jobs(["job1"], delete_rows=_delete_rows)
|
||||
|
||||
purger = threading.Thread(target=_purge)
|
||||
purger.start()
|
||||
merged = dub_pipeline.merge_and_save_job("job1", {"scene_cuts": [1.0]})
|
||||
purge_attempted.set() # release the save if the purge never got that far
|
||||
purger.join(timeout=5.0)
|
||||
|
||||
assert merged is True, "the save started first, so it must complete"
|
||||
assert order == ["save", "delete"], (
|
||||
f"the write must never land after the delete — got {order}. "
|
||||
"'delete' first means the row was resurrected."
|
||||
)
|
||||
assert "job1" not in dub_pipeline._dub_jobs
|
||||
|
||||
|
||||
def test_a_purge_that_wins_the_race_stops_the_save_entirely(monkeypatch):
|
||||
"""The other ordering: purge first, then the pipeline reaches its save."""
|
||||
saved = []
|
||||
monkeypatch.setattr(
|
||||
dub_pipeline, "save_job", lambda job_id, job, *a, **kw: saved.append(job_id),
|
||||
)
|
||||
dub_pipeline.put_job("job1", {"filename": "a.mp4"})
|
||||
|
||||
dub_pipeline.purge_jobs(["job1"], delete_rows=lambda: None)
|
||||
|
||||
assert dub_pipeline.merge_and_save_job("job1", {"scene_cuts": []}) is False
|
||||
assert saved == [], "a withdrawn job must never reach the database"
|
||||
|
||||
|
||||
def test_the_create_checkpoints_are_atomic_too(monkeypatch):
|
||||
"""Review finding (#1252): the mid-pipeline put_job + save_job pairs were
|
||||
still unlocked, so a clear-history landing between them left a ghost row
|
||||
behind the purge."""
|
||||
import inspect
|
||||
|
||||
src = inspect.getsource(dub_pipeline.ingest_pipeline)
|
||||
assert "put_and_save_job(" in src
|
||||
assert "put_job(job_id," not in src, "the unlocked pair is the race"
|
||||
|
||||
saved = []
|
||||
monkeypatch.setattr(
|
||||
dub_pipeline, "save_job", lambda job_id, job, *a, **kw: saved.append(job_id),
|
||||
)
|
||||
dub_pipeline.put_and_save_job("job2", {"filename": "b.mp4"})
|
||||
assert saved == ["job2"]
|
||||
assert dub_pipeline._dub_jobs["job2"]["filename"] == "b.mp4"
|
||||
|
||||
|
||||
def test_clear_history_also_evicts_in_memory_jobs(monkeypatch):
|
||||
"""`DELETE /dub/history` deleted every row but evicted nothing from memory,
|
||||
so an in-flight job survived "clear history" outright and re-saved itself
|
||||
on completion."""
|
||||
import inspect
|
||||
|
||||
from api.routers import dub_core
|
||||
|
||||
src = inspect.getsource(dub_core.clear_dub_history)
|
||||
assert "purge_jobs" in src, "clear-all must evict memory too, not just rows"
|
||||
|
||||
|
||||
def test_the_ingest_pipeline_persists_atomically():
|
||||
import inspect
|
||||
|
||||
src = inspect.getsource(dub_pipeline.ingest_pipeline)
|
||||
assert "merge_and_save_job(" in src
|
||||
# The two-step form is what the race lived in.
|
||||
assert "save_job(job_id, get_job(" not in src
|
||||
|
||||
|
||||
# ── withdrawal survives a job's FIRST write ──────────────────────────────
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clean_tombstones():
|
||||
dub_pipeline._inflight_jobs.clear()
|
||||
dub_pipeline._withdrawn_jobs.clear()
|
||||
yield
|
||||
dub_pipeline._inflight_jobs.clear()
|
||||
dub_pipeline._withdrawn_jobs.clear()
|
||||
|
||||
|
||||
def test_clearing_history_mid_ingest_is_not_undone_by_the_next_checkpoint(monkeypatch):
|
||||
"""Review finding (#1252): dict membership can't express "withdrawn".
|
||||
|
||||
An ingest's FIRST persistence CREATES the entry, so an absent key means
|
||||
"not written yet" for a new job and "deleted" for an established one — two
|
||||
opposite instructions from one signal. A clear-history landing before that
|
||||
first checkpoint was therefore silently undone by the checkpoint recreating
|
||||
the row, and the run went on to persist its result into history the user
|
||||
had just cleared.
|
||||
"""
|
||||
saved = []
|
||||
monkeypatch.setattr(
|
||||
dub_pipeline, "save_job", lambda job_id, job, *a, **kw: saved.append(job_id),
|
||||
)
|
||||
|
||||
dub_pipeline.begin_ingest("job1")
|
||||
# Clear history BEFORE the job has ever been written. It appears in no row,
|
||||
# so `job_ids` is empty — only the in-flight sweep can catch it.
|
||||
dub_pipeline.purge_jobs([], delete_rows=lambda: None, include_inflight=True)
|
||||
|
||||
assert dub_pipeline.put_and_save_job("job1", {"filename": "a.mp4"}) is False
|
||||
assert saved == [], "the checkpoint must not recreate a cleared job"
|
||||
# ...and the terminal write stays refused too.
|
||||
assert dub_pipeline.merge_and_save_job("job1", {"scene_cuts": []}) is False
|
||||
|
||||
|
||||
def test_a_single_delete_also_withdraws_an_inflight_job(monkeypatch):
|
||||
saved = []
|
||||
monkeypatch.setattr(
|
||||
dub_pipeline, "save_job", lambda job_id, job, *a, **kw: saved.append(job_id),
|
||||
)
|
||||
dub_pipeline.begin_ingest("job1")
|
||||
dub_pipeline.put_and_save_job("job1", {"filename": "a.mp4"})
|
||||
saved.clear()
|
||||
|
||||
dub_pipeline.purge_jobs(["job1"], delete_rows=lambda: None)
|
||||
|
||||
assert dub_pipeline.put_and_save_job("job1", {"filename": "a.mp4"}) is False
|
||||
assert saved == []
|
||||
|
||||
|
||||
def test_a_normal_ingest_is_unaffected(monkeypatch):
|
||||
"""The gate must be invisible when nobody deletes anything — this runs on
|
||||
every import."""
|
||||
saved = []
|
||||
monkeypatch.setattr(
|
||||
dub_pipeline, "save_job", lambda job_id, job, *a, **kw: saved.append(job_id),
|
||||
)
|
||||
dub_pipeline.begin_ingest("job1")
|
||||
|
||||
assert dub_pipeline.put_and_save_job("job1", {"filename": "a.mp4"}) is True
|
||||
assert dub_pipeline.merge_and_save_job("job1", {"scene_cuts": [1.0]}) is True
|
||||
assert saved == ["job1", "job1"]
|
||||
|
||||
|
||||
def test_a_delete_during_a_RENDER_is_honoured(monkeypatch):
|
||||
"""The case Greptile actually reported, and the one an earlier version of
|
||||
this fix did not close.
|
||||
|
||||
A dub is imported ONCE and rendered many times, so the realistic delete
|
||||
lands during a render — long after its ingest ended. Scoping the tombstone
|
||||
to in-flight ingests looked right and protected almost nothing: the render's
|
||||
own `save_job` wrote the row straight back.
|
||||
"""
|
||||
written = []
|
||||
monkeypatch.setattr(
|
||||
dub_pipeline, "_persist_job", lambda *a, **kw: written.append(a[0]),
|
||||
)
|
||||
|
||||
# An ordinary saved dub whose import finished long ago.
|
||||
dub_pipeline.begin_ingest("old1")
|
||||
dub_pipeline.put_and_save_job("old1", {"filename": "a.mp4"})
|
||||
dub_pipeline.end_ingest("old1")
|
||||
written.clear()
|
||||
|
||||
# Deleted from history while a render is still running.
|
||||
dub_pipeline.purge_jobs(["old1"], delete_rows=lambda: None)
|
||||
# The render completes and persists, exactly as dub_generate.py does.
|
||||
dub_pipeline.save_job("old1", {"filename": "a.mp4", "dubbed_tracks": {"en": {}}})
|
||||
|
||||
assert written == [], "a render finishing after the delete must not revive it"
|
||||
|
||||
|
||||
def test_the_ingest_ending_is_not_an_un_delete(monkeypatch):
|
||||
"""`end_ingest` used to clear the tombstone, which is what opened the gap
|
||||
above — the ingest finishing is not the user un-deleting anything."""
|
||||
monkeypatch.setattr(dub_pipeline, "_persist_job", lambda *a, **kw: None)
|
||||
dub_pipeline.begin_ingest("job1")
|
||||
dub_pipeline.purge_jobs(["job1"], delete_rows=lambda: None)
|
||||
|
||||
dub_pipeline.end_ingest("job1")
|
||||
|
||||
assert "job1" in dub_pipeline._withdrawn_jobs
|
||||
assert dub_pipeline._inflight_jobs == set()
|
||||
|
||||
|
||||
def test_re_importing_the_same_id_revives_it(monkeypatch):
|
||||
"""The one thing that legitimately un-deletes a job."""
|
||||
written = []
|
||||
monkeypatch.setattr(
|
||||
dub_pipeline, "_persist_job", lambda *a, **kw: written.append(a[0]),
|
||||
)
|
||||
dub_pipeline.purge_jobs(["job1"], delete_rows=lambda: None)
|
||||
assert dub_pipeline.save_job("job1", {"filename": "a.mp4"}) is None
|
||||
assert written == []
|
||||
|
||||
dub_pipeline.begin_ingest("job1") # a deliberate re-import
|
||||
assert dub_pipeline.put_and_save_job("job1", {"filename": "a.mp4"}) is True
|
||||
assert written == ["job1"]
|
||||
|
||||
|
||||
def test_clearing_a_large_history_mid_render_does_not_evict_the_running_job(
|
||||
monkeypatch,
|
||||
):
|
||||
"""Review finding (#1252, Greptile P1): a count-bounded LRU is evictable by
|
||||
ordinary use.
|
||||
|
||||
`DELETE /dub/history` selects every row with no limit, so a user with a
|
||||
large history clearing it while a render is running would push that very
|
||||
job's marker out — and the render would then write it straight back. Age is
|
||||
the honest policy: what matters is how long ago the delete happened, not how
|
||||
many others happened to follow it.
|
||||
"""
|
||||
written = []
|
||||
monkeypatch.setattr(
|
||||
dub_pipeline, "_persist_job", lambda *a, **kw: written.append(a[0]),
|
||||
)
|
||||
|
||||
# One dub is mid-render; the user clears a history far larger than any
|
||||
# count cap would keep.
|
||||
# Deliberately larger than the size cap, so the cap is forced to choose —
|
||||
# which is exactly the situation that evicted a live marker before.
|
||||
everything = ["rendering"] + [
|
||||
f"old{i}" for i in range(dub_pipeline._WITHDRAWN_MAX + 500)
|
||||
]
|
||||
dub_pipeline.purge_jobs(everything, delete_rows=lambda: None)
|
||||
|
||||
dub_pipeline.save_job("rendering", {"filename": "a.mp4"})
|
||||
assert written == [], "the running job's withdrawal must survive the sweep"
|
||||
|
||||
|
||||
def test_markers_expire_by_age(monkeypatch):
|
||||
"""They are held for the life of the process otherwise, so something has to
|
||||
let them go — but it must be time, not volume."""
|
||||
import time as _time
|
||||
|
||||
base = _time.monotonic()
|
||||
monkeypatch.setattr(dub_pipeline.time, "monotonic", lambda: base)
|
||||
dub_pipeline.purge_jobs(["old"], delete_rows=lambda: None)
|
||||
assert "old" in dub_pipeline._withdrawn_jobs
|
||||
|
||||
# Well past the TTL, a later delete sweeps the stale one out.
|
||||
monkeypatch.setattr(
|
||||
dub_pipeline.time, "monotonic",
|
||||
lambda: base + dub_pipeline._WITHDRAWN_TTL_S + 1,
|
||||
)
|
||||
dub_pipeline.purge_jobs(["fresh"], delete_rows=lambda: None)
|
||||
|
||||
assert "old" not in dub_pipeline._withdrawn_jobs
|
||||
assert "fresh" in dub_pipeline._withdrawn_jobs
|
||||
|
||||
|
||||
def test_the_marker_map_cannot_grow_without_bound():
|
||||
"""The count cap is a memory backstop, not the policy."""
|
||||
for i in range(dub_pipeline._WITHDRAWN_MAX + 100):
|
||||
dub_pipeline.purge_jobs([f"job{i}"], delete_rows=lambda: None)
|
||||
|
||||
assert len(dub_pipeline._withdrawn_jobs) <= dub_pipeline._WITHDRAWN_MAX
|
||||
|
||||
|
||||
def test_the_pipeline_registers_and_releases_its_run():
|
||||
import inspect
|
||||
|
||||
src = inspect.getsource(dub_pipeline.ingest_pipeline)
|
||||
assert "begin_ingest(job_id)" in src
|
||||
assert "end_ingest(job_id)" in src, "a leaked tombstone would block a later run"
|
||||
# Released in `finally`, so a crash or cancel can't leak it.
|
||||
finally_block = src[src.rindex("finally:"):]
|
||||
assert "end_ingest(job_id)" in finally_block
|
||||
|
||||
|
||||
def test_clear_history_sweeps_inflight_jobs():
|
||||
import inspect
|
||||
|
||||
from api.routers import dub_core
|
||||
|
||||
src = inspect.getsource(dub_core.clear_dub_history)
|
||||
assert "include_inflight=True" in src, (
|
||||
"an ingest with no row yet appears in no id list — only the in-flight "
|
||||
"sweep can clear it"
|
||||
)
|
||||
|
||||
|
||||
# ── the gate is at the choke point, not in the callers ───────────────────
|
||||
|
||||
|
||||
def test_every_direct_save_path_honours_a_withdrawal(monkeypatch):
|
||||
"""Review finding (#1252, Greptile P1): gating only the ingest helpers left
|
||||
eight direct `save_job` call sites — across dub generate, translate, export
|
||||
and core — able to resurrect a dub the user deleted *mid-render*. Deleting
|
||||
during generation is at least as likely as deleting during import.
|
||||
|
||||
The gate now lives in `save_job` itself, so every caller inherits it and
|
||||
the ninth one cannot forget."""
|
||||
written = []
|
||||
monkeypatch.setattr(
|
||||
dub_pipeline, "_persist_job", lambda *a, **kw: written.append(a[0]),
|
||||
)
|
||||
|
||||
dub_pipeline.begin_ingest("job1")
|
||||
dub_pipeline.purge_jobs(["job1"], delete_rows=lambda: None)
|
||||
|
||||
# The shape every router uses: a bare save_job, no lock, no helper.
|
||||
dub_pipeline.save_job("job1", {"filename": "a.mp4"})
|
||||
|
||||
assert written == [], "a post-ingest save must not resurrect a deleted dub"
|
||||
|
||||
|
||||
def test_a_normal_save_still_writes(monkeypatch):
|
||||
written = []
|
||||
monkeypatch.setattr(
|
||||
dub_pipeline, "_persist_job", lambda *a, **kw: written.append(a[0]),
|
||||
)
|
||||
dub_pipeline.save_job("job1", {"filename": "a.mp4"})
|
||||
assert written == ["job1"]
|
||||
|
||||
|
||||
def test_the_lock_is_reentrant():
|
||||
"""`save_job` acquires the lock, and the atomic helpers call it while
|
||||
already holding it — a plain Lock would deadlock the backend here."""
|
||||
import threading
|
||||
|
||||
assert isinstance(dub_pipeline._dub_jobs_lock, type(threading.RLock()))
|
||||
|
||||
|
||||
def test_the_atomic_helpers_still_work_through_the_reentrant_path(monkeypatch):
|
||||
"""Guards the deadlock directly: if this hangs, the RLock regressed."""
|
||||
written = []
|
||||
monkeypatch.setattr(
|
||||
dub_pipeline, "_persist_job", lambda *a, **kw: written.append(a[0]),
|
||||
)
|
||||
dub_pipeline.begin_ingest("job1")
|
||||
assert dub_pipeline.put_and_save_job("job1", {"filename": "a.mp4"}) is True
|
||||
assert dub_pipeline.merge_and_save_job("job1", {"scene_cuts": [1.0]}) is True
|
||||
assert written == ["job1", "job1"]
|
||||
|
||||
|
||||
def test_eviction_order_does_not_depend_on_hash_seed():
|
||||
"""A `set` of target ids made eviction order depend on PYTHONHASHSEED — so
|
||||
which markers survived a cap-forced trim was luck. The test for the
|
||||
behaviour above passed locally and reddened main for that reason alone.
|
||||
|
||||
Same input, same surviving markers, every time.
|
||||
"""
|
||||
ids = [f"j{i}" for i in range(50)]
|
||||
|
||||
dub_pipeline._withdrawn_jobs.clear()
|
||||
dub_pipeline.purge_jobs(ids, delete_rows=lambda: None)
|
||||
first = list(dub_pipeline._withdrawn_jobs)
|
||||
|
||||
dub_pipeline._withdrawn_jobs.clear()
|
||||
dub_pipeline.purge_jobs(ids, delete_rows=lambda: None)
|
||||
assert list(dub_pipeline._withdrawn_jobs) == first == ids
|
||||
|
||||
|
||||
def test_a_purge_larger_than_the_cap_keeps_all_of_its_own_markers():
|
||||
"""The cap must never discard a marker the current purge just recorded:
|
||||
those are the newest and the likeliest to still be held. The bound is
|
||||
therefore `cap + one purge`, which is the honest guarantee."""
|
||||
dub_pipeline._withdrawn_jobs.clear()
|
||||
oversized = [f"x{i}" for i in range(dub_pipeline._WITHDRAWN_MAX + 750)]
|
||||
|
||||
dub_pipeline.purge_jobs(oversized, delete_rows=lambda: None)
|
||||
|
||||
assert len(dub_pipeline._withdrawn_jobs) == len(oversized)
|
||||
for job_id in oversized:
|
||||
assert job_id in dub_pipeline._withdrawn_jobs
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user