Compare commits

..
Author SHA1 Message Date
debpalash 553d91d0e3 docs(changelog): re-merge [Unreleased] after the main merge
Each PR appends to the same [Unreleased] block, so every merge after the first
conflicts there. Rebuilt from main's version with this branch's entries
re-inserted, rather than resolving the diff — which mangles the section
structure the changelog linter enforces.
2026-07-23 05:26:46 +05:30
154 changed files with 510 additions and 11475 deletions
-133
View File
@@ -1,133 +0,0 @@
---
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.
+1 -5
View File
@@ -98,11 +98,7 @@ reviews:
access accordingly; window and webview lifecycle on all three OSes;
child-process spawn/exit-code/stderr handling; no unwrap/expect on
user-controlled input; platform cfg blocks keep user-visible defaults
identical across macOS/Windows/Linux. The parity rule covers BEHAVIOUR,
not PERFORMANCE: hardware acceleration is host-dependent by design
(CUDA/MPS/DirectML, Triton availability, torch.compile), so an
optimization skipped where it cannot work is NOT a parity violation and
must not be reported as one.
identical across macOS/Windows/Linux.
- path: "tests/**/*.py"
instructions: >-
Review as a test-infrastructure engineer. Check: the test would fail
-6
View File
@@ -13,12 +13,6 @@ Thanks for your interest in improving OmniVoice Studio! This guide covers everyt
---
## Adding a TTS or ASR engine
New engines are hired for a **named job**, not added to a list — the bar, the current job map,
and the out-of-tree path are in [docs/engine-acceptance.md](../docs/engine-acceptance.md).
Read it before opening a proposal; the licence check in particular ends most of them.
## Development Setup
### Prerequisites
+5 -43
View File
@@ -83,14 +83,6 @@ jobs:
- name: Validate install docs against desktop-prod.sh
run: python scripts/validate-install-docs.py
# The AppImage launcher decides which WebKitGTK actually runs — the wrong
# answer is a permanently blank window on Linux (#56, #961, #1258), and
# the only place that logic is exercised is this shell harness. It had
# never been wired into CI, so its cases were a regression test nothing
# ran. Cheap (pure bash, stubs pkg-config) and it gates the class.
- name: AppImage launcher (AppRun) unit tests
run: bash frontend/src-tauri/appimage/AppRun.test.sh
# `backend/tests/` mounts routers on bare FastAPI apps (no heavy main
# import chain) with a hermetic data dir from its conftest.py. It no
# longer stubs sys.modules, so mixed sessions with tests/ are safe;
@@ -275,22 +267,7 @@ jobs:
- os: ubuntu-22.04
label: Linux
runs-on: ${{ matrix.os }}
# Priced for a COLD `uv sync`, on every platform.
#
# The previous split (Windows 25, Linux/macOS 10) came from a warm-cache
# measurement — Linux and macOS finish in ~65 s when setup-uv restores its
# cache, so 10 looked generous. Then run 30439640107 hit
# "Failed to restore: Cache service responded with 400", Linux installed
# torch from scratch, and the leg was killed at 10m17s. The 65 s was the
# cache, not the platform.
#
# A cache miss is not rare enough to treat as an outage (GitHub's cache
# service 400s, a lockfile change invalidates the key, a new runner image
# starts empty), and a timeout here is self-perpetuating: the leg dies
# before the post-step saves the cache, so the next run is cold too.
# 25 everywhere is still bounded — a genuinely wedged job is caught in
# minutes, not hours — and warm runs land nowhere near it.
timeout-minutes: 25
timeout-minutes: 10
env:
# Restricted-network resilience (RESEARCH Pitfall #6) — keeps uv from
# giving up on the first slow PyPI / python-build-standalone fetch.
@@ -321,26 +298,11 @@ jobs:
if: runner.os == 'Windows'
shell: bash
run: |
# The community chocolatey feed 50x's intermittently (broke PR runs on
# 2026-07-20 and 2026-07-28) — retry with backoff before failing.
#
# Test the OUTCOME, not choco's exit code. On 2026-07-28 the feed
# returned 503, choco reported "Unable to find package 'ffmpeg'" and
# "installed 0/0 packages" — and still exited 0. The `&& break` that
# was supposed to guard this fired on the first attempt, no retry ran,
# and the job died one line later on `ffmpeg: command not found`.
# A retry that trusts a lying exit code is not a retry.
# The community chocolatey feed 504s intermittently (broke a PR run
# on 2026-07-20) — retry with backoff before failing the job.
for i in 1 2 3; do
choco install ffmpeg -y --no-progress || true
hash -r 2>/dev/null || true
if command -v ffmpeg >/dev/null 2>&1; then break; fi
# No backoff after the last attempt — there is no fourth try to
# wait for, and sleeping 90s only delays an already-doomed job.
if [ "$i" -eq 3 ]; then
echo "choco failed to produce ffmpeg after 3 attempts"
break
fi
echo "choco attempt $i did not produce ffmpeg — retrying in $((i * 30))s"
choco install ffmpeg -y --no-progress && break
echo "choco attempt $i failed — retrying in $((i * 30))s"
sleep $((i * 30))
done
ffmpeg -version
-75
View File
@@ -515,81 +515,6 @@ jobs:
mv "$tmp" "$CONF"
echo "Stamped preview version: $PREVIEW_VERSION"
# The rolling `preview` release is REUSED every night, and macOS updater
# artifacts are the only ones Tauri names WITHOUT the version:
#
# OmniVoice Studio_0.4.1-103_x64.dmg <- unique per run, uploads fine
# OmniVoice Studio_x64.app.tar.gz <- constant, collides
#
# So every preview build after the first failed the macOS legs with
# `Validation Failed: {"resource":"ReleaseAsset","code":"already_exists"}`
# — and it failed AFTER the dmg upload, so the run went red while looking
# partially successful. The macOS updater bundles on `preview` went stale
# on 2026-07-04/05 and stayed that way for three weeks: Preview-channel
# macOS users had no working update path, and the nightly run was red
# every night.
#
# Delete this arch's updater bundle before uploading the new one. Scoped
# to the preview path (a `v*` tag makes a fresh release, nothing to
# collide with) and to this job's own arch, so the parallel aarch64/x64
# legs never touch each other's assets.
#
# ONLY an absent release/asset is benign. Auth, permission, rate-limit and
# network failures must not be swallowed: the step would report success
# while the stale asset survived, the upload would then die with
# `already_exists`, and we would be back to the exact outage this step
# exists to prevent — minus the red step that explains why. Since GH_TOKEN
# is scoped to this same repo, a 404 really does mean "not there".
- name: Clear this arch's stale preview updater bundle (macOS)
if: needs.preview-gate.outputs.is_preview == 'true' && runner.os == 'macOS'
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -uo pipefail
# aarch64-apple-darwin -> aarch64 ; x86_64-apple-darwin -> x64
case "${{ matrix.arch }}" in
aarch64-*) SUFFIX=aarch64 ;;
x86_64-*) SUFFIX=x64 ;;
*) echo "::error::unexpected arch ${{ matrix.arch }}"; exit 1 ;;
esac
# Match the STORED name, not the uploaded one: GitHub rewrites spaces
# to dots, so "OmniVoice Studio_x64.app.tar.gz" is stored as
# "OmniVoice.Studio_x64.app.tar.gz". Query the release and filter,
# rather than guessing which spelling to pass.
if ! gh release view preview --json assets -q '.assets[].name' \
> /tmp/preview-assets.txt 2> /tmp/gh-view-err.txt; then
if grep -qiE 'not found|HTTP 404' /tmp/gh-view-err.txt; then
echo "No preview release yet — nothing to clear."
exit 0
fi
echo "::error::Could not read the preview release, so a stale ${SUFFIX} bundle may still be there."
echo "Refusing to continue blind — the Tauri upload would fail with already_exists."
cat /tmp/gh-view-err.txt
exit 1
fi
grep -E "[ .]Studio_${SUFFIX}\.app\.tar\.gz(\.sig)?$" /tmp/preview-assets.txt \
> /tmp/stale.txt || true
if [ ! -s /tmp/stale.txt ]; then
echo "No stale ${SUFFIX} updater bundle on preview — nothing to clear."
exit 0
fi
while IFS= read -r name; do
echo "Removing stale preview asset: $name"
if ! gh release delete-asset preview "$name" --yes \
2> /tmp/gh-del-err.txt; then
# Already gone is fine — a re-run or the sibling leg beat us to
# it, and the goal (no asset under this name) is met either way.
if grep -qiE 'not found|HTTP 404' /tmp/gh-del-err.txt; then
echo " (already gone — nothing to collide with)"
continue
fi
echo "::error::Failed to delete stale preview asset $name."
cat /tmp/gh-del-err.txt
exit 1
fi
done < /tmp/stale.txt
- name: Build + release (Tauri)
uses: tauri-apps/tauri-action@v0
env:
+1 -9
View File
@@ -45,14 +45,10 @@ 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`) and project-bundled
# review agents — the owner's review standards belong with the code they
# govern, not in one machine's local state.
# (CLAUDE.md invites `.claude/skills/<name>/SKILL.md`).
.claude/*
!.claude/skills/
!.claude/skills/**
!.claude/agents/
!.claude/agents/**
/.cache*
/.tmp/
@@ -138,10 +134,6 @@ marketing.md
.specify/
.claude/skills/speckit-*/
.antigravitycli/
# `backlog` (the CLI task tracker) writes a config + one markdown file per task
# into the repo root. A contributor running it locally had those three files
# swept into a PR that was otherwise a single script (#1322 / #1306).
backlog/
# Locally-installed third-party skill packs (marketingskills, hallmark,
# mattpocock/skills, …) — ignore every skill dir by default; a skill that
+1 -7
View File
@@ -2,8 +2,7 @@
Binding for every AI agent (Claude, Codex, Cursor, review bots, …). CLAUDE.md is the full constitution; this is the operating contract. When they conflict, CLAUDE.md wins.
## Token economy (owner directive, 2026-07-20; tightened 2026-07-28)
- **Default to the shortest response that fully answers.** Outlines and tables over prose; no preamble, no recap of what you just did, no re-explaining a fix the diff already shows. Applies to every response, not just status updates.
## Token economy (owner directive, 2026-07-20)
- Lead with the outcome. No narration, no restating diffs, no filler praise, no plans you're about to execute anyway.
- Status updates: one line. Final reports: only what changes the reader's next action.
- Don't re-derive what CI, linters, or review bots already computed — read their output first (`gh pr checks`, bot comments via `gh api .../pulls/N/comments`).
@@ -11,11 +10,6 @@ Binding for every AI agent (Claude, Codex, Cursor, review bots, …). CLAUDE.md
- Run targeted tests while iterating; full suites only before landing.
- Tests and CI simulate CI honestly: `HF_HUB_OFFLINE=1` + empty `HF_HUB_CACHE` — a populated dev cache masks real failures.
## Cross-platform parity: behaviour, not performance
- The parity rule covers user-visible BEHAVIOUR. Hardware acceleration varies by host by design (CUDA/MPS/DirectML, Triton availability, `torch.compile`); skipping an optimization where it physically cannot work is not a parity violation.
- Do not "fix" a parity finding by disabling a working optimization everywhere. That trades a real regression for a semantic one.
- A feature the user can see and use on one OS but not another IS a violation. Judge by what the user can do, not by how fast it runs.
## Merge protocol (hard rules)
1. Never merge without review. Harvest CodeRabbit + Greptile comments first; never merge with an unread Critical/P1.
2. Never accept a PR as-is: fix findings ON the PR branch pre-merge (maintainer commits fine; credit contributors in CHANGELOG). No merge-then-fix, no comment-and-walk-away.
+1 -107
View File
@@ -10,114 +10,18 @@ The bundled TTS model package (`pyproject.toml`) is versioned independently.
**Highlights**
- RTX 40-series GPUs are used again instead of being sent to the CPU
- A warning before a slow generation, rather than after a five-minute wait
- The watermark can be turned off in Settings, as the docs always said
- macOS support now matches what the app actually delivers
- Linux AppImage: a blank white window on rolling distros (Mesa 26.1+) now starts normally
- A failed audiobook chapter says why, instead of turning red and saying nothing
### Changed
- macOS floor raised to 13.3 (Ventura) — the frontend has required Safari 16.4 for some time, so macOS 12 was a promise the stack could not keep (#1268)
### Added
- Settings → Privacy now has an **Invisible watermark** toggle. On by default, available to everyone, and it only affects audio generated after the change. (#1308)
- A new opt-in crash-isolated TTS engine, so a native crash takes down the sidecar instead of the whole backend — thanks @paoloantinori! (#1292, #1298, #1304)
### Fixed
- Every RTX 40-series card (40604090) was declared unsupported and silently run on the CPU. The compatibility gate demanded an exact `sm_89` match, but PyTorch ships `sm_86` kernels that already cover Ada. (#1285)
- Under-provisioned hardware is now flagged **before** a synthesis starts instead of after the full compute budget expires. (#1240, #1246, #1248, #1277, #1283, #1284)
- Long text on a CPU-only machine gets the same warning up front. (#1260, #1299)
- A crash inside the compute stack no longer blames VRAM: a segfault or Windows access violation now points at the GPU driver or an incomplete model download. (#1275, #1293)
- ffmpeg failures report the failure instead of ffmpeg's build configuration. (#1309)
- A cut TLS connection is explained in words rather than as `_ssl.c:1016`. (#1301)
- `torch.compile` is skipped when the torch library path contains a space, instead of failing in the linker on every load. (#1266)
- macOS Preview updates work again — the updater bundle had been colliding with itself since early July. (#1281)
- A dub whose transcription stream is cut by a reverse proxy now says so, instead of blaming the ASR model. (#1317)
- The dev backend going quiet under `--reload` is named as auto-reload rather than reported as a crash. (#1261)
- Audiobook: a chapter that fails to render now shows the reason in the chapter list and in the final error, instead of a red row whose cause existed only in the backend log — thanks @Reaksa-Cambodia! (#1321)
- Audiobook: an engine that stops without producing audio no longer stalls the render forever with no error and no timeout. (#1321)
- Linux AppImage: a permanently blank window on Mesa 26.1+ hosts (Arch/CachyOS and other rolling distros) — the bundled WebKit ran against a newer system Mesa than it was built for, and no environment variable could help because the failure precedes every rendering flag; the launcher now lets a newer system WebKitGTK take precedence — thanks @rvasilev and @HannaLovvold! (#1258, #1244)
- Linux AppImage: `OMNIVOICE_PREFER_SYSTEM_WEBKIT=1` forces your own WebKitGTK for hosts where its version can't be read automatically (no `pkg-config`), and `=0` forces the bundled one (#1258)
### Docs
- Engine acceptance: new `docs/engine-acceptance.md` documents the job map, the bar a new engine must clear, and the out-of-tree path (#1306)
- macOS install notes and the README support table now state the real floor (#1268)
- Contact: the project X account is listed alongside Discord (#1313)
### CI
- Windows smoke tests stopped silently passing a broken ffmpeg install, and every smoke leg is now budgeted for a cold dependency install. (#1290)
- Test suites no longer leak config paths or model-manager shutdown state into one another, which had been failing unrelated pull requests. (#1269)
## [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
- Linux install: a new section for the Mesa 26.1+ blank window, stating plainly that no environment variable works and why (#1258)
- 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)
@@ -130,16 +34,6 @@ The bundled TTS model package (`pyproject.toml`) is versioned independently.
- 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)
### CI
- The AppImage launcher's unit tests now run in CI — they existed but nothing executed them (#1258)
## [0.4.0] — 2026-07-21
@@ -461,6 +355,7 @@ 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.
@@ -809,7 +704,6 @@ 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
+2 -2
View File
@@ -13,7 +13,7 @@ Everything else (new engines, fancy features) is downstream of "the thing instal
- **Existing engine compatibility**: Users with already-installed engines (IndexTTS, CosyVoice, etc.) must not have to reinstall. Fixes touching engine code must be backward-compatible with on-disk model state.
- **Cross-platform parity**: Every fix must work on macOS (Apple Silicon + Intel), Windows (x64), and Linux (AppImage + deb). No platform-only regressions; the cross-platform bug bash (PR #51) is the baseline.
- **Default features must work on every platform (strict rule, 2026-05-20):** A feature that ships in default mode — out-of-the-box, no user customization, no opt-in toggle — must behave identically on macOS, Windows, and Linux. Platform-specific *implementation code* is allowed for OS APIs / shells / packaging, but the user-visible *default behavior* cannot diverge. Platform-only features (e.g., a macOS-only global shortcut, a Windows-only path picker) must go behind explicit user opt-in: Settings toggle, env var, or CLI flag. When a default doesn't work on a platform, that's a P0 bug — either fix it on the missing platform or move it behind opt-in. No third option. **This rule governs BEHAVIOUR, not PERFORMANCE** (clarified 2026-07-30, council): hardware acceleration is expected to vary by host — CUDA, MPS, DirectML, Triton availability and `torch.compile` are all host-dependent by design, and reading the rule to forbid that would forbid GPU support itself. An optimization that is skipped where it cannot work (missing Triton, an arch the wheel lacks, a path its toolchain cannot link) is NOT a parity violation; a *feature* the user can see and use on one OS but not another is.
- **Default features must work on every platform (strict rule, 2026-05-20):** A feature that ships in default mode — out-of-the-box, no user customization, no opt-in toggle — must behave identically on macOS, Windows, and Linux. Platform-specific *implementation code* is allowed for OS APIs / shells / packaging, but the user-visible *default behavior* cannot diverge. Platform-only features (e.g., a macOS-only global shortcut, a Windows-only path picker) must go behind explicit user opt-in: Settings toggle, env var, or CLI flag. When a default doesn't work on a platform, that's a P0 bug — either fix it on the missing platform or move it behind opt-in. No third option.
- **Backward-compatible project data**: Existing `omnivoice_data/` (user voices, projects, settings) must keep working without manual migration. Any DB schema change goes through alembic with a tested upgrade path.
- **Local-first guarantee preserved**: nothing leaves the machine without the user's **explicit yes**, and the app must remain fully functional with everything declined. Auto bug reporting is opt-in and submits only to GitHub Issues (prefilled-URL, from the user's own browser). Product analytics (owner-sanctioned 2026-07-16) is opt-in PostHog EU with a **first-run consent prompt** — two equal-weight Yes/No buttons, never default-on, skipping = off; consent-gated, allowlisted content-free metadata only (`backend/core/analytics.py`); every build — installer, Docker, and source alike (owner reversal 2026-07-20, #1193) — carries the in-repo publishable write-only token and shows the same consent ask, with env/baked token overriding it. No required cloud calls, accounts, or API keys.
- **Beta release cadence (no RC, no ceremony — strict rule, 2026-05-20):** the v0.3.x line has **no release candidates, no 48h soak, no formal release ceremony**. Every fix goes continuous-to-main; the owner tags a patch (`v0.3.Z`) from main whenever the current state is worth cutting. No `-rc` tags. No phased release. No `v0.4` deferrals while the v0.3.x line is open — every open issue and every open community PR gets absorbed into the v0.3.x line or explicitly declined. Users follow `main` for previews; users wanting stable stay on the latest tagged release. ROADMAP.md's Phase 6 "Release/Verify/Retro" entries are obsolete unless the user revives them.
@@ -76,7 +76,7 @@ Direct repo edits are authorized (owner decision, 2026-07-08). The GSD command g
**Harvest bot reviews before merging (rule, 2026-07-20):** CodeRabbit and Greptile auto-review every PR (tuned via `.coderabbit.yaml` / `greptile.json`, both fed CLAUDE.md as context). Before merging ANY PR — including your own — read their inline comments (`gh api repos/<owner>/<repo>/pulls/<N>/comments` filtered by bot login) and triage: fix real findings, ignore noise, never merge with an unread Critical/P1. They are the free first review pass; reserve deep agent-driven review for what they can't judge (architecture, cross-file semantics, product intent). Mechanical rules belong in deterministic CI tests, not in any AI reviewer.
**Token economy (owner directive, 2026-07-20; tightened 2026-07-28):** default to the shortest response that fully answers — outlines and tables over prose, no preamble, no recap of work just done, no re-explaining what the diff shows; applies to every response, not just status updates. Lead with the outcome; one-line statuses; no narration, filler, or diff-restating. Read what CI/linters/review bots already computed instead of re-deriving it. Mechanical rules belong in deterministic tests (changelog style, locale parity, version lockstep, CJK — all in `tests/`), never in agent effort. Targeted tests while iterating; full suites only before landing. `AGENTS.md` carries this contract for all agents — keep the two in sync.
**Token economy (owner directive, 2026-07-20):** lead with the outcome; one-line statuses; no narration, filler, or diff-restating. Read what CI/linters/review bots already computed instead of re-deriving it. Mechanical rules belong in deterministic tests (changelog style, locale parity, version lockstep, CJK — all in `tests/`), never in agent effort. Targeted tests while iterating; full suites only before landing. `AGENTS.md` carries this contract for all agents — keep the two in sync.
**Never accept a PR as-is (owner directive, 2026-07-20):** review findings — bot, agent, or human — get FIXED on the PR branch before merge (maintainer commits are fine and credit the contributor in the changelog); do not merge with known issues, do not merge-then-fix, do not leave findings as comments for someone else. Also merge current `main` into stale community branches before judging their CI, so the PR runs today's workflow gates (PR-green under an old workflow ≠ main-green).
<!-- GSD:workflow-end -->
+1 -5
View File
@@ -13,7 +13,6 @@
<a href="#sponsor--donate">Donate</a> ·
<a href="#contributing">Contributing</a> ·
<a href="https://discord.gg/bzQavDfVV9">Discord</a> ·
<a href="https://x.com/idebpalash">X</a> ·
<a href="README_CN.md"><strong>简体中文</strong></a>
</p>
@@ -24,7 +23,6 @@
<a href="LICENSE"><img src="https://img.shields.io/badge/license-AGPL--3.0-blue?style=flat-square" alt="License" /></a>
<a href="https://github.com/debpalash/OmniVoice-Studio/issues"><img src="https://img.shields.io/github/issues/debpalash/OmniVoice-Studio?style=flat-square&color=ef4444" alt="Issues" /></a>
<a href="https://discord.gg/bzQavDfVV9"><img src="https://img.shields.io/badge/Discord-Join_Community-5865F2?style=flat-square&logo=discord&logoColor=white" alt="Discord" /></a>
<a href="https://x.com/idebpalash"><img src="https://img.shields.io/badge/X-Follow_for_updates-000000?style=flat-square&logo=x&logoColor=white" alt="Follow on X" /></a>
<a href="https://ko-fi.com/debpalash"><img src="https://img.shields.io/badge/Ko--fi-Support_Us-FF5E5B?style=flat-square&logo=ko-fi&logoColor=white" alt="Ko-fi" /></a>
<a href="https://paypal.me/palashCoder"><img src="https://img.shields.io/badge/PayPal-Donate-00457C?style=flat-square&logo=paypal&logoColor=white" alt="PayPal" /></a>
</p>
@@ -213,7 +211,7 @@ Professional-grade voice AI, minus the subscription and the cloud.
| | **Minimum** | **Recommended** |
|---|---|---|
| **OS** | Windows 10, macOS 13.3+ (Apple Silicon), Ubuntu 24.04+ (glibc 2.39+) | Any modern 64-bit OS |
| **OS** | Windows 10, macOS 12+ (Apple Silicon), Ubuntu 24.04+ (glibc 2.39+) | Any modern 64-bit OS |
| **RAM** | 8 GB | 16 GB+ |
| **VRAM (GPU)** | 4 GB (auto-offloads TTS to CPU) | 8 GB+ (NVIDIA RTX 3060+) |
| **Disk** | 10 GB free (models + cache) | 20 GB+ SSD |
@@ -486,7 +484,6 @@ OmniVoice is **free** and **AGPL-3.0** — no paid tier, no SaaS revenue. Sponso
<div align="center">
<a href="https://discord.gg/bzQavDfVV9"><img src="https://img.shields.io/badge/💬_Discord-Join_Community-5865F2?style=for-the-badge&logo=discord&logoColor=white" alt="Join Discord" /></a>
<a href="https://x.com/idebpalash"><img src="https://img.shields.io/badge/𝕏_Follow-for_updates-000000?style=for-the-badge&logo=x&logoColor=white" alt="Follow on X" /></a>
<br/>
<sub>We respond to setup questions within hours, not days.</sub>
</div>
@@ -518,7 +515,6 @@ Yes please — bug fixes, new TTS engine adapters, UI improvements, docs, transl
- 📖 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
- 𝕏 Follow [@idebpalash](https://x.com/idebpalash) for updates and what's being built next
---
+3 -48
View File
@@ -760,7 +760,6 @@ async def _render_longform_sse(
convergence point: one renderer, two front doors.
"""
from core.config import OUTPUTS_DIR
from core.failure import build_failure, build_failure_event
from services.ffmpeg_utils import find_ffmpeg, run_ffmpeg
from services.model_manager import _gpu_pool
@@ -850,9 +849,6 @@ async def _render_longform_sse(
chapters_meta: list[tuple[str, int]] = []
cached_n = 0
failed: list[int] = []
# Kept so the terminal "all chapters failed" event can name the cause
# instead of restating the symptom (#1321).
last_chapter_exc: Exception | None = None
interrupted = False
yield _emit({"type": "started", "job_id": job_id, "chapters": total})
@@ -882,28 +878,12 @@ async def _render_longform_sse(
chapter, synth, sr, engine_id, resolve, cache_dir, lexicon,
resolved_lang, opts, voice_map,
)
except Exception as e: # isolate a bad chapter — keep going
except Exception: # isolate a bad chapter — keep going
logger.warning("[%s] chapter %d (%s) failed to render",
job_id, i, chapter.title, exc_info=True)
failed.append(i)
# Carry the real reason (#1321). The old event said only
# "chapter failed to render", so a failed chapter was a red row
# and nothing else — the cause existed solely in the backend log,
# which is why the report for this arrived as a bare traceback.
# build_failure guarantees a non-empty reason even for exceptions
# whose str() is empty (a generator-based engine that yields
# nothing raises a bare StopIteration), sanitizes paths/tokens,
# and adds the docs deeplink + hint. `error` stays populated —
# build_failure mirrors reason into it — so older frontends and
# the Stories exporter keep working.
last_chapter_exc = e
yield _emit({"type": "chapter_error", "index": i, "total": total,
"title": chapter.title,
# No env diagnostic per chapter: a book can fail
# hundreds of times and it is identical every time.
# The terminal error below carries one.
**build_failure(e, stage="audiobook_chapter",
include_diagnostic=False)})
"title": chapter.title, "error": "chapter failed to render"})
continue
chapter_files.append(wav_path)
chapters_meta.append((chapter.title, int(round(dur * 1000))))
@@ -940,32 +920,7 @@ async def _render_longform_sse(
return
if not chapter_files:
# Every chapter failed, so the render is over — this is the event the
# UI turns into a toast, and it used to carry only the symptom
# (#1321). Lead with the summary, then the cause; docs_topic/hint are
# classified from the raw exception text, so prefixing the reason
# afterwards cannot mis-route the deeplink.
if last_chapter_exc is not None:
ev = build_failure_event(last_chapter_exc, stage="audiobook_render")
ev["reason"] = f"all {total} chapters failed to render — {ev['reason']}"
ev["error"] = ev["reason"]
else:
ev = {"type": "error", "error": "all chapters failed to render",
"reason": "all chapters failed to render"}
# Terminal failure — record it. This branch used to return without
# touching job history, so the row stayed `running` forever: the next
# startup read it as an interrupted job, and the retained manifest
# offered a render that had already failed every chapter as
# resumable (Greptile P1 on #1321). The manifest IS kept on purpose —
# a failure whose cause the user can now see (a missing voice, an
# engine that can't read the script) is worth retrying once fixed,
# and the chapter cache is empty here so a retry costs nothing extra.
if job_store is not None:
try:
job_store.mark_failed(job_id, ev["reason"])
except Exception:
pass # best-effort job history; never block the stream
yield _emit(ev)
yield _emit({"type": "error", "error": "all chapters failed to render"})
return
yield _emit({"type": "assembling"})
+4 -16
View File
@@ -229,16 +229,7 @@ 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()]
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)
conn.execute("DELETE FROM dub_history")
for jid in ids:
safe = _safe_job_dir(jid)
if safe and os.path.isdir(safe):
@@ -248,15 +239,12 @@ def clear_dub_history():
@router.delete("/dub/history/{history_id}")
def delete_single_dub_history(history_id: str):
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)
with db_conn() as conn:
conn.execute("DELETE FROM dub_history WHERE id=?", (history_id,))
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}
+8 -9
View File
@@ -12,7 +12,6 @@ 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,
@@ -516,7 +515,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": content_disposition(dl_name)},
headers={"Content-Disposition": f'attachment; filename="{dl_name}"'},
)
# Determine whether this export should drive video through a per-segment
@@ -799,7 +798,7 @@ async def dub_download(
return FileResponse(
output_path, media_type="video/mp4",
headers={"Content-Disposition": content_disposition(dl_name), **extra_headers},
headers={"Content-Disposition": f'attachment; filename="{dl_name}"', **extra_headers},
)
@@ -1383,7 +1382,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": content_disposition(dl_name)},
headers={"Content-Disposition": f'attachment; filename="{dl_name}"'},
)
@@ -1470,7 +1469,7 @@ async def dub_export_srt(
return Response(
content=srt_content,
media_type="text/plain",
headers={"Content-Disposition": content_disposition(dl_name)},
headers={"Content-Disposition": f'attachment; filename="{dl_name}"'},
)
def _format_vtt_time(seconds):
@@ -1517,7 +1516,7 @@ async def dub_export_vtt(
return Response(
content=vtt_content,
media_type="text/vtt",
headers={"Content-Disposition": content_disposition(dl_name)},
headers={"Content-Disposition": f'attachment; filename="{dl_name}"'},
)
@@ -1558,7 +1557,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": content_disposition(f"segments_{safe_name}.zip")},
headers={"Content-Disposition": f'attachment; filename="segments_{safe_name}.zip"'},
)
@router.get("/dub/download-mp3/{job_id}")
@@ -1638,7 +1637,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": content_disposition(dl_name)},
headers={"Content-Disposition": f'attachment; filename="{dl_name}"'},
)
@router.get("/dub/export-stems/{job_id}")
@@ -1677,5 +1676,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": content_disposition(f"stems_{safe_name}.zip")},
headers={"Content-Disposition": f'attachment; filename="stems_{safe_name}.zip"'},
)
+1 -59
View File
@@ -634,66 +634,11 @@ def _run_backend_inference(
except ValueError as e:
# Don't wrap validation errors in OOM message
raise _language_rejection_or(e, backend, language)
raise e
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.
@@ -931,9 +876,6 @@ async def generate_speech(
except Exception:
pass
# VRAM eviction runs in get_model()'s warm-return path now, so every native
# TTS generate (this route, WS TTS, dub, batch, audiobook) is covered.
_model = None
_backend = None
if backend_cls is OmniVoiceBackend:
+1 -2
View File
@@ -39,7 +39,6 @@ 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")
@@ -132,7 +131,7 @@ def export_profile(profile_id: str):
buf,
media_type="application/zip",
headers={
"Content-Disposition": content_disposition(filename),
"Content-Disposition": f'attachment; filename="{filename}"',
"Content-Length": str(buf.getbuffer().nbytes),
},
)
+1 -5
View File
@@ -32,7 +32,6 @@ 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")
@@ -385,9 +384,6 @@ async def create_speech(req: SpeechRequest):
from services.text_normalization import normalize_for_tts
text = normalize_for_tts(req.input, req.language)
# VRAM eviction runs in get_model()'s warm-return path now, covering every
# native TTS generate (this route, WS TTS, dub, batch, audiobook).
# ── #1033/#1037/#1014: warm the engine under the LOAD budget before the
# generate clock starts. The T4 verification (#1014) measured a fresh
# install's first /v1/audio/speech burning its whole 300s generate budget
@@ -470,7 +466,7 @@ async def create_speech(req: SpeechRequest):
_headers = {
"Content-Length": str(len(audio_bytes)),
"Content-Disposition": content_disposition(f"speech.{ext}", disposition="inline"),
"Content-Disposition": f'inline; filename="speech.{ext}"',
}
if _routing_notice:
from services.engine_routing import header_safe_reason
+1 -2
View File
@@ -28,7 +28,6 @@ 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()
@@ -101,7 +100,7 @@ async def export_persona(
BytesIO(content),
media_type="application/zip",
headers={
"Content-Disposition": content_disposition(filename),
"Content-Disposition": f'attachment; filename="{filename}"',
"Content-Length": str(len(content)),
},
)
+2 -52
View File
@@ -19,7 +19,6 @@ 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
@@ -321,41 +320,6 @@ 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
@@ -528,22 +492,8 @@ async def install_model(req: InstallModelRequest):
_snapshot_path = snapshot_download(**dl_kwargs)
_validate_snapshot_has_weights(req.repo_id, _snapshot_path)
break
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
):
except (HfHubHTTPError, LocalEntryNotFoundError, OSError) as net_err:
if _attempt >= _max_attempts:
raise
_backoff = min(30, 2 ** _attempt)
logger.info(
+1 -2
View File
@@ -14,7 +14,6 @@ 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()
@@ -77,7 +76,7 @@ async def stories_encode(
return Response(
content=encoded,
media_type=mime,
headers={"Content-Disposition": content_disposition(f"story.{ext}")},
headers={"Content-Disposition": f'attachment; filename="story.{ext}"'},
)
finally:
for p in (in_path, out_path):
-14
View File
@@ -90,20 +90,6 @@ 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()
+1 -49
View File
@@ -30,7 +30,6 @@ from __future__ import annotations
import functools
import os
import platform as _platform
import re
import sys
from dataclasses import dataclass
from typing import Literal
@@ -112,53 +111,6 @@ def build_arch_list(torch) -> list[str]:
return []
_CUDA_ARCH_TAG = re.compile(r"^(sm|compute)_(\d+)([a-z]?)$")
def cuda_build_covers(arch_list, major: int, minor: int) -> bool:
"""Can a torch build compiled for ``arch_list`` run on CC ``major.minor``?
NOT an exact-tag match, because NVIDIA's compatibility rules are not exact
and PyTorch depends on that (#1285):
* **SASS (``sm_XY``) is binary-compatible upward within a major version**
a cubin built for 8.6 runs on any 8.x device with minor 6. This is why
the official wheels ship ``sm_80``/``sm_86`` and **no ``sm_89``**: the
8.6 kernels already cover Ada. An exact-match gate therefore declared
every RTX 40-series card (40604090, all sm_89) unsupported and
force-routed it to CPU, which is exactly what #1285 reported.
* **PTX (``compute_XY``) JIT-compiles forward** to any newer architecture,
so embedded PTX at or below the device's capability is a valid path.
* **An ``a``/``f`` suffix (``sm_90a``) is architecture-SPECIFIC** those
cubins deliberately do not forward-run, so they only count on an exact
capability match.
Unparseable entries are skipped rather than guessed at.
"""
device_cc = major * 10 + minor
for entry in arch_list or ():
m = _CUDA_ARCH_TAG.match(str(entry).strip())
if not m:
continue
kind, digits, suffix = m.group(1), m.group(2), m.group(3)
try:
cc = int(digits)
except ValueError:
continue
e_major, e_minor = divmod(cc, 10)
if suffix:
# Arch-specific: exact capability only, whatever the kind.
if cc == device_cc:
return True
continue
if kind == "sm":
if e_major == major and e_minor <= minor:
return True
elif cc <= device_cc:
return True
return False
def gfx_for_hsa_override(value: str) -> str | None:
"""``"11.0.0"`` → ``"gfx1100"``. The inverse of :func:`hsa_override_for`.
@@ -234,7 +186,7 @@ def arch_unsupported(torch) -> tuple[str, tuple[str, ...]] | None:
# ── CUDA: arch_list holds sm_/compute_ tags ──────────────────────
major, minor = torch.cuda.get_device_capability(0)
sm_tag = f"sm_{major}{minor}"
if cuda_build_covers(arch_list, 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:
+10 -205
View File
@@ -42,21 +42,12 @@ _HINTS: dict[str, str] = {
"COMPUTE_TYPE_UNSUPPORTED": "Your GPU doesn't support float16 — OmniVoice retried on int8. If transcription still fails, set OMNIVOICE/ASR_COMPUTE_TYPE=int8 or use CPU.",
"TRANSFORMERS_IMPORT": "Your transformers install is incomplete, or a package it loads models through (torchaudio) is missing or mismatched with your torch. Reinstall them together (`uv pip install --reinstall torch torchaudio transformers`), then restart the backend. If only transcription is affected, switching ASR to faster-whisper (Settings → Models) also works around it.",
"WINDOWS_APP_CONTROL_BLOCKED": "Windows refused to load a file OmniVoice needs — an Application Control policy (Smart App Control, WDAC, or AppLocker) blocked it. On a personal PC: Windows Security → App & browser control → Smart App Control → Off (Windows only lets you turn it off once — re-enabling requires a Windows reset), then restart OmniVoice. On a managed/work PC, ask IT to allow the OmniVoice install folder.",
"WINDOWS_PAGING_FILE_TOO_SMALL": "Windows ran out of virtual memory while mapping the model into memory — its paging file is smaller than the model needs. This is not the same as your RAM being full, and closing other apps usually won't fix it: Windows has to be allowed to back the mapping. Set a bigger paging file — Settings → System → About → Advanced system settings → Performance → Settings → Advanced → Virtual memory → Change: untick \"Automatically manage\", pick your system drive, choose \"Custom size\" and set both Initial and Maximum to at least 32768 MB (more than the model's size), then OK and restart Windows. A smaller/quantized engine (OmniVoice GGUF, Supertonic-3) also avoids the large mapping entirely.",
"MEDIA_TOOL_MISSING": "OmniVoice's media engine (ffmpeg/ffprobe) wasn't on the system path when a component went looking for it. Open Settings → Audio tools and use Download/Repair to fetch the bundled copy, then retry — a restart picks it up for everything. If you'd rather use a system install, install ffmpeg (macOS: `brew install ffmpeg`; Windows: `winget install Gyan.FFmpeg`; Linux: your package manager) and restart OmniVoice, or point FFMPEG_PATH / OMNIVOICE_FFPROBE_PATH at the binaries in Settings.",
"AUDIO_IO_FAILED": "An audio file couldn't be read or written at the OS level. Check the drive isn't full, that the output and temp folders exist and are writable, and that antivirus or OneDrive isn't locking them (add an OmniVoice exclusion if you use one).",
"VIDEO_DOWNLOAD_OS_ERROR": "The OS refused a file operation while saving the downloaded video — this is a disk/folder problem, not a network one, so retrying the same link won't help. The download is written to a job folder under your OmniVoice data directory (Settings → Storage shows the path): check that drive isn't full, that the folder exists and is writable, and that antivirus or a cloud-sync client (OneDrive, Dropbox) isn't locking it — add an OmniVoice exclusion if you use one. If your data directory sits on a synced or network drive, move it to a local one.",
"OS_INVALID_ARGUMENT": "The OS rejected a file operation (Errno 22 / invalid argument) — in the transcribe path this is the temporary WAV write before ASR. It's almost always the temp directory: missing, read-only, on a full or removed drive, or blocked by antivirus. Check that your system TEMP/TMP folder exists and is writable and the drive has free space (add an OmniVoice antivirus exclusion if you use one), then retry.",
"SOCKS_PROXY_SUPPORT_MISSING": "A SOCKS proxy is configured in your environment (ALL_PROXY/HTTPS_PROXY=socks5://…) and the backend's HTTP client is missing SOCKS support. Newer OmniVoice builds ship SOCKS support (the socksio package) — update the app. If you still see this, unset ALL_PROXY/HTTPS_PROXY for OmniVoice, or run `uv pip install 'httpx[socks]'` in the backend venv, then restart.",
"SSL_HANDSHAKE_FAILURE": "A corporate or antivirus proxy is intercepting HTTPS traffic and re-signing certificates with its own CA — your OS trusts that CA, but Python's bundled certifi CA list doesn't, so the TLS handshake fails even though the connection reached the server. Newer OmniVoice builds trust the OS certificate store at startup (the truststore package), which should already fix this — update the app and retry. If you still see this, add an HTTPS-scanning exclusion for OmniVoice/Python in your antivirus, or ask IT for the proxy's CA bundle and set SSL_CERT_FILE to it, then restart.",
"UNSUPPORTED_VIDEO_URL": "This link isn't a directly downloadable video. Paste a direct video page (e.g. a youtube.com/watch?v=… or douyin.com/video/<id> link), not a share/profile/feed link — or download the file and drop it in directly.",
"VIDEO_DRM_PROTECTED": "The video host only offered OmniVoice a DRM-protected copy, which can't be downloaded. This is often not a property of the video itself — the host serves a different format set to different clients, and OmniVoice already retried through every client it has. Try the link again in a minute, or download the video with a browser extension / the host's own download button and drop the file into Dubbing directly.",
# #1301: distinct from SSL_HANDSHAKE_FAILURE. The handshake did not fail on
# trust — the connection was CUT while TLS was in progress, so the certifi /
# proxy-CA advice above would send the user to fix something that isn't
# broken. Raw form is "[SSL: UNEXPECTED_EOF_WHILE_READING] EOF occurred in
# violation of protocol (_ssl.c:1016)", which means nothing to anyone.
"TLS_CONNECTION_DROPPED": "The secure connection was cut off mid-transfer — the other end (or something between you and it) closed the socket during the TLS exchange. This is almost always transient: flaky Wi-Fi, a VPN reconnecting, a captive portal, or a download server dropping a long transfer. Retrying is safe: a partly-downloaded MODEL is picked up where it left off rather than started over. If it repeats every time, a VPN or an HTTPS-inspecting proxy is terminating long-lived connections — try without the VPN, or on another network.",
"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.",
@@ -97,18 +88,6 @@ _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
@@ -222,9 +201,6 @@ def append_hf_mirror_hint(text: str) -> str:
_CONTEXT_FREE_HINT_CLASSES = frozenset({
"SOCKS_PROXY_SUPPORT_MISSING",
"SSL_HANDSHAKE_FAILURE",
# Its trigger is an exact OpenSSL string, so it cannot be confused with
# another failure the way a bare "timed out" could.
"TLS_CONNECTION_DROPPED",
})
@@ -284,34 +260,18 @@ def classify(reason: str) -> str:
marker in low for marker in _DOWNLOAD_CONTEXT_MARKERS
):
return "VIDEO_DOWNLOAD_OS_ERROR"
# #1256: a third-party library shelled out to `ffprobe`/`ffmpeg` BY NAME and
# the OS had nothing to run. OmniVoice's own code always resolves the
# bundled sidecar explicitly, so this only ever comes from a dependency —
# which meant it arrived with no class at all and the user was told the
# engine "stopped with an error OmniVoice doesn't recognize". Checked
# before the generic errno-2 rules, which would otherwise claim it.
if _is_missing_media_tool(low):
return "MEDIA_TOOL_MISSING"
# #1251: Windows refused to map the model because the PAGING FILE is too
# small. The generate path already counted this as an OOM, but that hint
# ("close other apps, use a lighter engine") is the wrong remedy — the
# machine had 32 GB of RAM. Matched on the numeric code too, since the OS
# translates the message text, and in both the Python (`[WinError 1455]`)
# and Rust (`os error 1455`, from the safetensors mmap) spellings.
if "1455" in low and ("winerror" in low or "os error" in low):
return "WINDOWS_PAGING_FILE_TOO_SMALL"
if "paging file is too small" in low:
return "WINDOWS_PAGING_FILE_TOO_SMALL"
if "errno 22" in low:
return "OS_INVALID_ARGUMENT"
# An HF cache whose snapshot entries don't resolve (dangling symlinks /
# zero-byte stand-ins) 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:
# 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):
return "MODEL_CACHE_CORRUPT"
if (
"could not import module" in low
@@ -347,18 +307,6 @@ def classify(reason: str) -> str:
# certifi list doesn't (a different failure mode from #984's TCP-level
# "can't reach the host at all"). Requires "ssl" plus a handshake/cert-
# verify marker so a generic connection error isn't mislabelled.
# Check the dropped-connection shape FIRST: its text also contains "ssl",
# and the handshake branch below would otherwise claim it and hand out
# proxy/certifi advice for a socket that was simply cut (#1301).
# Requires an "ssl" marker as well: "unexpected EOF" on its own is a phrase
# a parser or an unrelated transport can also produce, and stamping the TLS
# taxonomy on those would hand out VPN/proxy advice for something else
# entirely. The OpenSSL text always carries the marker.
if "ssl" in low and (
"unexpected_eof_while_reading" in low
or "eof occurred in violation of protocol" in low
):
return "TLS_CONNECTION_DROPPED"
if "ssl" in low and (
"handshake" in low
or "certificate verify failed" in low
@@ -383,13 +331,6 @@ 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
@@ -430,46 +371,6 @@ def classify(reason: str) -> str:
return ""
# ffmpeg/ffprobe write a version banner to STDERR on every invocation, before
# doing any work. When a command fails we capture that stderr and it becomes
# the error message — so the user is shown the build configuration of their
# ffmpeg instead of what went wrong (#1309: a dub extract failure reported as
# "extract: ffmpeg version N-125781-gacf6b520c1-20260727 Copyright (c) …").
#
# The real diagnosis is always AFTER the banner. Strip the boilerplate so the
# message starts at the first line that is actually about this run.
_FFMPEG_BANNER_START = re.compile(r"^\s*(ffmpeg|ffprobe)\s+version\s", re.IGNORECASE)
_FFMPEG_BANNER_CONT = re.compile(
r"^\s+(built with|configuration:|lib[a-z]+\s+\d)", re.IGNORECASE
)
def strip_ffmpeg_banner(text: Optional[str]) -> str:
"""Drop ffmpeg's version/configuration preamble from a captured stderr.
Returns the text unchanged when no banner is present, and importantly
when stripping would leave nothing: a message that is *only* a banner is
unhelpful, but an empty one is worse.
"""
if not text:
return text or ""
lines = str(text).splitlines()
kept, in_banner = [], False
for line in lines:
if _FFMPEG_BANNER_START.match(line):
in_banner = True
continue
if in_banner:
# The banner is the version line plus its indented continuation
# block; the first line that is not part of that ends it.
if not line.strip() or _FFMPEG_BANNER_CONT.match(line):
continue
in_banner = False
kept.append(line)
out = "\n".join(kept).strip()
return out or str(text).strip()
def sanitize(text: Optional[str]) -> str:
"""Redact secrets and strip the home path from a string.
@@ -567,71 +468,6 @@ _DOWNLOAD_CONTEXT_MARKERS = (
)
#: The media binaries a dependency may shell out to by bare name.
_MEDIA_TOOLS = ("ffprobe", "ffmpeg")
def _is_missing_media_tool(low: str) -> bool:
"""True when the failure is "the OS could not find ffprobe/ffmpeg" (#1256).
Deliberately narrow: it must name the binary *as the thing that could not
be found*, so a perfectly ordinary "ffmpeg failed: no such file or
directory: /path/to/input.wav" — a missing INPUT, an entirely different
problem is not handed the "install the media engine" remedy.
"""
if "no such file or directory" not in low and "[winerror 2]" not in low:
return False
# The name must appear UNQUALIFIED — quoted with no directory part, which
# is how a bare-name spawn fails. A path that merely ends in the tool's
# name ('/tmp/ffmpeg', '~/Movies/my-ffmpeg-export.mp4') is a missing FILE,
# an entirely different problem that must not get the "repair your media
# engine" remedy (#1256 review).
return any(
f"'{tool}'" in low or f'"{tool}"' in low
for tool in _MEDIA_TOOLS
)
#: transformers' two ways of saying "this snapshot has no weight shard".
#:
#: They come from different code paths and share no wording, so matching only
#: the first — which both the classifier and model_manager's self-heal used to
#: do — silently missed half the class (#1273):
#:
#: hub load: "<repo> does not appear to have a file named
#: pytorch_model.bin or model.safetensors"
#: local dir: "Error no file named model.safetensors, or pytorch_model.bin,
#: found in directory <path>"
#:
#: The second is what a load of a *subfolder* inside a cached snapshot raises
#: (e.g. `audio_tokenizer/`), which is exactly where an interrupted download
#: leaves a repo half-written. Both mean the same thing and both are repaired
#: the same way, so both must classify and both must trigger the heal.
_INCOMPLETE_CACHE_PHRASES = (
"does not appear to have a file named",
# Matched as two fragments: the file list between them varies with the
# transformers version and the requested weight variant.
("error no file named", "found in directory"),
)
def is_incomplete_cache_message(text: str) -> bool:
"""True when *text* is transformers reporting a snapshot with no weights.
Takes an already-lowercased string. Shared by :func:`classify` and
model_manager's cache self-heal so the healer and the error message can
never disagree about what an interrupted download looks like.
"""
low = str(text).lower()
for phrase in _INCOMPLETE_CACHE_PHRASES:
if isinstance(phrase, tuple):
if all(part in low for part in phrase):
return True
elif phrase in low:
return True
return False
def is_os_write_refusal(reason: Optional[str]) -> bool:
"""True when *reason* looks like the OS refusing a file operation (a full
or removed drive, a read-only folder, an antivirus/cloud-sync lock) rather
@@ -668,33 +504,6 @@ def describe_path_target(path: str) -> str:
return "; ".join(facts)
#: Exception types whose ``str()`` is a bare VALUE rather than a sentence, so
#: showing it alone tells the user nothing about what went wrong.
#: ``str(KeyError("mgw39lx3"))`` is ``"'mgw39lx3'"`` — the repr of the key.
_VALUE_ONLY_STR_EXCEPTIONS = (KeyError,)
def describe_exception(exc: BaseException) -> str:
"""``str(exc)`` in a form a human can act on.
#1252/#1253: a dub ingest failed with the toast ``ingest: 'mgw39lx3'`` and
nothing else the entire user-facing reason was the repr of a dict key,
because ``str(KeyError)`` does not mention that a lookup failed, or that it
was an exception at all. The reporter's ``'mgw39lx3'`` was their own job id
reflected back at them with no context.
Naming the class is the floor, not the goal: a failure that reaches here at
all is one nobody wrote a message for. It keeps the report diagnosable
instead of cryptic while the specific path gets its own handling.
"""
text = str(exc).strip()
if not text:
return type(exc).__name__
if isinstance(exc, _VALUE_ONLY_STR_EXCEPTIONS):
return f"{type(exc).__name__}: {text}"
return text
def build_failure(
exc_or_msg: Any,
*,
@@ -708,15 +517,11 @@ def build_failure(
"""
if isinstance(exc_or_msg, BaseException):
error_class = type(exc_or_msg).__name__
raw = describe_exception(exc_or_msg)
raw = str(exc_or_msg).strip() or error_class
else:
error_class = "Error"
raw = str(exc_or_msg).strip() or "Unknown failure"
# Strip ffmpeg's stderr banner before anything reads the text — both the
# user-facing reason and classify() below, which would otherwise be
# matching against a build configuration string (#1309).
raw = strip_ffmpeg_banner(raw)
reason = sanitize(raw) or error_class
docs_topic = classify(raw)
# HF_MIRROR_UNREACHABLE's hint is dynamic (it names the configured mirror)
-90
View File
@@ -1,90 +0,0 @@
"""RFC 6266 ``Content-Disposition`` construction.
#1262: exporting a voice profile whose name isn't spelled in Latin letters
returned a 500:
'latin-1' codec can't encode characters in position 22-25:
ordinal not in range(256)
``attachment; filename="`` is exactly 22 characters, so positions 22-25 were
the first four characters of the user's own profile name. HTTP header values
are latin-1 by definition, and every download endpoint built the header by
f-string interpolation, so any name outside latin-1 Chinese, Japanese,
Korean, Greek, Cyrillic, Hebrew, emoji crashed the request.
The sanitisers in front of those f-strings did not catch it because they all
filtered with ``str.isalnum()``, which is **True for every alphabetic script**,
not just ASCII. ``"我的声音".isalnum()`` is ``True``. They were removing
punctuation and passing the exact characters that break the header.
`content_disposition` is the one construction site: an ASCII-safe
``filename=`` that any client can read, plus the RFC 5987 ``filename*=`` that
gives modern browsers the user's real name back, correctly encoded.
"""
from __future__ import annotations
import re
import unicodedata
from urllib.parse import quote
__all__ = ["ascii_filename", "content_disposition"]
#: Characters Windows forbids in a filename, plus the quoting/injection risks
#: (`"` and `\` end the quoted-string; CR/LF would split the header).
_UNSAFE = re.compile(r'[\\/:*?"<>|\r\n\t]')
def _fold(text: str) -> str:
"""One filename part, reduced to safe ASCII."""
folded = unicodedata.normalize("NFKD", text)
# Drop the combining marks NFKD split off, keeping the base letters.
folded = "".join(c for c in folded if not unicodedata.combining(c))
folded = folded.encode("ascii", "ignore").decode("ascii")
return _UNSAFE.sub("_", folded).strip()
def ascii_filename(filename: str, fallback: str = "download") -> str:
"""A latin-1-safe rendering of *filename* for the legacy ``filename=``.
Accented Latin is folded to its base letters (``Sébastien``
``Sebastien``) rather than deleted, since that stays readable. Scripts with
no ASCII form (CJK, Cyrillic, Hebrew, emoji) have no meaningful fold, so
they drop out and *fallback* carries the name the ``filename*`` parameter
is what actually preserves those, and every browser released this decade
prefers it.
"""
raw = filename or ""
# Split the extension off FIRST: folding runs per-part so a name that is
# entirely non-ASCII loses its stem without also losing ".ovsvoice", which
# is what tells the OS (and the user) what the file actually is.
stem, dot, suffix = raw.rpartition(".")
if not dot:
stem, suffix = raw, ""
stem, suffix = _fold(stem), _fold(suffix)
if not stem.strip("_ ."):
stem = fallback
return f"{stem}.{suffix}" if suffix else stem
def content_disposition(
filename: str,
*,
disposition: str = "attachment",
fallback: str = "download",
) -> str:
"""A ``Content-Disposition`` value that is safe for ANY filename (#1262).
Emits both forms per RFC 6266 §4.3: ``filename=`` for the lowest common
denominator and ``filename*=UTF-8''`` for the real name. Clients that
understand the extended form ignore the plain one, so the user gets
``我的声音.ovsvoice`` while nothing anywhere has to encode it as latin-1.
"""
# The fallback is a caller-supplied string that lands in the header
# verbatim whenever the real name folds away entirely, so it gets the same
# treatment as the name itself — otherwise a non-ASCII or quote/CRLF
# fallback walks straight past every guard here (#1262 review).
safe_fallback = _fold(fallback) or "download"
safe = ascii_filename(filename, fallback=safe_fallback)
encoded = quote(_UNSAFE.sub("_", filename or safe_fallback), safe="")
return f"{disposition}; filename=\"{safe}\"; filename*=UTF-8''{encoded}"
+1 -1
View File
@@ -24,7 +24,7 @@ from pathlib import Path
# tests/test_app_version.py::test_all_version_files_in_lockstep and bumped by
# release.yml's version-bump job, so it stays equal to
# pyproject/tauri.conf/Cargo/package.json.
_FALLBACK_VERSION = "0.4.2"
_FALLBACK_VERSION = "0.4.0"
def _fallback_version() -> str:
@@ -1,105 +0,0 @@
"""omnivoice-subprocess: the resident OmniVoice TTS engine in a crash-isolated
sidecar process (#730/#1190).
The default ``omnivoice`` engine runs in-process on the GPU ``ThreadPoolExecutor``.
When a generate or load there exceeds its execution budget the pool is "reset"
but the abandoned worker *thread* cannot be killed (Python cannot interrupt a
native torch/MPS call), so it holds the MPS device until it finishes on its
own, and every later synth contends with the zombie and hangs.
This engine runs the SAME OmniVoice model in a child process via
:class:`SubprocessBackend`. A child process CAN be hard-killed: on a recv
timeout the parent's watchdog calls ``proc.kill()``, reclaiming the child's
VRAM/device, and the next request transparently respawns a fresh sidecar. That
is the one thing the in-process engine structurally cannot do.
OPT-IN (Settings -> Engines, or ``OMNIVOICE_TTS_BACKEND=omnivoice-subprocess``);
the in-process ``omnivoice`` stays the default so existing users see no change.
Tradeoff vs the in-process engine: identical model and quality, a little extra
per-call overhead (one stdio round-trip), and it does not carry the native
advanced-parameter surface (``t_shift`` / ``layer_penalty_factor`` /
``position_temperature`` / ``class_temperature``) or parent-side seed
determinism, because the generic ``backend.generate`` path does not forward
those. Acceptable for unattended / reaction-triggered use where reliability
matters more than those controls.
Unlike IndexTTS / dots.tts / Supertonic-3, this sidecar runs under the PARENT
interpreter (``venv_python() -> sys.executable``): the goal here is crash
isolation, not dependency isolation, and the OmniVoice engine uses the host's
own pins.
"""
from __future__ import annotations
import logging
import os
import sys
from pathlib import Path
from typing import TYPE_CHECKING
from services.subprocess_backend import SubprocessBackend
if TYPE_CHECKING:
import torch # noqa: F401
logger = logging.getLogger("omnivoice.omnivoice_subprocess")
class OmniVoiceSubprocessBackend(SubprocessBackend):
"""The resident OmniVoice model in a killable sidecar process."""
id = "omnivoice-subprocess"
display_name = "OmniVoice (subprocess-isolated, killable on timeout)"
_DEFAULT_SAMPLE_RATE = 24000
gpu_compat = ("cuda", "mps", "cpu")
# Match OmniVoiceBackend: the measured floor below which a render that
# should take seconds runs for minutes (the #1226/#1222 4 GB reports).
min_vram_gb = 6.0
@classmethod
def is_available(cls) -> tuple[bool, str]:
# Same probe as OmniVoiceBackend: the package must be importable. The
# interpreter is the parent's own (sys.executable), so there is no
# separate venv to validate.
try:
import omnivoice.models.omnivoice # noqa: F401
except Exception as e:
return False, f"omnivoice package missing: {e}"
return True, "ready"
@classmethod
def venv_python(cls) -> Path:
# Same interpreter as the parent: this engine isolates for crash
# recovery, not dependency pins, so it needs no dedicated venv.
return Path(sys.executable)
@classmethod
def sidecar_script(cls) -> Path:
return Path(__file__).resolve().parent / "main.py"
@property
def recv_timeout_s(self) -> float:
"""Override the base 60s recv timeout.
Aligns the kill deadline with the generate budget: a long-but-valid
OmniVoice synth (which can take tens of seconds) is not falsely killed,
while a genuinely wedged one is hard-killed and its VRAM reclaimed at
the deadline. That reclaim is the concrete behavior the in-process
engine lacks (it abandons but never frees the device).
"""
try:
return max(30.0, float(os.environ.get("OMNIVOICE_SIDECAR_RECV_TIMEOUT_S", "300")))
except (ValueError, TypeError):
return 300.0
@property
def sample_rate(self) -> int:
return self._DEFAULT_SAMPLE_RATE
@property
def supported_languages(self) -> list[str]:
# OmniVoice advertises 600+ zero-shot; "multi" is the honest tag.
return ["multi"]
__all__ = ["OmniVoiceSubprocessBackend"]
@@ -1,249 +0,0 @@
"""omnivoice-subprocess sidecar entry point (#730/#1190).
Runs the resident OmniVoice TTS model in a child process under the parent's own
interpreter (same pins), so a wedged generate can be hard-killed by the parent
to reclaim VRAM/device, the thing the in-process ``ThreadPoolExecutor`` worker
structurally cannot do.
Wire protocol: length-prefixed JSON over stdin/stdout, byte-identical to
``services/subprocess_backend.py`` and ``engines/dots_tts/main.py``::
[ 4-byte big-endian uint32 length ][ N bytes UTF-8 JSON ]
Op flow:
1. sidecar -> parent: {"op":"ready","engine":"omnivoice-subprocess",
"sample_rate":24000}
2. parent -> sidecar: {"op":"ping"} -> {"op":"pong","vram_mb":N}
3. parent -> sidecar: {"op":"synthesize","text":"...",
"ref_audio":"/path","ref_text":"...",
"language":"...","num_step":16,
"guidance_scale":2.0,"speed":1.0,...}
-> {"op":"progress",...} (cold load) then
-> {"op":"audio","audio_pcm_b64":"...","sample_rate":24000,
"n_samples":N}
4. parent -> sidecar: {"op":"shutdown"} -> exit 0
Stdlib-only at import time; torch + the OmniVoice model are imported lazily on
the first synthesize so the ``ready`` frame fits the parent's 30s spawn
handshake even on a cold filesystem.
"""
from __future__ import annotations
import base64
import json
import os
import struct
import sys
import traceback
# Mirrors backend/services/subprocess_backend.py::MAX_FRAME_BYTES (T-02-01).
MAX_FRAME_BYTES = 64 * 1024 * 1024
#: OmniVoice's canonical output rate. The real value is re-read from the loaded
#: model on each generate.
OMNIVOICE_SAMPLE_RATE = 24000
#: kwargs model.generate accepts. The parent forwards JSON-safe kwargs from
#: backend.generate(); allowlist the known surface so an unexpected key never
#: reaches model.generate (it has an explicit signature and TypeErrors on
#: unknown kwargs). cache_ref is a parent-side cache marker, not a model param.
_GEN_KW_ALLOWLIST = (
"language", "instruct", "duration", "num_step", "guidance_scale",
"speed", "denoise", "postprocess_output", "preprocess_prompt",
)
_model = None
# ── wire protocol ─────────────────────────────────────────────────────────
def _send(stream, obj: dict) -> None:
body = json.dumps(obj, separators=(",", ":")).encode("utf-8")
stream.write(struct.pack("!I", len(body)))
stream.write(body)
stream.flush()
def _recv(stream):
header = stream.read(4)
if len(header) < 4:
return None # EOF
(n,) = struct.unpack("!I", header)
if n > MAX_FRAME_BYTES:
raise IOError(f"frame too large: {n}")
body = bytearray()
while len(body) < n:
chunk = stream.read(n - len(body))
if not chunk:
raise IOError("short read")
body.extend(chunk)
return json.loads(bytes(body).decode("utf-8"))
def _measure_vram_mb() -> float:
"""This sidecar's own GPU memory in MB. 0 on CPU. Never raises."""
try:
import torch
if torch.cuda.is_available():
return round(torch.cuda.memory_allocated() / (1024 ** 2), 1)
mps = getattr(torch.backends, "mps", None)
if mps is not None and mps.is_available():
return round(torch.mps.driver_allocated_memory() / (1024 ** 2), 1)
except Exception:
pass
return 0.0
# ── model loading (lazy, on first synthesize) ─────────────────────────────
def _ensure_backend_on_path() -> None:
"""The sidecar is launched as ``<python> main.py``, so sys.path[0] is this
script's directory, not ``backend/``. Add ``backend/`` so ``services`` is
importable, letting us reuse the parent's load primitives verbatim."""
root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
if root and root not in sys.path:
sys.path.insert(0, root)
def _load_model(stdout):
"""Cold-construct the OmniVoice model, reusing the parent's load path."""
global _model
if _model is not None:
return _model
_send(stdout, {"op": "progress", "stage": "loading_model", "percent": 0})
_ensure_backend_on_path()
# Reuse the parent's own load primitives (same interpreter): the checkpoint
# resolver, device probe, and ASR-preload policy are exactly what the
# in-process engine uses, so this sidecar loads the identical model.
from services.model_manager import ( # noqa: PLC0415
_lazy_omnivoice,
_lazy_torch,
get_best_device,
resolve_omnivoice_checkpoint,
should_preload_tts_asr,
)
from utils.hf_progress import register_listener, unregister_listener # noqa: PLC0415
# Forward real HF download/weight progress so the parent's recv loop keeps
# its watchdog alive across a slow cold load (the parent consumes these
# {"op": "progress"} frames and re-arms its deadline on each one).
def _on_progress(ev):
pct = ev.get("pct", 0.0)
if pct:
_send(stdout, {"op": "progress", "stage": "loading_model",
"percent": min(round(pct * 100), 99)})
torch = _lazy_torch()
OmniVoice = _lazy_omnivoice()
checkpoint = resolve_omnivoice_checkpoint()
device = get_best_device()
preload_asr = should_preload_tts_asr()
lid = register_listener(_on_progress)
try:
_model = OmniVoice.from_pretrained(
checkpoint, device_map=device, dtype=torch.float16, load_asr=preload_asr,
)
finally:
unregister_listener(lid)
_send(stdout, {"op": "progress", "stage": "loading_model", "percent": 100})
return _model
def _tensor_to_pcm_b64(audio, sample_rate: int) -> tuple[str, int, int]:
"""Convert a torch waveform tensor (1, N) in [-1, 1] to base64 int16 PCM."""
import numpy as np
arr = audio.detach().to("cpu").float().numpy()
arr = np.asarray(arr, dtype=np.float32).squeeze()
if arr.ndim > 1:
arr = arr.mean(axis=0) # defensive downmix to mono
arr = np.clip(arr, -1.0, 1.0)
pcm = (arr * 32767.0).astype(np.int16).tobytes()
return base64.b64encode(pcm).decode("ascii"), int(sample_rate), int(arr.shape[0])
def _handle_synthesize(msg: dict, stdout) -> None:
"""Dispatch one synthesize request. Emits the audio frame or raises."""
text = msg.get("text")
if not text or not isinstance(text, str):
raise ValueError("synthesize: missing or non-string 'text'")
model = _load_model(stdout)
ref_audio = msg.get("ref_audio") or None
ref_text = msg.get("ref_text") or None
gen_kw = {k: msg[k] for k in _GEN_KW_ALLOWLIST if k in msg}
audios = model.generate(
text=text, ref_audio=ref_audio, ref_text=ref_text, **gen_kw
)
audio = audios[0] if isinstance(audios, (list, tuple)) else audios
sample_rate = int(getattr(model, "sampling_rate", OMNIVOICE_SAMPLE_RATE))
pcm_b64, sr, n_samples = _tensor_to_pcm_b64(audio, sample_rate)
_send(stdout, {
"op": "audio",
"audio_pcm_b64": pcm_b64,
"sample_rate": sr,
"n_samples": n_samples,
})
# ── main loop ─────────────────────────────────────────────────────────────
def main() -> int:
stdin = sys.stdin.buffer
stdout = sys.stdout.buffer
# Ready handshake fires BEFORE any heavy import.
_send(stdout, {
"op": "ready",
"engine": "omnivoice-subprocess",
"sample_rate": OMNIVOICE_SAMPLE_RATE,
})
while True:
try:
msg = _recv(stdin)
except Exception as exc:
_send(stdout, {
"op": "error",
"stage": "recv",
"message": f"{type(exc).__name__}: {exc}",
"traceback": traceback.format_exc(),
})
return 1
if msg is None:
return 0
op = msg.get("op") if isinstance(msg, dict) else None
try:
if op == "ping":
_send(stdout, {"op": "pong", "vram_mb": _measure_vram_mb()})
elif op == "synthesize":
_handle_synthesize(msg, stdout)
elif op == "shutdown":
return 0
else:
_send(stdout, {
"op": "error",
"stage": "dispatch",
"message": f"unknown op: {op!r}",
})
except Exception as exc:
_send(stdout, {
"op": "error",
"stage": op or "unknown",
"message": f"{type(exc).__name__}: {exc}",
"traceback": traceback.format_exc(),
})
if __name__ == "__main__":
sys.exit(main())
-111
View File
@@ -1,111 +0,0 @@
"""pockettts: Kyutai PocketTTS as a crash-isolated, CPU-only TTS sidecar (#1306).
PocketTTS (kyutai-labs/pocket-tts, 100M params) is hired for the "fastest CPU
render / lowest latency" job, the row the engine-acceptance framework leaves
unheld: every CPU engine OmniVoice ships is either English-only or a quality
engine falling back to CPU. PocketTTS is the complementary opposite end of the
spectrum from the quality engines (omnivoice, IndexTTS, Supertonic-3): small,
fast, CPU-only, zero-shot cloning from a reference clip. Six languages
(en/fr/de/pt/it/es), one model per language, selected via the ``language``
kwarg. Measured ~8-9x real-time on an Apple M3 Pro (see
scripts/bench_engines_latency.py, PR #1322).
This engine runs PocketTTS in a child process via :class:`SubprocessBackend`,
mirroring engines/omnivoice_subprocess and engines/supertonic3. Crash isolation:
a wedged generate is hard-killed by the parent's watchdog, reclaiming the
child's memory, the thing an in-process engine structurally cannot do. CPU-only
by design (``gpu_compat = ("cpu",)``): Kyutai observes no GPU speedup for this
100M, batch-1 model.
Opt-in (Settings -> Engines, or ``OMNIVOICE_TTS_BACKEND=pockettts``); the
default ``omnivoice`` engine is unchanged, so existing users see no behaviour
change.
Licence: MIT (code) + CC-BY-4.0 (weights), both commercial-OK (cleared from
primary sources in #1306). The weights are gated on HuggingFace (an access
agreement plus an acceptable-use clause); the engine must surface that honestly
at first-run rather than failing inside a download (condition 6 of the #1306
acceptance). That preflight is built on top of this shape, not in it.
Streaming note: PocketTTS streams audio (``generate_audio_stream``), but this
batch sidecar returns one audio frame per synth, matching the SubprocessBackend
contract every other subprocess engine uses. A streaming-aware variant
(incremental audio frames) is a documented opportunity to recover PocketTTS's
~33 ms time-to-first-audio end-to-end; out of scope for this shape, raised on
the PR.
"""
from __future__ import annotations
import math
import os
import sys
from pathlib import Path
from typing import TYPE_CHECKING
from services.subprocess_backend import SubprocessBackend
if TYPE_CHECKING:
import torch # noqa: F401
class PocketTTSBackend(SubprocessBackend):
"""Kyutai PocketTTS in a killable, CPU-only sidecar process."""
id = "pockettts"
display_name = "PocketTTS (Kyutai, 6 langs, CPU-only, MIT/CC-BY-4.0)"
_DEFAULT_SAMPLE_RATE = 24_000
# CPU-only by design (honest hardware reporting, like supertonic3): Kyutai
# ships no CUDA/MPS path and reports no GPU speedup for this model.
gpu_compat: tuple[str, ...] = ("cpu",)
supports_cloning = True # zero-shot clone from a reference clip
@classmethod
def is_available(cls) -> tuple[bool, str]:
# Optional-dep gate: the pocket-tts wheel is installed only when the user
# opted in. The interpreter is the parent's own (sys.executable), so
# there is no separate venv to validate.
try:
import pocket_tts # type: ignore[import-not-found] # noqa: F401
except Exception as e:
return False, (
f"pocket_tts package not installed or failed to import ({e}). "
f"Enable in Settings -> Engines (pip install pocket-tts)."
)
return True, "ready (CPU-only)"
@classmethod
def venv_python(cls) -> Path:
# Parent interpreter: pocket-tts deps (torch>=2.5, scipy, beartype) sit
# happily at the parent's pins, so this isolates for crash recovery, not
# dependency pins (same rationale as omnivoice-subprocess).
return Path(sys.executable)
@classmethod
def sidecar_script(cls) -> Path:
return Path(__file__).resolve().parent / "main.py"
@property
def recv_timeout_s(self) -> float:
# A cold load pulls gated weights (a 24-layer model can be hundreds of MB),
# so allow a long recv deadline; the sidecar also heartbeats progress frames
# during the download (main.py) to keep the watchdog armed.
try:
v = float(os.environ.get("OMNIVOICE_POCKETTTS_RECV_TIMEOUT_S", "600"))
except (ValueError, TypeError):
return 600.0
if not math.isfinite(v): # reject inf/nan so the deadline can't be disabled
return 600.0
return max(30.0, v)
@property
def sample_rate(self) -> int:
return self._DEFAULT_SAMPLE_RATE
@property
def supported_languages(self) -> list[str]:
# Protocol tag; six languages (en/fr/de/pt/it/es), one model per
# language, selected via the language kwarg.
return ["multi"]
__all__ = ["PocketTTSBackend"]
-310
View File
@@ -1,310 +0,0 @@
"""pockettts sidecar entry point (#1306).
Runs Kyutai PocketTTS in a child process under the parent's own interpreter
(same pins), so a wedged generate can be hard-killed by the parent to reclaim
memory. Mirrors engines/omnivoice_subprocess/main.py.
Wire protocol: length-prefixed JSON over stdin/stdout, byte-identical to
services/subprocess_backend.py::
[ 4-byte big-endian uint32 length ][ N bytes UTF-8 JSON ]
Op flow:
1. sidecar -> parent: {"op":"ready","engine":"pockettts","sample_rate":24000}
2. parent -> sidecar: {"op":"ping"} -> {"op":"pong","vram_mb":0}
3. parent -> sidecar: {"op":"synthesize","text":"...",
"ref_audio":"/path/to/ref.wav",
"language":"it"}
-> {"op":"progress",...} (cold load) then
-> {"op":"audio","audio_pcm_b64":"...","sample_rate":24000,
"n_samples":N}
4. parent -> sidecar: {"op":"shutdown"} -> exit 0
Stdlib-only at import time; torch + pocket_tts are imported lazily on the first
synthesize so the ready frame fits the parent's 30s spawn handshake even on a
cold filesystem.
Languages: PocketTTS ships one model per language (en/fr/de/pt/it/es), selected
by ``language``. The first synth in a given language cold-loads + caches that
model; later calls reuse it. (The HF model card's "English only at the moment"
line is stale; the GitHub README and pocket-tts 2.1.0 confirm six languages.)
Note: ``TTSModel.load_model(language=...)`` pulls the gated kyutai weights from
HuggingFace, so it needs HF auth + the access agreement accepted. A failure here
currently surfaces as a raw error frame; the typed "weights are gated" preflight
(condition 6) is built on top of this shape, not in it.
"""
from __future__ import annotations
import base64
import json
import os
import re
import struct
import sys
import threading
import traceback
from collections import OrderedDict
# Mirrors services/subprocess_backend.py::MAX_FRAME_BYTES.
MAX_FRAME_BYTES = 64 * 1024 * 1024
#: PocketTTS emits 24 kHz mono. Re-read from the loaded model on each generate.
POCKETTTS_SAMPLE_RATE = 24_000
#: OmniVoice language (ISO code, name, or sentinel) -> pocket-tts model language.
#: "auto"/"multi"/"na"/None default to english (the library default).
_LANG_MAP = {
"en": "english", "eng": "english", "english": "english",
"fr": "french", "fra": "french", "french": "french",
"de": "german", "deu": "german", "german": "german",
"pt": "portuguese", "por": "portuguese", "portuguese": "portuguese",
"it": "italian", "ita": "italian", "italian": "italian",
"es": "spanish", "esp": "spanish", "spanish": "spanish",
}
#: Default preset voice per language when no reference clip is supplied (public
#: presets from kyutai/tts-voices; voice source does not affect synth speed).
_DEFAULT_VOICE_BY_LANG = {
"english": "alba",
"italian": "giovanni",
"spanish": "lola",
"german": "juergen",
"portuguese": "rafael",
"french": "estelle",
}
#: Emit a progress frame at least this often during a cold load so the parent's
#: recv watchdog doesn't kill a healthy sidecar on a slow first download.
_HEARTBEAT_S = 5.0
#: ref_audio must be a local file path, not a URL (local-first; no SSRF).
_URL_RE = re.compile(r"^[a-z][a-z0-9+.\-]*://", re.IGNORECASE)
#: Bound the per-(language, voice) voice-state cache (LRU) so a long session
#: with many distinct reference clips can't grow memory without limit.
_VOICE_CACHE_MAX = 8
# Per-language model cache: load_model(language=...) is slow and PocketTTS ships
# one model per language, so cache each. Bounded by the distinct languages used
# in a session (at most six).
_MODELS: dict[str, object] = {}
# (language, voice) -> voice_state, LRU-bounded to _VOICE_CACHE_MAX entries.
# get_state_for_audio_prompt is relatively slow, so cache per (language, voice)
# to avoid re-encoding on every call.
_voice_cache: OrderedDict[str, object] = OrderedDict()
# -- wire protocol -----------------------------------------------------------
#: Serializes _send across threads (the cold-load heartbeat + the main loop) so
#: concurrent length+body writes can't interleave and corrupt the framing.
_send_lock = threading.Lock()
def _send(stream, obj: dict) -> None:
body = json.dumps(obj, separators=(",", ":")).encode("utf-8")
with _send_lock:
stream.write(struct.pack("!I", len(body)))
stream.write(body)
stream.flush()
def _recv(stream):
header = stream.read(4)
if len(header) < 4:
return None # EOF
(n,) = struct.unpack("!I", header)
if n > MAX_FRAME_BYTES:
raise IOError(f"frame too large: {n}")
body = bytearray()
while len(body) < n:
chunk = stream.read(n - len(body))
if not chunk:
raise IOError("short read")
body.extend(chunk)
return json.loads(bytes(body).decode("utf-8"))
def _measure_vram_mb() -> float:
"""CPU-only engine: always 0. Kept for protocol parity with the parent."""
return 0.0
# -- model loading (lazy, on first synthesize per language) ------------------
def _pocket_language(raw) -> str:
"""Map an OmniVoice language value to a pocket-tts model language. A specific
but unsupported language raises rather than silently fall back to English and
mispronounce; empty / "auto" / "multi" / "na" default to English."""
if not raw:
return "english"
s = str(raw).strip().lower()
if s in ("", "auto", "multi", "na"):
return "english"
if s in _LANG_MAP:
return _LANG_MAP[s]
raise ValueError(
f"PocketTTS does not support language {raw!r}; supported: en, fr, de, pt, it, es."
)
def _load_model(stdout, language: str):
"""Cold-construct the PocketTTS model for ``language`` (cached per language).
Emits progress frames for the parent watchdog. Raises on failure (e.g.
gated-weights access without HF auth); the caller emits an error frame and
stays alive for a retry."""
model = _MODELS.get(language)
if model is not None:
return model
_send(stdout, {"op": "progress", "stage": "loading_model", "percent": 0})
# Heartbeat: a cold load (gated weights download) can outlast the parent's
# recv timeout. Emit a progress frame every few seconds while it runs so the
# parent's watchdog sees activity and does not kill a healthy sidecar.
stop = threading.Event()
def _heartbeat() -> None:
pct = 1
while not stop.wait(_HEARTBEAT_S):
pct = min(pct + 1, 99)
_send(stdout, {"op": "progress", "stage": "loading_model", "percent": pct})
hb = threading.Thread(target=_heartbeat, daemon=True)
hb.start()
try:
from pocket_tts import TTSModel # type: ignore[import-not-found] # noqa: PLC0415
model = TTSModel.load_model(language=language)
_MODELS[language] = model
finally:
stop.set()
hb.join(timeout=_HEARTBEAT_S + 1)
_send(stdout, {"op": "progress", "stage": "loading_model", "percent": 100})
return model
def _voice_state(model, language: str, ref_audio):
"""Return a (cached, LRU-bounded) voice state for ``ref_audio`` (a local
file path) or the language's default preset voice when none is given. URLs
are rejected to keep the sidecar local-first (no SSRF)."""
if ref_audio and _URL_RE.match(ref_audio):
raise ValueError(
"ref_audio must be a local file path; URLs are not accepted (local-first)."
)
voice = ref_audio or _DEFAULT_VOICE_BY_LANG.get(language, "alba")
# For a local file ref, fold mtime+size into the cache key so a file replaced
# at the same path does not return a stale voice from the previous contents.
fingerprint = ""
if ref_audio:
try:
st = os.stat(ref_audio)
fingerprint = f"|m{st.st_mtime_ns}s{st.st_size}"
except OSError:
fingerprint = ""
key = f"{language}|{voice}{fingerprint}"
state = _voice_cache.get(key)
if state is not None:
_voice_cache.move_to_end(key)
return state
state = model.get_state_for_audio_prompt(voice)
_voice_cache[key] = state
if len(_voice_cache) > _VOICE_CACHE_MAX:
_voice_cache.popitem(last=False) # evict oldest
return state
def _tensor_to_pcm_b64(audio, sample_rate: int) -> tuple[str, int, int]:
"""Convert a float waveform in [-1, 1] to base64 int16 PCM."""
import numpy as np
arr = np.asarray(audio, dtype=np.float32).squeeze()
if arr.ndim > 1:
raise ValueError(
f"expected mono audio (1-D after squeeze), got shape {arr.shape}; "
f"PocketTTS returns mono, so a multi-channel array means an upstream change."
)
arr = np.clip(arr, -1.0, 1.0)
pcm = (arr * 32767.0).astype(np.int16).tobytes()
return base64.b64encode(pcm).decode("ascii"), int(sample_rate), int(arr.shape[-1])
def _handle_synthesize(msg: dict, stdout) -> None:
"""Dispatch one synthesize request. Emits the audio frame or raises."""
text = msg.get("text")
if not text or not isinstance(text, str):
raise ValueError("synthesize: missing or non-string 'text'")
language = _pocket_language(msg.get("language"))
model = _load_model(stdout, language)
ref_audio = msg.get("ref_audio") or None
voice_state = _voice_state(model, language, ref_audio)
audio = model.generate_audio(voice_state, text)
sample_rate = int(getattr(model, "sample_rate", POCKETTTS_SAMPLE_RATE))
pcm_b64, sr, n_samples = _tensor_to_pcm_b64(audio, sample_rate)
_send(stdout, {
"op": "audio",
"audio_pcm_b64": pcm_b64,
"sample_rate": sr,
"n_samples": n_samples,
})
# -- main loop ---------------------------------------------------------------
def main() -> int:
stdin = sys.stdin.buffer
stdout = sys.stdout.buffer
# Ready handshake fires BEFORE any heavy import.
_send(stdout, {
"op": "ready",
"engine": "pockettts",
"sample_rate": POCKETTTS_SAMPLE_RATE,
})
while True:
try:
msg = _recv(stdin)
except Exception as exc:
_send(stdout, {
"op": "error",
"stage": "recv",
"message": f"{type(exc).__name__}: {exc}",
"traceback": traceback.format_exc(),
})
return 1
if msg is None:
return 0
op = msg.get("op") if isinstance(msg, dict) else None
try:
if op == "ping":
_send(stdout, {"op": "pong", "vram_mb": _measure_vram_mb()})
elif op == "synthesize":
_handle_synthesize(msg, stdout)
elif op == "shutdown":
return 0
else:
_send(stdout, {
"op": "error",
"stage": "dispatch",
"message": f"unknown op: {op!r}",
})
except Exception as exc:
_send(stdout, {
"op": "error",
"stage": op or "unknown",
"message": f"{type(exc).__name__}: {exc}",
"traceback": traceback.format_exc(),
})
if __name__ == "__main__":
sys.exit(main())
+10 -136
View File
@@ -383,7 +383,6 @@ 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,
@@ -461,19 +460,6 @@ 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:
@@ -888,25 +874,6 @@ 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).
@@ -919,46 +886,6 @@ 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:
@@ -975,7 +902,15 @@ async def global_exception_handler(request: Request, exc: Exception):
_entry = error_journal.record(
exc, route=str(request.url.path), trace=traceback.format_exc()
)
headers: dict[str, str] = _cors_headers_for(request)
# 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"
# #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
@@ -1360,12 +1295,6 @@ 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.
#
@@ -1376,59 +1305,4 @@ 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")
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
uvicorn.run(app, host=_bind_host, port=_port)
-18
View File
@@ -114,24 +114,6 @@ 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:
+15 -268
View File
@@ -37,7 +37,6 @@ import subprocess
import sys
import threading
import time
from collections import OrderedDict
from typing import AsyncIterator, Optional
import soundfile as sf
@@ -68,45 +67,7 @@ logger = logging.getLogger("omnivoice.dub_pipeline")
# backward compat during the transition.
_dub_jobs: dict[str, dict] = {}
# 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_jobs_lock = threading.Lock()
_DUB_DIR_REAL = os.path.realpath(DUB_DIR)
_HASH_BUF_SIZE = 1 << 18 # 256 KB chunks for hashing
@@ -236,162 +197,6 @@ 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.
@@ -404,22 +209,6 @@ 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())
@@ -767,23 +556,10 @@ _YT_PLAYER_CLIENTS = ["tv", "android", "web_safari"]
def _is_forbidden_download_error(exc: BaseException) -> bool:
"""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.
"""
"""True for an HTTP 403 — not transient (the same client keeps 403ing), but
often fixable by switching the YouTube player client."""
s = str(exc)
if "403" in s or "Forbidden" in s:
return True
low = s.lower()
return "drm protected" in low or "drm-protected" in low
return "403" in s or "Forbidden" in s
def _cleanup_partial_download(job_dir: str) -> None:
@@ -1061,9 +837,6 @@ 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"]
@@ -1232,12 +1005,8 @@ async def ingest_pipeline(
"youtube_subs": youtube_subs_by_lang or None,
"input_type": input_type,
}
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
put_job(job_id, full_job)
save_job(job_id, full_job, filename, dur, content_hash)
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)),
@@ -1260,12 +1029,8 @@ async def ingest_pipeline(
"youtube_subs": youtube_subs_by_lang or None,
"input_type": input_type,
}
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
put_job(job_id, partial)
save_job(job_id, partial, filename, dur, content_hash)
yield prep_event("extract_done", job_id=job_id, duration=round(dur, 2), filename=filename)
vocals_path = os.path.join(job_dir, "vocals.wav")
@@ -1357,30 +1122,13 @@ 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))
# 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
_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)
yield prep_event("ready", job_id=job_id, duration=round(dur, 2), filename=filename)
except asyncio.CancelledError:
@@ -1400,6 +1148,5 @@ 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)
-53
View File
@@ -98,46 +98,6 @@ def _cuda_arch_supported_for_compile() -> "tuple[bool, str]":
return True, ""
def _torch_lib_path_is_linkable() -> tuple[bool, str]:
"""``(ok, reason)`` — False when inductor's C++ link step is guaranteed to
fail because the torch library path contains whitespace (#1266).
Inductor passes the torch lib directory to ``clang++``/``g++`` as an
unquoted ``-L`` flag. A path with a space splits into two arguments and the
compile dies with ``no such file or directory: 'Support/...'``. The bug is
inside PyTorch, so we cannot fix the quoting but a path we already know
cannot compile is one we should not spend a compile attempt on.
It is not hypothetical on any platform: the macOS data dir lives under
``~/Library/Application Support/``, and a Windows user profile is routinely
``C:/Users/First Last``.
Never raises an unreadable torch path means "no reason to skip".
"""
try:
import torch
lib_dir = os.path.join(os.path.dirname(torch.__file__), "lib")
except Exception:
return True, ""
if any(ch.isspace() for ch in lib_dir):
# The path is genuinely useful for diagnosis, but it contains the user's
# home directory and this string is logged and lands in pasted bug
# reports — so it goes through the same redaction every other
# user-facing failure text uses (home → ~, secrets stripped).
try:
from core.failure import sanitize
shown = sanitize(lib_dir)
except Exception:
shown = os.path.basename(lib_dir.rstrip(os.sep)) or "<torch lib>"
return False, (
f"the torch library path contains whitespace ({shown!r}); inductor "
f"passes it to the C++ linker unquoted, so every compile attempt fails"
)
return True, ""
def should_torch_compile(device: str) -> bool:
"""Decide whether to apply ``torch.compile`` to an in-process model.
@@ -175,19 +135,6 @@ def should_torch_compile(device: str) -> bool:
_compile_runtime_failure,
)
return False
linkable, path_reason = _torch_lib_path_is_linkable()
if not linkable:
if _force_compile_requested():
logger.warning(
"torch.compile forced via %s=1 despite: %s", _FORCE_COMPILE_ENV, path_reason,
)
return True
logger.info(
"torch.compile skipped: %s — using eager mode. "
"(Set %s=1 to attempt compile anyway.)",
path_reason, _FORCE_COMPILE_ENV,
)
return False
supported, reason = _cuda_arch_supported_for_compile()
if not supported:
if _force_compile_requested():
-61
View File
@@ -314,67 +314,6 @@ def find_ffprobe():
return None
def ensure_media_tools_on_path() -> list[str]:
"""Put the resolved ffmpeg/ffprobe on ``PATH`` for third-party code (#1256).
OmniVoice's own call sites always resolve an explicit path, so a bundled
sidecar that was never on ``PATH`` works fine for us. Our dependencies do
not get that courtesy: a library that shells out to ``ffprobe`` by bare
name dies with ``FileNotFoundError: [Errno 2] No such file or directory:
'ffprobe'``. The reporter of #1256 hit that mid-synthesis and was told the
engine had "stopped with an error OmniVoice doesn't recognize", on a Mac
where the app's OWN ffprobe was sitting on disk, resolvable, the whole
time.
Prepending the resolved binaries' directories fixes every such dependency
at once, rather than chasing them one import at a time. Prepended (not
appended) so the copy we validated wins over a broken system one.
Returns the directories added. Idempotent, best-effort, never raises.
"""
added: list[str] = []
try:
directories: list[str] = []
for resolve in (find_ffmpeg, find_ffprobe):
try:
path = resolve()
except Exception:
continue
if not path:
continue
directory = os.path.dirname(os.path.abspath(path))
if directory and directory not in directories:
directories.append(directory)
current = os.environ.get("PATH", "")
entries = current.split(os.pathsep) if current else []
# Case-insensitive comparison on Windows/macOS, where PATH is not
# case-sensitive and "already present" must not depend on casing.
normalize = os.path.normcase
present = {normalize(e) for e in entries if e}
for directory in directories:
if normalize(directory) in present:
continue
entries.insert(0, directory)
present.add(normalize(directory))
added.append(directory)
if added:
os.environ["PATH"] = os.pathsep.join(entries)
# Count, not paths: a user-set FFMPEG_PATH resolves under their home
# directory, and absolute home paths must not reach the log
# (#1256 review). find_ffmpeg/find_ffprobe already log their own
# resolution at debug level when that detail is wanted.
logger.info(
"Published %d media-tool director%s on PATH so dependencies can "
"find ffmpeg/ffprobe (#1256)",
len(added), "y" if len(added) == 1 else "ies",
)
except Exception as e: # diagnosis must never break the thing it helps
logger.debug("ensure_media_tools_on_path failed (non-fatal): %s", e)
return added
async def _spawn_async(cmd, **kwargs):
"""Try asyncio subprocess; fall back to thread-based subprocess on Windows
where ProactorEventLoop may not be available (e.g. under uvicorn --reload)."""
-39
View File
@@ -225,45 +225,6 @@ 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}")
+18 -158
View File
@@ -59,52 +59,8 @@ logger = logging.getLogger("omnivoice.model")
_GPU_VRAM_PER_JOB_GB = 5.0
_GPU_WORKER_CAP = 4
class WorkerStopIteration(RuntimeError):
"""A pool worker raised a bare ``StopIteration``.
asyncio refuses to put ``StopIteration`` into a Future ``_copy_future_
state`` raises ``TypeError: StopIteration interacts badly with generators
and cannot be raised into a Future`` *inside the event loop's callback*, so
the ``run_in_executor`` future is never completed and the awaiting caller
waits **forever**. Not a theoretical edge: verified on the bundled CPython
3.11, and the failure has no error, no event and no timeout a render just
stops, which is indistinguishable to the user from a wedged app.
Generator-driven engines reach it on ordinary bad input: VoxCPM's
``next_and_close`` is a bare ``next(gen)``, so a generator that ends without
yielding (text the model normalises away to nothing, for instance) raises
exactly this out of ``backend.generate`` (#1321 class).
Translating it to a RuntimeError at the pool boundary the one place every
dispatch funnels through turns a silent hang into a normal failure that
the existing per-chapter / per-job error handling reports. Subclasses
RuntimeError so every `except Exception` site upstream keeps working.
"""
def _guard_stopiteration(fn):
"""Wrap `fn` so a bare StopIteration can never escape into a Future."""
def _guarded(*a, **kw):
try:
return fn(*a, **kw)
except StopIteration as e:
raise WorkerStopIteration(
"the engine stopped without producing a result (StopIteration) — "
"its generator ended before yielding anything, which usually means "
"it could not handle this input"
) from e
return _guarded
class _GuardedCpuPool(ThreadPoolExecutor):
"""CPU pool with the same StopIteration guard as the GPU pool."""
def submit(self, fn, /, *args, **kwargs):
return super().submit(_guard_stopiteration(fn), *args, **kwargs)
_gpu_pool_singleton: "_ResilientGpuPool | None" = None
_cpu_pool = _GuardedCpuPool(max_workers=CPU_POOL_WORKERS)
_cpu_pool = ThreadPoolExecutor(max_workers=CPU_POOL_WORKERS)
def _workers_for_free_vram(free_gb: float) -> int:
@@ -151,28 +107,9 @@ def _pick_gpu_workers() -> int:
return 1
# thread_name_prefix for the GPU pool, centralised so the "am I on a gpu-pool
# worker?" predicates (running_on_gpu_pool below; SubprocessBackend.generate's
# on-pool skip) cannot drift from the pool's actual prefix. A drift would
# silently re-introduce the 1-worker self-deadlock this couples against.
_GPU_POOL_THREAD_PREFIX = "gpu-pool"
def _build_gpu_pool() -> ThreadPoolExecutor:
workers = _pick_gpu_workers()
return ThreadPoolExecutor(
max_workers=workers, thread_name_prefix=_GPU_POOL_THREAD_PREFIX)
def running_on_gpu_pool() -> bool:
"""True iff the calling thread is a gpu-pool worker (already holds a slot).
Routes that dispatch backend work via run_on_gpu_pool_guarded are already on
a pool worker; re-acquiring a slot there would self-deadlock on a 1-worker
pool (MPS). Used by SubprocessBackend.generate()'s on-pool skip and by
_heal_tts_placement.
"""
return threading.current_thread().name.startswith(_GPU_POOL_THREAD_PREFIX)
return ThreadPoolExecutor(max_workers=workers, thread_name_prefix="gpu-pool")
class _ResilientGpuPool(Executor):
@@ -242,9 +179,7 @@ class _ResilientGpuPool(Executor):
self._running += 1
t0 = time.monotonic()
try:
# A bare StopIteration here would never reach the caller — it
# hangs the awaiting future instead (see WorkerStopIteration).
return _guard_stopiteration(fn)(*a, **kw)
return fn(*a, **kw)
finally:
elapsed = time.monotonic() - t0
with self._stats_lock:
@@ -814,8 +749,8 @@ def check_device_compatibility():
return False, (
f"{device_name} ({device_arch}) is not supported by this PyTorch build. "
f"Supported architectures: {', '.join(arch_list)}. "
f"Install a build that covers it: pip install --force-reinstall torch "
f"--index-url https://download.pytorch.org/whl/cu128"
f"Try: pip install torch --index-url "
f"https://download.pytorch.org/whl/nightly/cu128"
)
@@ -1089,23 +1024,14 @@ 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 / #1273).
"""True when `exc` is the truncated-HF-cache class (#352 / #581).
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))
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)
def _hf_offline() -> bool:
@@ -1749,13 +1675,6 @@ async def get_model():
# contract unnecessary: a future unbalanced offload can no longer
# strand the model, because the next generation moves it back.
await _heal_tts_placement()
# Free idle GPU memory before this warm generate reuses the resident
# model. The cold-load path already evicts (_make_room_before_tts_load);
# this closes the WARM path for every native TTS generate (/generate, WS
# TTS, dub, batch, audiobook), not just a couple of routes. No-op on a
# roomy machine. Off the event loop because the eviction does gc.collect
# + cache drop + ASR teardown that can block for hundreds of ms.
await asyncio.get_running_loop().run_in_executor(None, make_room_before_generate)
return model
async with _model_lock:
@@ -1790,77 +1709,18 @@ def _make_room_before_tts_load() -> None:
if free_gb is None or free_gb >= _UNIFIED_OFFLOAD_HEADROOM_GB:
return
logger.info(
"Memory tight before TTS load (%.1f GB free), releasing idle "
"Memory tight before TTS load (%.1f GB free) releasing idle "
"models first.", free_gb,
)
_release_idle_tts_memory("load")
except Exception: # noqa: BLE001 -- making room must never break loading
logger.debug("pre-load memory reclaim skipped", exc_info=True)
def _release_idle_tts_memory(stage):
"""Drop capture-ASR, TTS side caches, and allocator caches. Best-effort;
never raises (a cleanup failure must not break the load/generate that called
it). Shared by the cold-load and warm-generate make-room paths so the
eviction recipe cannot drift between them (#730/#1190)."""
try:
try:
from services.asr_backend import release_idle_capture_backend
release_idle_capture_backend(0.0) # 0s idle = release if unleased
except Exception: # noqa: BLE001 -- best-effort, never blocks the caller
logger.debug("capture-ASR pre-%s release failed", stage, exc_info=True)
except Exception: # noqa: BLE001 best-effort, never block the load
logger.debug("capture-ASR pre-load release failed", exc_info=True)
release_tts_side_caches()
free_vram()
except Exception: # noqa: BLE001 -- a cleanup failure must never break the caller
logger.debug("pre-%s memory reclaim skipped", stage, exc_info=True)
def _should_make_room_for_generate():
"""Decide whether to free idle GPU memory before a generate (#730/#1190).
Modes (OMNIVOICE_FREE_VRAM_BEFORE_GENERATE):
auto (default): free when free system RAM is below the unified headroom,
mirroring _make_room_before_tts_load. A roomy machine pays nothing.
always: free before every generate (small per-call cost from gc.collect +
cache drop).
never: opt out.
"""
mode = os.environ.get("OMNIVOICE_FREE_VRAM_BEFORE_GENERATE", "auto").strip().lower()
if mode == "never":
return False
if mode == "always":
return True
try:
from services.memory_budget import available_memory
free_gb = (available_memory() or {}).get("ram_available_gb")
if free_gb is not None and free_gb < _UNIFIED_OFFLOAD_HEADROOM_GB:
return True
except Exception: # noqa: BLE001 -- a probe failure must never block a generate
logger.debug("make_room memory probe failed", exc_info=True)
return False
def make_room_before_generate():
"""Free idle GPU memory before a warm, heavy generate (#730/#1190).
The cold LOAD path already evicts (``_make_room_before_tts_load`` runs inside
``_load_model_with_timeout``), but the warm path (model already resident,
``get_model`` returns early at the cache check) skipped it. A long generate
on a VRAM-tight MPS box then contended with capture-ASR and the clone-prompt
side cache until it exceeded the execution budget and was abandoned, which is
exactly how one slow synth cascaded into a stuck, device-holding backend.
This runs the same fail-safe eviction the load path uses, just before a
generate the policy says is likely to starve.
Deliberately NOT admission control and NOT a device reclaim. It only drops
things the app already releases on idle, just now instead of later, so a
roomy machine or a short synth pays nothing. It cannot kill an already
abandoned worker; only a crash-isolated subprocess engine can (see
services.subprocess_backend).
"""
if not _should_make_room_for_generate():
return
_release_idle_tts_memory("generate")
except Exception: # noqa: BLE001 — making room must never break loading
logger.debug("pre-load memory reclaim skipped", exc_info=True)
def _checkpoint_in_local_cache(checkpoint: str) -> bool:
@@ -2245,7 +2105,7 @@ async def _heal_tts_placement() -> None:
"""
if _stranded_tts_target() is None:
return
if running_on_gpu_pool():
if threading.current_thread().name.startswith("gpu-pool"):
# Reached from a GPU-pool thread — OmniVoiceBackend._ensure_loaded()
# bootstraps a fresh loop with asyncio.run(get_model()) from inside
# generate(). We already hold the GPU slot, so we already have the
+9 -25
View File
@@ -23,7 +23,6 @@ from __future__ import annotations
import logging
import sys
import threading
from pathlib import Path
from services.subprocess_backend import (
@@ -82,31 +81,17 @@ class SubprocessASRBackend(SubprocessBackend):
broken-pipe) and the *next* call respawns a fresh sidecar via
``_spawn``'s dead-process check. Acquires a GPU-pool slot for the
duration, released even if the child dies (the base's try/finally)."""
# On-pool callers (run_transcribe_guarded dispatches via run_in_executor
# on the GPU pool) already own a pool slot; re-acquiring would
# self-deadlock on a 1-worker (MPS) pool, so skip it. Off-pool callers
# hold a real slot for the whole transcription via _occupy. Mirrors
# SubprocessBackend.generate()'s path-aware slot block.
from services.model_manager import running_on_gpu_pool
_held = None
slot_future = None
if not running_on_gpu_pool():
from services.model_manager import _get_gpu_pool
pool = _get_gpu_pool()
_held = threading.Event()
_acquired = threading.Event()
from services.model_manager import _get_gpu_pool
def _occupy():
_acquired.set()
_held.wait()
slot_future = pool.submit(_occupy)
pool = _get_gpu_pool()
slot = pool.submit(lambda: None)
try:
slot.result(timeout=10)
except Exception:
slot.cancel()
raise
try:
if _held is not None and not _acquired.wait(timeout=10):
if slot_future is not None:
slot_future.cancel()
raise TimeoutError("timed out waiting for a free GPU worker")
with self._lock:
self._spawn()
self._send({
@@ -133,8 +118,7 @@ class SubprocessASRBackend(SubprocessBackend):
)
return reply.get("result") or {"segments": [], "language": "unknown"}
finally:
if _held is not None:
_held.set()
pass # slot returns to the pool when the no-op task completes
class IsolatedFasterWhisperBackend(SubprocessASRBackend):
+20 -51
View File
@@ -280,15 +280,6 @@ class SubprocessBackend(TTSBackend):
# Default sample rate; subclasses override.
_DEFAULT_SAMPLE_RATE = 24000
# Per-engine recv timeout for generate(): how long the parent waits for the
# sidecar's audio frame before the watchdog hard-kills the child and reclaims
# its VRAM/device. Default is the conservative RECV_TIMEOUT_S (60s). A
# subclass whose legitimate generates run longer overrides it (or exposes it
# as a property) so a slow-but-valid synth is not falsely killed, while a
# genuinely wedged one is still reclaimed. health_check() keeps using
# RECV_TIMEOUT_S directly, since a ping must stay fast.
recv_timeout_s: float = RECV_TIMEOUT_S
# ── instance state (initialised in __init__) ───────────────────────────
def __init__(self) -> None:
@@ -527,39 +518,22 @@ class SubprocessBackend(TTSBackend):
sample rate. Decodes the int16 PCM the sidecar returns into float32
in [-1, 1].
"""
# On-pool callers (every HTTP/dub/batch generate, dispatched via
# run_on_gpu_pool_guarded) already own a pool slot; re-acquiring would
# self-deadlock on a 1-worker (MPS) pool, so skip it. Off-pool callers
# (the deep-synth diagnostic probe in diagnose.py; the Settings
# self-test rejects subprocess-isolated engines with a 400) hold a real
# slot for the whole synthesis via _occupy so they serialize against
# pool jobs instead of over-subscribing the GPU.
from services.model_manager import running_on_gpu_pool
_held = None
# Bound before the branch: only the off-pool path assigns a real
# future, and `_held is not None` already implies that — but CodeQL
# (py/uninitialized-local-variable) reads the two as independent, and
# so would anyone adding a third exit path later.
slot_future = None
if not running_on_gpu_pool():
# Lazy-import the GPU pool so importing this module doesn't pull in
# the entire model_manager + torch ecosystem at registry-listing time.
from services.model_manager import _get_gpu_pool
pool = _get_gpu_pool()
_held = threading.Event()
_acquired = threading.Event()
# Lazy-import the GPU pool so importing this module doesn't pull in
# the entire model_manager + torch ecosystem at registry-listing time.
from services.model_manager import _get_gpu_pool
def _occupy():
_acquired.set()
_held.wait()
slot_future = pool.submit(_occupy)
# Acquire a GPU pool worker for the duration of this generate. The
# try/finally guarantees the slot is released even if the sidecar
# dies mid-frame (T-02-02 / Pitfall 7).
pool = _get_gpu_pool()
slot_future = pool.submit(lambda: None)
try:
slot_future.result(timeout=10) # wait for our turn
except Exception:
slot_future.cancel()
raise
try:
if _held is not None and not _acquired.wait(timeout=10):
if slot_future is not None:
slot_future.cancel()
raise TimeoutError("timed out waiting for a free GPU worker")
with self._lock:
self._spawn()
msg = {"op": "synthesize", "text": text}
@@ -570,13 +544,7 @@ class SubprocessBackend(TTSBackend):
if _is_jsonable(v):
msg[k] = v
self._send(msg)
reply = self._recv_with_timeout(self.recv_timeout_s)
# A cold sidecar may emit non-terminal {"op": "progress"} frames
# (during a model load, etc.) before the terminal audio frame.
# Each recv re-arms the watchdog, so a long-but-active load
# survives while a silent wedge is still killed at the deadline.
while reply is not None and reply.get("op") == "progress":
reply = self._recv_with_timeout(self.recv_timeout_s)
reply = self._recv_with_timeout(RECV_TIMEOUT_S)
if not reply:
raise RuntimeError(f"{self.id} sidecar closed pipe mid-generate")
if reply.get("op") == "error":
@@ -593,11 +561,12 @@ class SubprocessBackend(TTSBackend):
tensor = torch.from_numpy(arr.copy()).unsqueeze(0)
return tensor
finally:
# Release the held GPU-pool worker (off-pool path only). _occupy
# blocks the worker until this fires, so the slot is held for the
# whole synthesis even though this thread isn't the pool worker.
if _held is not None:
_held.set()
# Slot is released the instant this thread leaves the pool's
# task — by holding slot_future we kept one worker busy; nothing
# further to do. (ThreadPoolExecutor doesn't expose a manual
# release; the slot returns to the pool when our submitted no-op
# finishes, which happens immediately after .result() above.)
pass
# ── wire protocol ──────────────────────────────────────────────────────
+22 -151
View File
@@ -106,87 +106,27 @@ 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 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:
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,
)
try:
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
from huggingface_hub.utils import close_session
close_session()
except Exception: # pragma: no cover — hub too old / API renamed
logger.warning(
"%s: model download failed (%s); retrying (attempt %d/%d). "
"Already-downloaded files are reused.",
what, e, attempt, attempts,
"%s: couldn't reset the HF Hub client; retrying anyway.", what,
)
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
return loader()
# ── Protocol ────────────────────────────────────────────────────────────────
@@ -834,12 +774,7 @@ 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)
# #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",
)
self._model = VoxCPM.from_pretrained(checkpoint, load_denoiser=False)
def generate(self, text, **kw) -> torch.Tensor:
self._ensure_loaded()
@@ -911,34 +846,6 @@ class VoxCPM2Backend(TTSBackend):
# ── MOSS-TTS-Nano adapter (tiny, CPU-friendly, 20 langs) ────────────────────
# ── MOSS-TTS-Nano entry-point resolution (#1287) ────────────────────────────
# The upstream repo is installed straight from git (`pip install -e .`) with no
# pinned release, and the class it exports has changed. Rather than hard-import
# one name and fail at generate time, resolve among the names it has used and
# report honestly when none is present.
_MOSS_CLASS_NAMES = ("MossTTSNano", "MOSSTTSNano", "MossTTS", "MossTTSNanoForCausalLM")
def _moss_model_class(module):
"""The first known MOSS model class on ``module``, or None."""
for name in _MOSS_CLASS_NAMES:
cls = getattr(module, name, None)
if cls is not None and hasattr(cls, "from_pretrained"):
return cls
return None
def _moss_candidate_exports(module):
"""Public names on ``module`` that look like a model class — so the error
can say what IS there instead of only what is missing."""
return [
n
for n in dir(module)
if not n.startswith("_")
and hasattr(getattr(module, n, None), "from_pretrained")
]
class MossTTSNanoBackend(TTSBackend):
"""OpenMOSS MOSS-TTS-Nano-100M — the low-resource / broad-language pick.
@@ -972,27 +879,13 @@ class MossTTSNanoBackend(TTSBackend):
try:
# MOSS ships its own package alongside the HF weights.
import moss_tts_nano # noqa: F401
return True, "ready"
except ImportError:
return False, (
"moss_tts_nano package not installed. Install from "
"https://github.com/OpenMOSS/MOSS-TTS-Nano "
"(`pip install -e .`), then set OMNIVOICE_TTS_BACKEND=moss-tts-nano."
)
# Importing the MODULE is not enough (#1287). The user had the package
# installed, so this reported "ready", they switched engine, and the
# first generate died with `cannot import name 'MossTTSNano'` — the
# upstream repo is unpinned and moves. An availability check that does
# not verify the API it will actually call is a check that lies.
if _moss_model_class(moss_tts_nano) is None:
exported = ", ".join(_moss_candidate_exports(moss_tts_nano)) or "no model class"
return False, (
"moss_tts_nano is installed but does not expose a usable model "
f"class (found: {exported}). MOSS-TTS-Nano is unpinned upstream and "
"its entry point has changed before — pull the latest "
"github.com/OpenMOSS/MOSS-TTS-Nano and re-run `pip install -e .`, "
"or open an issue with the version you have so the name can be added."
)
return True, "ready"
@property
def sample_rate(self) -> int:
@@ -1011,21 +904,12 @@ class MossTTSNanoBackend(TTSBackend):
ok, msg = self.is_available()
if not ok:
raise RuntimeError(f"MOSS-TTS-Nano unavailable: {msg}")
import moss_tts_nano # type: ignore[import-not-found]
model_cls = _moss_model_class(moss_tts_nano)
if model_cls is None: # pragma: no cover - is_available() gates this
raise RuntimeError(
"moss_tts_nano exposes no usable model class; see Settings → Engines"
)
from moss_tts_nano import MossTTSNano # type: ignore[import-not-found]
checkpoint = os.environ.get(
"OMNIVOICE_MOSS_TTS_MODEL", "OpenMOSS-Team/MOSS-TTS-Nano"
)
logger.info("Loading MOSS-TTS-Nano from %s", checkpoint)
self._model = _retry_once_with_fresh_hf_client(
lambda: model_cls.from_pretrained(checkpoint, trust_remote_code=True),
"MOSS-TTS-Nano",
)
self._model = MossTTSNano.from_pretrained(checkpoint, trust_remote_code=True)
def generate(self, text, **kw) -> torch.Tensor:
self._ensure_loaded()
@@ -1896,17 +1780,6 @@ _LAZY_REGISTRY: dict[str, tuple[str, str]] = {
# IndexTTS2. Lazy for the same import-cycle reason as the entries above.
"moss-tts-v15": ("engines.moss_tts_v15", "MossTTSV15Backend"),
"dots-tts": ("engines.dots_tts", "DotsTTSBackend"),
# The resident OmniVoice model in a crash-isolated sidecar (#730/#1190):
# same model and quality as the in-process "omnivoice" engine, but a wedged
# generate can be hard-killed to reclaim VRAM/device. Opt-in (the in-process
# engine stays the default). Unlike the entries above it runs under the
# parent interpreter (crash isolation, not dependency isolation).
"omnivoice-subprocess": ("engines.omnivoice_subprocess", "OmniVoiceSubprocessBackend"),
# Issue #1306: Kyutai PocketTTS, CPU-only, low-latency TTS hired for the
# "fastest CPU render / lowest latency" job. Opt-in, subprocess-isolated
# under the parent interpreter (crash isolation, not dependency isolation,
# same as omnivoice-subprocess: pocket-tts deps sit at the parent's pins).
"pockettts": ("engines.pockettts", "PocketTTSBackend"),
# Issue #590: Confucius4-TTS (netease-youdao) — LLM-based, 14-language
# cross-lingual zero-shot cloning, Apache-2.0. Opt-in + subprocess-isolated
# (own Python 3.10 venv) like the entries above. Validated end-to-end
@@ -2002,7 +1875,6 @@ _LAST_ERRORS: dict[str, str] = {}
# Helps users understand what pip package to install and where.
_INSTALL_HINTS: dict[str, str] = {
"omnivoice": "pip install omnivoice (bundled — no extra install needed)",
"omnivoice-subprocess": "No extra install; uses the host OmniVoice install. Opt in with OMNIVOICE_TTS_BACKEND=omnivoice-subprocess (same model in a killable sidecar, for unattended reliability).",
"cosyvoice": "git clone --recursive FunAudioLLM/CosyVoice + pip install -r requirements.txt + SoX",
"kittentts": "pip install kittentts (ONNX, CPU-only, ~80 MB)",
"mlx-audio": "pip install mlx-audio (Apple Silicon only)",
@@ -2013,7 +1885,6 @@ _INSTALL_HINTS: dict[str, str] = {
"sherpa-onnx": "pip install sherpa-onnx (universal ONNX runtime, WASM-ready)",
"omnivoice-gguf":"Bundled — runs the C++ omnivoice-tts binary in bin/. Quants download lazily from Serveurperso/OmniVoice-GGUF on first generate.",
"supertonic3": "uv sync --extra supertonic (CPU-only ONNX, 31 langs, ~400 MB model on first use; OpenRAIL-M model license)",
"pockettts": "pip install pocket-tts (Kyutai, CPU-only, ~100 MB model on first use; MIT code + CC-BY-4.0 weights; weights are HF-gated, set HF_TOKEN)",
"moss-tts-v15": "git clone OpenMOSS/MOSS-TTS + set OMNIVOICE_MOSS_TTS_V15_DIR (own venv, transformers==5.0; 8B, ~16 GB weights; CUDA/CPU, no MPS; Apache-2.0)",
"dots-tts": "git clone rednote-hilab/dots.tts + set OMNIVOICE_DOTS_TTS_DIR (own venv, transformers==4.57; 2B, ~9 GB weights; CUDA/CPU, Linux/macOS only — no Windows; Apache-2.0)",
"confucius4-tts":"git clone netease-youdao/Confucius4-TTS + set OMNIVOICE_CONFUCIUS4_TTS_DIR (own Python 3.10 venv; 14-lang cross-lingual zero-shot clone; ~5 GB weights auto-download; CUDA/CPU, no MPS; Apache-2.0)",
-68
View File
@@ -109,71 +109,3 @@ def _clear_asr_installed_memo(request):
_clear_all()
yield
_clear_all()
@pytest.fixture(autouse=True)
def _clean_model_manager_shutdown_state(request):
"""Start every test with the model manager NOT in shutdown mode (#1269).
``model_manager._shutting_down`` is a module-global Event and the GPU pool is
a module-global executor. Any test that runs the app lifespan flips both on
the way out ``begin_shutdown()`` plus ``_reset_gpu_pool()`` and nothing
puts them back, because in production that state is correct: the process is
ending.
Across a combined ``pytest tests/ backend/tests/`` session it is not
correct, and it is not a cosmetic leak. A test that arrives with the flag set
finds a shut-down executor, so its very first ``run_in_executor`` raises
"cannot schedule new futures after shutdown" which the preload path
classifies as a benign shutdown and swallows. The symptom is a load that
silently never starts: ``test_lifespan_shutdown_mid_load_is_clean_and_clears
_sentinel`` failed on ``assert started.is_set()`` for exactly this reason,
while passing alone.
Reset before AND after: before so an inherited flag cannot decide this test,
after so a test that legitimately shuts down does not hand the state on.
Cleans the module in ``sys.modules`` AND any module-typed alias the test
module holds (``import services.model_manager as mm`` at module scope) the
same stale-alias class ``asr_model_installed`` above handles. Test modules
bind that alias at COLLECTION time; ``tests/backend/**`` purges
``services.*`` from ``sys.modules`` after every test it owns, so in a
combined ``pytest tests/ backend/tests/`` run the alias and the live module
are two different objects. Cleaning only one of them means a test dirties
the alias and the next test reads it still dirty
(``test_shutdown_state_isolation.py::test_next_test_starts_clean``).
"""
import types
def _targets():
# Import rather than probe sys.modules: unchanged from the original
# fixture, and it guarantees a live module to reset even in a run where
# a sibling suite purged the name.
import services.model_manager as mod
# `import x.y as z` binds the PACKAGE ATTRIBUTE, which can diverge from
# the sys.modules entry after module surgery — take both.
found = {id(m): m for m in (mod, sys.modules.get("services.model_manager"))
if m is not None}
test_module = getattr(request, "module", None)
if test_module is not None:
for val in vars(test_module).values():
if (isinstance(val, types.ModuleType)
and getattr(val, "__name__", "") == "services.model_manager"):
found[id(val)] = val
return found.values()
def _clean():
# Deliberately NOT wrapped in try/except. A reset that fails silently
# leaves the next test with stale shutdown or executor state, which is
# precisely the order-dependent failure this fixture exists to remove —
# swallowing the error would defeat the fixture while looking like it
# worked (CodeRabbit). If either of these can raise, that is a real
# problem in model_manager and it should be loud.
for mod in _targets():
mod.reset_shutdown_flag()
mod._reset_gpu_pool()
_clean()
yield
_clean()
@@ -1,75 +0,0 @@
"""A warm generate must free idle GPU memory first when the box is tight
(#730/#1190).
The cold LOAD path already evicts via _make_room_before_tts_load (inside
_load_model_with_timeout). The warm path (model already resident, get_model
returns early at the cache check) skipped it, so a generate on a VRAM-tight
MPS box contended with capture-ASR and the clone-prompt side cache until it
exceeded the execution budget and was abandoned (#730/#1190).
make_room_before_generate, called from get_model()'s warm-return path, closes
that gap for every native TTS generate; the policy is the one real
reliability-vs-latency knob.
"""
import services.model_manager as mm
from services.model_manager import make_room_before_generate
def _count(monkeypatch):
"""Patch the three eviction primitives and count how often each runs."""
calls = {"free_vram": 0, "side_caches": 0, "capture_asr": 0}
monkeypatch.setattr(mm, "free_vram", lambda: calls.__setitem__("free_vram", calls["free_vram"] + 1))
monkeypatch.setattr(
mm, "release_tts_side_caches",
lambda: calls.__setitem__("side_caches", calls["side_caches"] + 1),
)
import services.asr_backend as ab
monkeypatch.setattr(
ab, "release_idle_capture_backend",
lambda _idle_s: calls.__setitem__("capture_asr", calls["capture_asr"] + 1),
)
return calls
def _ram(monkeypatch, gb):
import services.memory_budget as mb
monkeypatch.setattr(mb, "available_memory", lambda: {"ram_available_gb": gb})
def test_never_mode_frees_nothing(monkeypatch):
monkeypatch.setenv("OMNIVOICE_FREE_VRAM_BEFORE_GENERATE", "never")
calls = _count(monkeypatch)
make_room_before_generate()
assert calls == {"free_vram": 0, "side_caches": 0, "capture_asr": 0}
def test_always_mode_frees_all(monkeypatch):
monkeypatch.setenv("OMNIVOICE_FREE_VRAM_BEFORE_GENERATE", "always")
calls = _count(monkeypatch)
make_room_before_generate()
assert calls["free_vram"] == 1 and calls["side_caches"] == 1 and calls["capture_asr"] == 1
def test_auto_skips_on_roomy_machine(monkeypatch):
monkeypatch.delenv("OMNIVOICE_FREE_VRAM_BEFORE_GENERATE", raising=False)
_ram(monkeypatch, 999.0) # ample RAM -> a roomy machine pays nothing
calls = _count(monkeypatch)
make_room_before_generate()
assert calls == {"free_vram": 0, "side_caches": 0, "capture_asr": 0}
def test_auto_frees_on_tight_ram(monkeypatch):
monkeypatch.delenv("OMNIVOICE_FREE_VRAM_BEFORE_GENERATE", raising=False)
_ram(monkeypatch, 0.5) # below the 6.0 GB unified headroom
calls = _count(monkeypatch)
make_room_before_generate()
assert calls["free_vram"] == 1 and calls["side_caches"] == 1
def test_default_mode_is_auto(monkeypatch):
# No env set at all must behave as auto (tight RAM triggers).
monkeypatch.delenv("OMNIVOICE_FREE_VRAM_BEFORE_GENERATE", raising=False)
_ram(monkeypatch, 0.5)
calls = _count(monkeypatch)
make_room_before_generate()
assert calls["free_vram"] == 1
+55 -218
View File
@@ -14,10 +14,8 @@ instead. See model_manager.py and main.py's lifespan wiring
(``begin_shutdown``/``reset_shutdown_flag``).
"""
import asyncio
import contextlib
import logging
import os
import sys
import threading
import types
@@ -29,48 +27,6 @@ from services.model_manager import (
_is_interpreter_shutdown_error,
)
_PURGED_PREFIXES = ("core.", "api.", "services.")
_PURGED_NAMES = ("main", "core", "api", "services")
@contextlib.contextmanager
def _reimported_backend_modules():
"""Run the block against FRESHLY imported ``main``/``core``/``api``/
``services`` modules, then put the originals back.
The module-level ``import services.model_manager as mm`` above binds at
COLLECTION time. ``tests/backend/conftest.py`` purges exactly these names
from ``sys.modules`` after every test it owns, so in a combined
``pytest tests/ backend/tests/`` run a later ``import main`` here builds its
lifespan on a NEW ``services.model_manager`` object and the ``mm`` alias
is a stale copy whose globals nothing reads. ``monkeypatch.setattr(mm, ...)``
then patched nothing: the real ``preload_model`` ran, found no fake loader,
and the test failed on ``assert started.is_set()`` while passing alone
(#1269 residual).
Tests that only exercise model_manager stay self-consistent on the alias.
Any test that patches model_manager and then drives *main* must resolve the
live module this context manager makes that path deterministic in
isolation, so the bug reproduces without needing the sibling suite.
"""
saved = {
name: mod for name, mod in sys.modules.items()
if name in _PURGED_NAMES or name.startswith(_PURGED_PREFIXES)
}
def _purge():
for name in [n for n in sys.modules
if n in _PURGED_NAMES or n.startswith(_PURGED_PREFIXES)]:
sys.modules.pop(name, None)
_purge()
try:
yield
finally:
# Drop whatever the block imported, then restore the originals — a
# half-restored tree (fresh submodule under a stale package) is exactly
# the split-import hazard this guards against.
_purge()
sys.modules.update(saved)
@pytest.fixture(autouse=True)
def _fresh_shutdown_flag():
@@ -317,180 +273,61 @@ def test_lifespan_shutdown_mid_load_is_clean_and_clears_sentinel(
interaction the #1174 fix has to preserve)."""
from fastapi import FastAPI
with _reimported_backend_modules():
import main as main_mod
from core import run_sentinel
import main as main_mod
from core import run_sentinel
# The module object main's lifespan actually loads through — NOT the
# module-level `mm` alias, which a sibling suite's sys.modules purge can
# leave stale (see _reimported_backend_modules).
live_mm = sys.modules["services.model_manager"]
monkeypatch.setattr(run_sentinel, "SENTINEL_PATH", str(tmp_path / "run_sentinel.json"))
monkeypatch.setattr(run_sentinel, "CRASH_RECORD_PATH", str(tmp_path / "last_run_crash.json"))
monkeypatch.setattr(run_sentinel, "LOG_PATH", str(tmp_path / "omnivoice.log"))
run_sentinel._reset_for_tests()
monkeypatch.setattr(run_sentinel, "SENTINEL_PATH", str(tmp_path / "run_sentinel.json"))
monkeypatch.setattr(run_sentinel, "CRASH_RECORD_PATH", str(tmp_path / "last_run_crash.json"))
monkeypatch.setattr(run_sentinel, "LOG_PATH", str(tmp_path / "omnivoice.log"))
fake_torch = types.SimpleNamespace(
float16="f16",
cuda=types.SimpleNamespace(is_available=lambda: False),
backends=types.SimpleNamespace(),
)
monkeypatch.setattr(mm, "_lazy_torch", lambda: fake_torch)
monkeypatch.setattr(mm, "model", None)
monkeypatch.setattr(mm, "_model_lock", asyncio.Lock())
monkeypatch.setattr(mm, "_checkpoint_in_local_cache", lambda c: True)
monkeypatch.setenv("OMNIVOICE_PRELOAD_CAPTURE_ASR", "0")
started = threading.Event()
release = threading.Event()
def _wedged_load():
started.set()
release.wait(30)
raise RuntimeError("cannot schedule new futures after interpreter shutdown")
async def _fake_load_with_timeout():
loop = asyncio.get_running_loop()
return await loop.run_in_executor(mm._get_gpu_pool(), _wedged_load)
monkeypatch.setattr(mm, "_load_model_with_timeout", _fake_load_with_timeout)
async def scenario():
app = FastAPI()
async with main_mod.lifespan(app):
# The preload's load really is in flight on a pool thread. Poll
# asynchronously — a blocking Event.wait would starve the loop the
# preload task needs to reach run_in_executor.
for _ in range(200):
if started.is_set():
break
await asyncio.sleep(0.05)
assert started.is_set()
# Lifespan shutdown completed while that thread was still wedged.
try:
asyncio.run(scenario())
# The clean shutdown retired the sentinel…
assert not os.path.exists(run_sentinel.SENTINEL_PATH)
# …so the next startup must NOT see a crash.
assert run_sentinel.detect_unclean_shutdown() is None
# And the model_manager was flipped into shutdown mode first, so the
# wedged load classifies executor rejections as benign.
assert mm.is_shutting_down() is True
finally:
release.set()
run_sentinel._reset_for_tests()
fake_torch = types.SimpleNamespace(
float16="f16",
cuda=types.SimpleNamespace(is_available=lambda: False),
backends=types.SimpleNamespace(),
)
monkeypatch.setattr(live_mm, "_lazy_torch", lambda: fake_torch)
monkeypatch.setattr(live_mm, "model", None)
monkeypatch.setattr(live_mm, "_model_lock", asyncio.Lock())
monkeypatch.setattr(live_mm, "_checkpoint_in_local_cache", lambda c: True)
monkeypatch.setenv("OMNIVOICE_PRELOAD_CAPTURE_ASR", "0")
# A fresh import inherits nothing from the previous lifespan, but an
# in-place one would: arm loads explicitly so a leaked shutdown flag
# can't make the preload bail before it starts.
live_mm.reset_shutdown_flag()
started = threading.Event()
release = threading.Event()
def _wedged_load():
started.set()
release.wait(30)
raise RuntimeError("cannot schedule new futures after interpreter shutdown")
async def _fake_load_with_timeout():
loop = asyncio.get_running_loop()
return await loop.run_in_executor(live_mm._get_gpu_pool(), _wedged_load)
monkeypatch.setattr(live_mm, "_load_model_with_timeout", _fake_load_with_timeout)
async def scenario():
app = FastAPI()
async with main_mod.lifespan(app):
# The preload's load really is in flight on a pool thread. Poll
# asynchronously — a blocking Event.wait would starve the loop the
# preload task needs to reach run_in_executor.
for _ in range(200):
if started.is_set():
break
await asyncio.sleep(0.05)
assert started.is_set()
# Lifespan shutdown completed while that thread was still wedged.
try:
asyncio.run(scenario())
# The clean shutdown retired the sentinel…
assert not os.path.exists(run_sentinel.SENTINEL_PATH)
# …so the next startup must NOT see a crash.
assert run_sentinel.detect_unclean_shutdown() is None
# And the model_manager was flipped into shutdown mode first, so the
# wedged load classifies executor rejections as benign.
assert live_mm.is_shutting_down() is True
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
-156
View File
@@ -1,156 +0,0 @@
"""Regression: an off-pool SubprocessBackend.generate() must HOLD its GPU-pool
slot for the whole synthesis, not just queue-wait.
Pre-fix, the slot was a bare no-op that completed the instant a worker picked
it up, releasing the worker before _spawn(). So an off-pool caller (engine
self-test, diagnostics) could synthesize concurrently with an in-flight pool
job and over-subscribe a 1-worker GPU. This test reproduces that: while an
off-pool generate is mid-synthesis, a second pool job must stay blocked.
"""
import base64
import json
import math
import array
import sys
import threading
import time
from concurrent.futures import ThreadPoolExecutor
from concurrent.futures import TimeoutError as FuturesTimeoutError
from pathlib import Path
import pytest
from services.subprocess_backend import SubprocessBackend
# Stub sidecar that SLEEPS during synthesize so the test can observe whether
# the slot is held for the synth duration.
STUB_SIDECAR = r'''
import sys, json, struct, math, array, base64, time
def _send(o):
b = json.dumps(o, separators=(",", ":")).encode()
sys.stdout.buffer.write(struct.pack("!I", len(b)) + b)
sys.stdout.buffer.flush()
def _recv():
h = sys.stdin.buffer.read(4)
if len(h) < 4:
return None
(n,) = struct.unpack("!I", h)
body = bytearray()
while len(body) < n:
c = sys.stdin.buffer.read(n - len(body))
if not c:
return None
body.extend(c)
return json.loads(bytes(body).decode())
_send({"op": "ready", "engine": "stub", "sample_rate": 24000})
while True:
m = _recv()
if m is None:
sys.exit(0)
op = m.get("op")
if op == "ping":
_send({"op": "pong", "vram_mb": 0.0})
elif op == "shutdown":
sys.exit(0)
elif op == "synthesize":
time.sleep(2) # hold the synth so the test can inspect the held slot
sr = 24000
pcm = array.array("h", (int(32767 * math.sin(2 * math.pi * 440 * i / sr)) for i in range(sr)))
_send({"op": "audio", "audio_pcm_b64": base64.b64encode(pcm.tobytes()).decode(),
"sample_rate": sr, "n_samples": sr})
else:
_send({"op": "error", "stage": "dispatch", "message": "unknown op %r" % op})
'''
class _StubBackend(SubprocessBackend):
id = "stub-hold"
display_name = "stub"
gpu_compat = ("cuda", "mps", "cpu")
@classmethod
def is_available(cls):
return True, "ok"
@classmethod
def venv_python(cls):
return Path(sys.executable)
@classmethod
def sidecar_script(cls):
raise NotImplementedError # patched per-test
@property
def sample_rate(self):
return 24000
@property
def supported_languages(self):
return ["multi"]
def test_off_pool_generate_holds_slot_for_synthesis(tmp_path, monkeypatch):
stub = tmp_path / "stub_sidecar.py"
stub.write_text(STUB_SIDECAR)
monkeypatch.setattr(_StubBackend, "sidecar_script",
classmethod(lambda cls: stub))
import services.model_manager as mm
pool = ThreadPoolExecutor(max_workers=1, thread_name_prefix="gpu-pool")
# Signalled the moment the slot-holding task actually STARTS on the worker.
# A sleep here is not synchronization: too short and the marker is enqueued
# before the generator has reserved anything (the assertion then passes for
# the wrong reason on a slow runner), too long and the test just idles.
occupying = threading.Event()
_real_submit = pool.submit
_wrapped = {"done": False}
def _tracking_submit(fn, *a, **k):
# Only the FIRST submit is the generator's slot claim; the marker below
# must go through untouched.
if _wrapped["done"]:
return _real_submit(fn, *a, **k)
_wrapped["done"] = True
def _seen(*aa, **kk):
occupying.set()
return fn(*aa, **kk)
return _real_submit(_seen, *a, **k)
monkeypatch.setattr(pool, "submit", _tracking_submit)
monkeypatch.setattr(mm, "_get_gpu_pool", lambda: pool)
b = _StubBackend()
box = {}
def _gen():
try:
b.generate("off-pool")
except Exception as exc: # noqa: BLE001
box["error"] = exc
gen_thread = threading.Thread(target=_gen, name="off-pool-caller", daemon=True)
try:
gen_thread.start()
assert occupying.wait(timeout=30), "off-pool generate never claimed a slot"
# A second pool job must NOT run while the off-pool generate holds the
# 1-worker pool's slot. Pre-fix (bare no-op), the worker was already
# free and this would complete immediately.
marker = pool.submit(lambda: "ran")
with pytest.raises(FuturesTimeoutError):
marker.result(timeout=1.0)
gen_thread.join(timeout=30)
assert "error" not in box, f"generate failed: {box.get('error')}"
assert marker.result(timeout=10) == "ran" # ran once the slot released
finally:
# Runs even when an assertion above fails — otherwise a red test leaks
# a sidecar process and a pool thread into the rest of the session.
b.shutdown()
pool.shutdown(wait=False)
-224
View File
@@ -1,224 +0,0 @@
"""omnivoice-subprocess engine: registry wiring, recv-timeout override, and the
hard-kill-on-timeout recovery that is the whole point of the engine (#730/#1190).
The in-process engine's abandoned worker thread cannot be killed and holds the
MPS device; a subprocess engine's child CAN be hard-killed (proc.kill() in
SubprocessBackend._timeout_kill), reclaiming VRAM/device, and the next request
respawns a fresh sidecar. The hard-kill test below is the deterministic proof,
the direct counterpart to the in-process ThreadPoolExecutor reproducer where
the zombie outlives the reset.
CI stays model-free: the roundtrip/hard-kill tests spawn a stub sidecar that
speaks the wire protocol and either returns a sine wave or wedges forever
(text == "HANG"), instead of loading the multi-GB OmniVoice model.
"""
import struct
import json
import math
import array
import base64
import pytest
from services.subprocess_backend import SubprocessBackend, RECV_TIMEOUT_S
from services.tts_backend import get_backend_class
from engines.omnivoice_subprocess import OmniVoiceSubprocessBackend
# ── stub sidecar (model-free) ──────────────────────────────────────────────
STUB_SIDECAR = r'''
import sys, json, struct, time, math, array, base64
def _send(o):
b = json.dumps(o, separators=(",", ":")).encode()
sys.stdout.buffer.write(struct.pack("!I", len(b)) + b)
sys.stdout.buffer.flush()
def _recv():
h = sys.stdin.buffer.read(4)
if len(h) < 4:
return None
(n,) = struct.unpack("!I", h)
body = bytearray()
while len(body) < n:
c = sys.stdin.buffer.read(n - len(body))
if not c:
return None
body.extend(c)
return json.loads(bytes(body).decode())
_send({"op": "ready", "engine": "omnivoice-subprocess", "sample_rate": 24000})
while True:
m = _recv()
if m is None:
sys.exit(0)
op = m.get("op")
if op == "ping":
_send({"op": "pong", "vram_mb": 0.0})
elif op == "shutdown":
sys.exit(0)
elif op == "synthesize":
t = m.get("text", "")
if t == "HANG":
while True: # wedge forever; the parent must hard-kill us
time.sleep(1)
# Emit progress frames before the audio when asked, to exercise the
# parent's progress-consuming recv loop (the cold-load fix).
if t.startswith("PROG:"):
for p in (10, 50, 90):
_send({"op": "progress", "stage": "loading_model", "percent": p})
sr = 24000
pcm = array.array("h", (int(32767 * math.sin(2 * math.pi * 440 * i / sr)) for i in range(sr)))
_send({"op": "audio", "audio_pcm_b64": base64.b64encode(pcm.tobytes()).decode(),
"sample_rate": sr, "n_samples": sr})
else:
_send({"op": "error", "stage": "dispatch", "message": "unknown op %r" % op})
'''
@pytest.fixture
def stub_sidecar(tmp_path):
p = tmp_path / "stub_sidecar.py"
p.write_text(STUB_SIDECAR)
return p
def _use_stub(monkeypatch, stub_path):
monkeypatch.setattr(
OmniVoiceSubprocessBackend, "sidecar_script",
classmethod(lambda cls: stub_path),
)
# ── registry + isolation ───────────────────────────────────────────────────
def test_registry_resolves_to_subprocess_backend():
assert get_backend_class("omnivoice-subprocess") is OmniVoiceSubprocessBackend
def test_is_marked_subprocess_isolated():
# list_backends() detects isolation via this duck-typed marker, not issubclass.
assert getattr(OmniVoiceSubprocessBackend, "_is_subprocess_isolated", False) is True
def test_is_available_returns_tuple():
ok, msg = OmniVoiceSubprocessBackend.is_available()
assert isinstance(ok, bool)
assert isinstance(msg, str)
# ── recv-timeout override (the F1-1 base-class hook) ───────────────────────
class _PlainBackend(SubprocessBackend):
"""Minimal concrete subclass that does NOT override recv_timeout_s."""
id = "plain"
@classmethod
def is_available(cls):
return True, "ok"
@property
def sample_rate(self):
return 24000
@property
def supported_languages(self):
return ["multi"]
def test_base_default_recv_timeout_is_60s():
# A subclass that does NOT override keeps the conservative default, so the
# existing subprocess engines (IndexTTS, dots.tts, ...) are byte-identical.
assert SubprocessBackend.recv_timeout_s == RECV_TIMEOUT_S == 60.0
assert _PlainBackend().recv_timeout_s == 60.0
def test_omnivoice_subprocess_recv_timeout_overrides_default():
b = OmniVoiceSubprocessBackend()
assert b.recv_timeout_s == 300.0 # aligns with the generate budget
def test_omnivoice_subprocess_recv_timeout_env_override(monkeypatch):
monkeypatch.setenv("OMNIVOICE_SIDECAR_RECV_TIMEOUT_S", "120")
assert OmniVoiceSubprocessBackend().recv_timeout_s == 120.0
def test_omnivoice_subprocess_recv_timeout_floors_at_30s(monkeypatch):
# A misconfigured tiny value must still leave time for a real handshake.
monkeypatch.setenv("OMNIVOICE_SIDECAR_RECV_TIMEOUT_S", "1")
assert OmniVoiceSubprocessBackend().recv_timeout_s == 30.0
# ── roundtrip via the stub sidecar ─────────────────────────────────────────
def test_roundtrip_synthesize_returns_audio_tensor(stub_sidecar, monkeypatch):
_use_stub(monkeypatch, stub_sidecar)
b = OmniVoiceSubprocessBackend()
try:
tensor = b.generate("hello")
assert tensor.shape[0] == 1 # (1, n_samples)
assert tensor.shape[1] == 24000 # 1s of 24 kHz from the stub
assert tensor.abs().max() > 0.0 # non-silent sine
finally:
b.shutdown()
def test_generate_consumes_progress_frames_before_audio(stub_sidecar, monkeypatch):
# Regression for the cold-load bug: a sidecar that emits {"op": "progress"}
# frames (as the real one does during a model load) before the audio frame
# must NOT make generate() raise "unexpected op". The base loops on progress.
_use_stub(monkeypatch, stub_sidecar)
b = OmniVoiceSubprocessBackend()
try:
tensor = b.generate("PROG:hello") # stub emits 3 progress frames first
assert tensor.shape[1] == 24000 # got the audio despite the progress
finally:
b.shutdown()
# ── hard-kill on timeout + recovery (the load-bearing regression) ──────────
def test_wedged_sidecar_is_hard_killed_and_recovers(stub_sidecar, monkeypatch):
_use_stub(monkeypatch, stub_sidecar)
# Short effective timeout so the test is fast. The property floors env at
# 30s, so drive the watchdog directly via the class attribute the base reads.
monkeypatch.setattr(OmniVoiceSubprocessBackend, "recv_timeout_s",
property(lambda self: 2.0))
b = OmniVoiceSubprocessBackend()
try:
# 1. A wedged generate raises (the watchdog kills the child at 2s, the
# pipe closes, _recv returns None -> "closed pipe").
with pytest.raises(RuntimeError):
b.generate("HANG")
# 2. The child is actually dead, the thing the in-process engine cannot do.
assert b._proc is not None
assert b._proc.poll() is not None
# 3. Recovery: the next generate respawns a fresh sidecar and succeeds.
tensor = b.generate("ok")
assert tensor.shape[1] == 24000
finally:
b.shutdown()
def test_generate_does_not_deadlock_when_called_on_gpu_pool_worker(stub_sidecar, monkeypatch):
# Regression: /v1/audio/speech and /generate dispatch backend.generate() via
# run_on_gpu_pool_guarded, i.e. ON a gpu-pool worker. generate() must NOT
# acquire a second slot from the same 1-worker pool (self-deadlock on MPS):
# before the fix, the inner pool.submit queued behind this very job and
# slot_future.result(timeout=10) raised before the sidecar ever spawned.
_use_stub(monkeypatch, stub_sidecar)
from services.model_manager import _get_gpu_pool
b = OmniVoiceSubprocessBackend()
pool = _get_gpu_pool()
try:
# Mirror run_on_gpu_pool_guarded: run generate() on a pool worker thread.
fut = pool.submit(lambda: b.generate("on-pool"))
tensor = fut.result(timeout=30) # pre-fix: raised ~10s slot timeout
assert tensor.shape[1] == 24000
finally:
b.shutdown()
@@ -1,95 +0,0 @@
"""A bare ``StopIteration`` from a pool worker must fail, not hang (#1321).
asyncio refuses to put ``StopIteration`` into a Future. The refusal does not
raise where anyone can catch it: ``_copy_future_state`` raises ``TypeError:
StopIteration interacts badly with generators and cannot be raised into a
Future`` inside the event loop's *callback*, the ``run_in_executor`` future is
left pending, and the awaiting coroutine waits forever. No error, no event, no
timeout an audiobook render simply stops emitting and the app looks wedged.
This is reachable from ordinary user input. Engines that drive a generator hit
it on text they cannot handle: VoxCPM's ``next_and_close`` is a bare
``next(gen)``, so a generator that ends without yielding raises ``StopIteration``
straight out of ``backend.generate`` and into the GPU-pool worker.
Both tests below hang forever without the guard (hence the explicit
``wait_for`` a hang must fail the suite, not stall CI until the job timeout).
"""
import asyncio
import pytest
import services.model_manager as mm
def _raise_bare_stopiteration():
raise StopIteration()
def _run_on(executor):
async def _scenario():
loop = asyncio.get_running_loop()
# Bounded on purpose: the bug this guards against is an infinite wait.
return await asyncio.wait_for(
loop.run_in_executor(executor, _raise_bare_stopiteration), timeout=15
)
return asyncio.run(_scenario())
def test_gpu_pool_translates_bare_stopiteration():
"""The GPU pool is where every engine/model dispatch runs."""
with pytest.raises(mm.WorkerStopIteration):
_run_on(mm._get_gpu_pool())
def test_cpu_pool_translates_bare_stopiteration():
"""The CPU offload pool takes the same class of callable (dub_core parks
loads and mixes on it), so the guard has to cover it too."""
with pytest.raises(mm.WorkerStopIteration):
_run_on(mm._cpu_pool)
def test_translated_error_is_a_runtimeerror():
"""Upstream handlers catch ``Exception`` (and often ``RuntimeError``) — the
translation must not slip past them into a bare-except crash path."""
assert issubclass(mm.WorkerStopIteration, RuntimeError)
def test_reason_is_non_empty_and_actionable():
"""``str(StopIteration())`` is ``''``. A translation that kept an empty
message would trade a hang for "unknown error", which is the failure class
core.failure exists to prevent."""
with pytest.raises(mm.WorkerStopIteration) as ei:
_run_on(mm._get_gpu_pool())
text = str(ei.value)
assert text.strip()
assert "StopIteration" in text
# The original is kept as the cause, so the traceback still points at the
# engine frame that actually stopped.
assert isinstance(ei.value.__cause__, StopIteration)
def test_normal_exceptions_are_untouched():
"""The guard is narrow: only StopIteration is translated."""
def _boom():
raise ValueError("ordinary failure")
async def _scenario():
loop = asyncio.get_running_loop()
return await asyncio.wait_for(
loop.run_in_executor(mm._get_gpu_pool(), _boom), timeout=15
)
with pytest.raises(ValueError, match="ordinary failure"):
asyncio.run(_scenario())
def test_return_values_pass_through():
async def _scenario():
loop = asyncio.get_running_loop()
return await asyncio.wait_for(
loop.run_in_executor(mm._get_gpu_pool(), lambda: 42), timeout=15
)
assert asyncio.run(_scenario()) == 42
@@ -1,112 +0,0 @@
"""The autouse shutdown-state reset must actually reset, and fail loudly.
``conftest._clean_model_manager_shutdown_state`` is the fix for #1269: a test
that runs the app lifespan leaves ``model_manager._shutting_down`` set and the
GPU pool torn down, and the next test then finds every ``run_in_executor``
raising "cannot schedule new futures after shutdown" swallowed by the preload
path as a benign shutdown, so the symptom is a load that silently never starts.
A fixture with nothing asserting it is a fixture nobody notices breaking. The
two pairs below are deliberately order-dependent (pytest runs tests in
definition order within a file): the first test of each pair dirties exactly the
state the lifespan dirties, the second asserts it arrived clean. Delete the
fixture and the second test fails; that is the fail-before/pass-after this file
exists to provide.
The second pair covers the stale-alias half of the fixture. Test modules bind
``import services.model_manager as mm`` at COLLECTION time, and ``tests/backend/
**`` purges ``services.*`` from ``sys.modules`` after every test it owns so in
a combined ``pytest tests/ backend/tests/`` run the alias and the live module
are two different objects, and a fixture that cleans only ``sys.modules`` leaves
the alias dirty. That is not hypothetical: it is how ``test_next_test_starts_
clean`` below failed in combined runs while passing alone. The pair fabricates
the duplicate module explicitly so the condition reproduces in isolation.
"""
import importlib
import os
import sys
import services.model_manager as mm
_CONFTEST = os.path.join(os.path.dirname(os.path.abspath(__file__)), "conftest.py")
def test_dirty_the_shutdown_state():
"""Stand in for any lifespan-running test: leave the module globals in the
exact state graceful shutdown leaves them."""
mm.begin_shutdown()
mm._reset_gpu_pool()
assert mm.is_shutting_down()
def test_next_test_starts_clean():
"""Runs immediately after the test above and must not inherit its state."""
assert not mm.is_shutting_down(), (
"the shutdown flag leaked from the previous test — the autouse fixture "
"in backend/tests/conftest.py is not resetting it, and every executor "
"submit in this test will now raise 'cannot schedule new futures after "
"shutdown' and be misread as a benign cancellation (#1269)"
)
# Bound by the test below to a SECOND copy of services.model_manager — the
# stand-in for the alias a sibling suite's sys.modules purge strands. Module
# scope on purpose: the fixture discovers cleanup targets by scanning this
# module's globals, exactly as it discovers a real `import ... as mm` alias.
stale_mm = None
def test_dirty_a_stale_module_alias():
"""Leave a *duplicate* model_manager module in the post-shutdown state."""
global stale_mm
live = sys.modules.pop("services.model_manager")
try:
stale_mm = importlib.import_module("services.model_manager")
finally:
sys.modules["services.model_manager"] = live
# Re-point the PACKAGE attribute too. `import services.model_manager as
# mod` reads it (not sys.modules) for the `as` binding, so leaving it on
# the duplicate would hand the fixture the very module this test just
# stranded — the guard would pass for the wrong reason.
sys.modules["services"].model_manager = live
assert stale_mm is not live, "expected a genuinely distinct module object"
assert importlib.import_module("services.model_manager") is live
stale_mm.begin_shutdown()
stale_mm._reset_gpu_pool()
assert stale_mm.is_shutting_down()
def test_stale_module_alias_starts_clean():
"""Runs immediately after the test above. The fixture has to reach the
alias, not just ``sys.modules["services.model_manager"]``."""
assert stale_mm is not None, "the previous test did not run — check ordering"
assert not stale_mm.is_shutting_down(), (
"the shutdown flag leaked on a stale duplicate of services.model_manager "
"— the autouse fixture in backend/tests/conftest.py is only cleaning the "
"module in sys.modules, so in a combined `pytest tests/ backend/tests/` "
"run every backend/tests module that holds an `import services."
"model_manager as mm` alias starts dirty (#1269)"
)
def test_reset_failures_are_not_swallowed():
"""A reset wrapped in ``except: pass`` hands the next test stale state while
reporting success the fixture would look like it worked and #1269 would
come back with the evidence removed. Mechanical, so it stays true."""
with open(_CONFTEST, encoding="utf-8") as fh:
src = fh.read()
marker = "def _clean_model_manager_shutdown_state("
assert marker in src, f"fixture renamed or removed from {_CONFTEST}"
body = src.split(marker, 1)[1].split("\n@", 1)[0]
# Comments and the docstring explain *why* there is no try/except; only the
# code is evidence of whether there is one.
code = "\n".join(
line for line in body.split('"""')[-1].splitlines()
if not line.lstrip().startswith("#")
)
assert "except" not in code, (
"the shutdown-state reset catches exceptions; a failed reset must fail "
"the test that caused it, not leak into the next one:\n" + code
)
@@ -1,152 +0,0 @@
"""Regression: SubprocessASRBackend.transcribe() must not self-deadlock when
called from a gpu-pool worker.
run_transcribe_guarded dispatches transcribe() via loop.run_in_executor on the
GPU pool, i.e. already on a pool worker. transcribe() used to unconditionally
submit a no-op to the same pool to "acquire a slot"; on a 1-worker pool (MPS)
that queued behind the very job running it and result(timeout=10) raised before
the sidecar spawned. This test reproduces that dispatch shape.
"""
import json
import struct
import sys
from pathlib import Path
from concurrent.futures import ThreadPoolExecutor
import pytest
from services.subprocess_asr import SubprocessASRBackend
STUB_SIDECAR = r'''
import sys, json, struct
def _send(o):
b = json.dumps(o, separators=(",", ":")).encode()
sys.stdout.buffer.write(struct.pack("!I", len(b)) + b)
sys.stdout.buffer.flush()
def _recv():
h = sys.stdin.buffer.read(4)
if len(h) < 4:
return None
(n,) = struct.unpack("!I", h)
body = bytearray()
while len(body) < n:
c = sys.stdin.buffer.read(n - len(body))
if not c:
return None
body.extend(c)
return json.loads(bytes(body).decode())
_send({"op": "ready", "engine": "stub-asr", "sample_rate": 16000})
while True:
m = _recv()
if m is None:
sys.exit(0)
op = m.get("op")
if op == "ping":
_send({"op": "pong", "vram_mb": 0.0})
elif op == "shutdown":
sys.exit(0)
elif op == "transcribe":
_send({"op": "segments", "result": {"segments": [], "language": "en"}})
else:
_send({"op": "error", "stage": "dispatch", "message": "unknown op %r" % op})
'''
class _StubASR(SubprocessASRBackend):
id = "stub-asr"
display_name = "stub-asr"
gpu_compat = ("cuda", "mps", "cpu")
@classmethod
def is_available(cls):
return True, "ok"
@classmethod
def venv_python(cls):
return Path(sys.executable)
@classmethod
def sidecar_script(cls):
raise NotImplementedError # patched per-test
def _device(self):
return "cpu"
@property
def sample_rate(self):
return 16000
@property
def supported_languages(self):
return ["multi"]
def test_transcribe_on_pool_worker_does_not_deadlock(tmp_path, monkeypatch):
stub = tmp_path / "stub_asr.py"
stub.write_text(STUB_SIDECAR)
monkeypatch.setattr(_StubASR, "sidecar_script",
classmethod(lambda cls: stub))
import services.model_manager as mm
pool = ThreadPoolExecutor(max_workers=1, thread_name_prefix="gpu-pool")
monkeypatch.setattr(mm, "_get_gpu_pool", lambda: pool)
b = _StubASR()
try:
fut = pool.submit(lambda: b.transcribe("/fake.wav"))
result = fut.result(timeout=30) # pre-fix: raised ~10s slot timeout
assert "segments" in result
finally:
b.shutdown()
pool.shutdown(wait=False)
def test_transcribe_off_pool_holds_a_slot(tmp_path, monkeypatch):
"""The other half of the branch — and the half that had no test.
Only the on-pool path above was covered, so `threading.Event()` in the
off-pool branch shipped with `threading` never imported: every direct
caller (the diagnostic probe) hit NameError before the sidecar started.
A branch with no test is how a one-word bug reaches CI.
Also asserts the slot is genuinely HELD: a second pool job must not run
while an off-pool transcription is in flight, or a 1-worker GPU gets
over-subscribed.
"""
import threading
stub = tmp_path / "stub_asr.py"
stub.write_text(STUB_SIDECAR)
monkeypatch.setattr(_StubASR, "sidecar_script",
classmethod(lambda cls: stub))
import services.model_manager as mm
pool = ThreadPoolExecutor(max_workers=1, thread_name_prefix="gpu-pool")
monkeypatch.setattr(mm, "_get_gpu_pool", lambda: pool)
b = _StubASR()
box = {}
def _call():
try:
box["result"] = b.transcribe("/fake.wav")
except Exception as exc: # noqa: BLE001
box["error"] = exc
# Deliberately NOT on a pool worker — this is the direct-caller shape.
caller = threading.Thread(target=_call, name="off-pool-asr", daemon=True)
try:
caller.start()
caller.join(timeout=30)
assert "error" not in box, f"off-pool transcribe failed: {box.get('error')}"
assert "segments" in box["result"]
# The slot must be back once the call returned.
marker = pool.submit(lambda: "ran")
assert marker.result(timeout=10) == "ran"
finally:
b.shutdown()
pool.shutdown(wait=False)
@@ -1,116 +0,0 @@
"""Regression: SubprocessBackend.generate() must not self-deadlock when called
from a gpu-pool worker.
/v1/audio/speech and /generate dispatch backend.generate() via
run_on_gpu_pool_guarded, i.e. already ON a gpu-pool worker. generate() used to
unconditionally submit a no-op to the same pool to "acquire a slot"; on a
1-worker pool (MPS) that submit queued behind the very job running it and
result(timeout=10) raised before the sidecar spawned. This test reproduces that
dispatch shape (generate on a pool worker) against a stub sidecar.
"""
import base64
import json
import math
import array
import sys
from pathlib import Path
import pytest
from services.subprocess_backend import SubprocessBackend
# Model-free stub sidecar: speaks the length-prefixed-JSON protocol and returns
# a 1s sine wave for any synthesize.
STUB_SIDECAR = r'''
import sys, json, struct, math, array, base64
def _send(o):
b = json.dumps(o, separators=(",", ":")).encode()
sys.stdout.buffer.write(struct.pack("!I", len(b)) + b)
sys.stdout.buffer.flush()
def _recv():
h = sys.stdin.buffer.read(4)
if len(h) < 4:
return None
(n,) = struct.unpack("!I", h)
body = bytearray()
while len(body) < n:
c = sys.stdin.buffer.read(n - len(body))
if not c:
return None
body.extend(c)
return json.loads(bytes(body).decode())
_send({"op": "ready", "engine": "stub", "sample_rate": 24000})
while True:
m = _recv()
if m is None:
sys.exit(0)
op = m.get("op")
if op == "ping":
_send({"op": "pong", "vram_mb": 0.0})
elif op == "shutdown":
sys.exit(0)
elif op == "synthesize":
sr = 24000
pcm = array.array("h", (int(32767 * math.sin(2 * math.pi * 440 * i / sr)) for i in range(sr)))
_send({"op": "audio", "audio_pcm_b64": base64.b64encode(pcm.tobytes()).decode(),
"sample_rate": sr, "n_samples": sr})
else:
_send({"op": "error", "stage": "dispatch", "message": "unknown op %r" % op})
'''
class _StubSubprocessBackend(SubprocessBackend):
"""Minimal concrete SubprocessBackend pointing at the stub sidecar."""
id = "stub-subprocess"
display_name = "stub"
gpu_compat = ("cuda", "mps", "cpu")
@classmethod
def is_available(cls):
return True, "ok"
@classmethod
def venv_python(cls):
return Path(sys.executable)
@classmethod
def sidecar_script(cls):
raise NotImplementedError # patched per-test
@property
def sample_rate(self):
return 24000
@property
def supported_languages(self):
return ["multi"]
def test_generate_on_pool_worker_does_not_deadlock(tmp_path, monkeypatch):
# Mirror /v1/audio/speech + /generate: dispatch generate() ON a gpu-pool
# worker. Force a 1-worker pool so the self-deadlock reproduces
# deterministically regardless of host (the ambient pool may have >1
# worker): pre-fix, generate()'s inner slot-submit queued behind this very
# job and slot_future.result(timeout=10) raised at ~10s, before the sidecar
# spawned.
stub = tmp_path / "stub_sidecar.py"
stub.write_text(STUB_SIDECAR)
monkeypatch.setattr(_StubSubprocessBackend, "sidecar_script",
classmethod(lambda cls: stub))
from concurrent.futures import ThreadPoolExecutor
import services.model_manager as mm
pool = ThreadPoolExecutor(max_workers=1, thread_name_prefix="gpu-pool")
monkeypatch.setattr(mm, "_get_gpu_pool", lambda: pool)
b = _StubSubprocessBackend()
try:
fut = pool.submit(lambda: b.generate("on-pool"))
tensor = fut.result(timeout=30) # pre-fix: raised ~10s slot timeout
assert tensor.shape[1] == 24000
finally:
b.shutdown()
pool.shutdown(wait=False)
+2 -2
View File
@@ -90,12 +90,12 @@ There's also a Compose file in the repo with `cpu` / `gpu` / `rocm` profiles
|-----|--------------|
| `:latest` | **Rolling preview** — latest commit on `main`, at or ahead of the last release. This is the preview channel; pin `:stable` for production. |
| `:stable` | Most recent versioned release (updated on every `v*` git tag) |
| `:0.4.1` | Exact release version |
| `:0.4.0` | Exact release version |
| `:0.4` | Latest patch within the `0.4` minor |
| `:main` | Alias of the same rolling `main` build as `:latest` |
| `:sha-xxxxxxx` | A specific commit (produced by manual workflow dispatch) |
| `:rocm` | **AMD GPU (ROCm) build** of the rolling preview — the ROCm analogue of `:latest` |
| `:stable-rocm`, `:0.4.1-rocm`, `:0.4-rocm`, `:sha-xxxxxxx-rocm` | ROCm builds of the corresponding tags above |
| `:stable-rocm`, `:0.4.0-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
-69
View File
@@ -1,69 +0,0 @@
# Adding a TTS or ASR engine
OmniVoice ships a lot of engines. That breadth is only an asset while every one
of them still works on every platform — otherwise it is a pile of support
queues, and the project's actual promise (*a first run that works*) is what pays
for it.
So new engines are **hired for a job**, not added to a list.
## The job map
Every engine in the tree owns at least one job. A job has exactly one holder.
| Job | Held by |
| --- | --- |
| Best zero-shot clone quality | `omnivoice` |
| Widest language coverage | `omnivoice` |
| Crash isolation for the default model | `omnivoice-subprocess` |
| Fastest CPU render / lowest latency | *open — see #1306* |
| Best Chinese/Japanese expressiveness | `cosyvoice`, `indextts2` |
| CPU-realtime English, tiny footprint | `kittentts`, `supertonic3` |
| Best transcription accuracy | `whisperx`, `faster-whisper` |
| Fastest Apple-Silicon transcription | `parakeet-mlx`, `mlx-whisper` |
| Crash isolation for transcription | `faster-whisper-isolated` |
A proposal must either **take a job from its current holder** (with numbers) or
**claim a job nothing covers**. "It benchmarks well" is not a job.
## The bar
A new engine is accepted when all of these hold. Miss one and the answer is no —
which is a property of the bar, not a judgement of the contributor.
1. **A job, named.** Which row above it takes or adds, and why the incumbent
does not cover it. Latency, language, hardware envelope or quality tier —
something a user would choose it *for*.
2. **Licence clean for commercial use.** Model weights *and* code. No
research-only weights, no ambiguous provenance. This is the one that most
often ends a proposal, so check it first.
3. **Every platform, or explicitly opt-in.** macOS (Apple Silicon and Intel),
Windows, Linux. A CPU path is required — an engine that only runs on one
accelerator is fine, but it must degrade rather than break, and a
platform-only engine goes behind an opt-in (see CLAUDE.md's parity rule).
4. **Fits the existing adapter.** `TTSBackend` / `SubprocessBackend` with no
changes to core pipelines. A dependency profile that conflicts with ours
means a sidecar (`SubprocessBackend`), which is a solved shape — see
`docs/engines/omnivoice-subprocess.md`.
5. **A CI smoke test in the same PR.** It does not need a GPU: stub the sidecar,
assert the adapter contract. An integration with no test is an integration
nobody will notice breaking.
6. **A named steward.** The proposer commits to being the point of contact for
that engine's issues for 12 months. No steward, no merge — this is the
difference between breadth and debt.
7. **Demand evidence.** A real request, a real workflow, a real user. Ideally
someone already working around its absence.
## Deprecation
Breadth is only worth carrying while it is alive. An engine is archived when,
for two consecutive releases, it has **no steward** and **no passing smoke
test**. Archiving is not a judgement either — it is how the remaining engines
stay trustworthy.
## If it doesn't clear the bar
The adapter interface is public. An engine can live out-of-tree, be installed
alongside, and be selected by id — you do not need our merge to use your engine,
and we would rather link a good external engine than carry a half-maintained
internal one.
-72
View File
@@ -1,72 +0,0 @@
# OmniVoice (subprocess-isolated) Engine
The `omnivoice-subprocess` engine runs the **same resident OmniVoice model** as
the default `omnivoice` engine, but in a **crash-isolated child process** so a
wedged generation can be hard-killed and its VRAM/device reclaimed.
## Why this engine exists
The default `omnivoice` engine runs in-process on the GPU worker pool. On
VRAM-tight machines (Apple Silicon MPS especially) a heavy generation or model
load can exceed its execution budget. When that happens the worker is
"abandoned" but **cannot be killed** (Python cannot interrupt a native torch /
MPS call), so it keeps holding the GPU device until it finishes on its own, and
every later synth queues behind it and hangs (#730 / #1190).
`omnivoice-subprocess` runs the model in a child process spawned via the same
`SubprocessBackend` primitive used by IndexTTS, Supertonic-3, and dots.tts. A
child process **can** be hard-killed: on a timeout the parent kills it
(`proc.kill()`), freeing its VRAM/device, and the next request transparently
respawns a fresh sidecar. That is the one thing the in-process engine
structurally cannot do.
## When to use it
- **Unattended / scheduled / reaction-triggered synthesis** where a stuck job
must recover on its own instead of hanging until a manual restart.
- **VRAM-starved MPS hosts** that hit the abandoned-worker cascade.
For interactive single-shot use on a machine with comfortable VRAM, the default
in-process `omnivoice` engine is faster (no stdio round-trip) and remains the
default.
## Selecting it
- **Settings -> Engines**, or
- `OMNIVOICE_TTS_BACKEND=omnivoice-subprocess`
It is **opt-in**; the in-process engine stays the default, so existing setups
see no change.
## Platform support
- **CUDA, MPS, and CPU** (same as the in-process OmniVoice engine).
- **No extra install.** Unlike IndexTTS / dots.tts / Supertonic-3, this sidecar
runs under OmniVoice's own interpreter, because the goal here is crash
isolation, not dependency isolation. If the default `omnivoice` engine works
for you, this one is ready too.
## Tradeoffs vs the in-process `omnivoice` engine
- **Identical model and output quality.**
- Slightly higher per-call latency (one stdio round-trip per synth).
- A wedged generation is **killed and recovered** at the recv-timeout deadline
(`OMNIVOICE_SIDECAR_RECV_TIMEOUT_S`, default 300s, aligned with the generate
budget) instead of hanging indefinitely.
- It does **not** carry the native advanced-parameter surface
(`t_shift` / `layer_penalty_factor` / `position_temperature` /
`class_temperature`) or parent-side seed determinism, because the generic
engine path does not forward those. For plain voice-clone and design
synthesis this is a non-issue.
- The recv-timeout deadline is per call and assumes the route's text chunking:
`/generate` and `/v1/audio/speech` split long text into pieces of at most
`max_chunk_chars` before calling the engine, so each call stays short. A
single very long unchunked `generate()` can exceed the deadline and be killed;
that is the watchdog working as intended, not a hang.
## Tuning
| Env var | Default | Purpose |
|---|---|---|
| `OMNIVOICE_SIDECAR_RECV_TIMEOUT_S` | `300` | Seconds to wait for a synth frame before hard-killing the sidecar (floored at 30s). |
| `OMNIVOICE_SIDECAR_IDLE_TIMEOUT_S` | `300` | Idle seconds before the sidecar is reaped to free its VRAM (shared with all subprocess engines). |
-3
View File
@@ -29,8 +29,6 @@ features:
tts_engines:
- id: omnivoice
readme: "**OmniVoice** (default)"
- id: omnivoice-subprocess
doc: docs/engines/omnivoice-subprocess.md
- id: cosyvoice
readme: CosyVoice 3
doc: docs/engines/cosyvoice.md
@@ -57,7 +55,6 @@ tts_engines:
- id: confucius4-tts
readme: "**Confucius4-TTS**"
doc: docs/engines/confucius4-tts.md
- id: pockettts
# Same contract against backend/services/asr_backend.py _REGISTRY.
asr_engines:
+3 -3
View File
@@ -13,12 +13,12 @@ and [`palashdeb/omnivoice-studio` on Docker Hub](https://hub.docker.com/r/palash
> |-----|--------------|
> | `:latest` | **Rolling preview** — latest commit on `main`, at or ahead of the last release. This is the preview channel; pin `:stable` for production. |
> | `:stable` | Most recent versioned release (updated on every `v*` git tag) |
> | `:0.4.1` | Exact release version |
> | `:0.4.0` | Exact release version |
> | `:0.4` | Latest patch within the 0.4 minor |
> | `:main` | Alias of the same rolling `main` build as `:latest` |
> | `:sha-xxxxxxx` | Specific commit (produced by manual workflow dispatch) |
> | `:rocm` | **AMD GPU (ROCm) build** of the rolling preview — the ROCm analogue of `:latest` |
> | `:stable-rocm`, `:0.4.1-rocm`, `:0.4-rocm`, `:sha-xxxxxxx-rocm` | ROCm builds of the corresponding CUDA tags above |
> | `:stable-rocm`, `:0.4.0-rocm`, `:0.4-rocm`, `:sha-xxxxxxx-rocm` | ROCm builds of the corresponding CUDA tags above |
>
> Versioning rule: preview builds always come from `main` and never
> version-sort below `:stable` — upgrades flow naturally.
@@ -89,7 +89,7 @@ PublishPort=127.0.0.1:3900:3900
Volume=omnivoice-data:/app/omnivoice_data
```
Release pins exist too: `:stable-rocm`, `:0.4.1-rocm`, `:0.4-rocm` mirror
Release pins exist too: `:stable-rocm`, `:0.4.0-rocm`, `:0.4-rocm` mirror
the CUDA tags exactly.
> **Consumer cards and APUs (RX 6000/7000, Strix Point/Halo):** the backend
+1 -44
View File
@@ -134,51 +134,8 @@ remains the documented fallback when running from a checked-out source tree.
WEBKIT_DISABLE_DMABUF_RENDERER=1 LIBGL_ALWAYS_SOFTWARE=1 ./OmniVoice.Studio_*.AppImage
```
### If no environment variable helps at all (Mesa 26.1+)
On a host with **Mesa 26.1 or newer** — Arch/CachyOS, and rolling distros
generally — none of the variables above make any difference, including
`WEBKIT_DMABUF_RENDERER_FORCE_SHM`, `WEBKIT_SKIA_ENABLE_CPU_RENDERING`,
`EGL_PLATFORM=surfaceless` and `MESA_LOADER_DRIVER_OVERRIDE=swrast`. That is
expected: the failure is in EGL **display creation**, which happens before
WebKit consults any rendering-path flag, so there is nothing left for a flag
to change.
The cause is a version pairing, not a bug in either half. The AppImage bundles
a WebKitGTK built on Ubuntu but ships no `libEGL` of its own, so that bundled
WebKit runs against *your* Mesa. On Mesa ≥ 26.1 it calls
`eglGetPlatformDisplay()` in a way the newer driver rejects. Your distro's own
WebKitGTK is fine, because it was compiled against the Mesa you are running —
which is why building from source works on the same machine.
**From v0.4.1 the AppImage handles this itself:** when your system has a
WebKitGTK at least as new as the bundled one, the launcher lets your copy take
precedence, and the bundled libraries fill in only what your system lacks.
That check reads your WebKit version from `pkg-config`, which is only installed
alongside the **development** package. If you have the runtime but not the dev
package, the launcher can't compare versions and keeps the bundled copy — so
tell it explicitly:
```bash
OMNIVOICE_PREFER_SYSTEM_WEBKIT=1 ./OmniVoice.Studio_*.AppImage
```
(Set it to `0` to force the bundled copy — useful if your distro's WebKitGTK is
older than ours and you'd rather keep the newer bundled one.)
If you are on v0.4.0 or older, either update or build from source:
```bash
git clone https://github.com/debpalash/OmniVoice-Studio.git
cd OmniVoice-Studio
bun install
bun run desktop-prod
```
Tracking issues: [#62](https://github.com/debpalash/OmniVoice-Studio/issues/62),
[#961](https://github.com/debpalash/OmniVoice-Studio/issues/961),
[#1258](https://github.com/debpalash/OmniVoice-Studio/issues/1258).
[#961](https://github.com/debpalash/OmniVoice-Studio/issues/961).
## .deb ffprobe conflict
+1 -1
View File
@@ -19,7 +19,7 @@ working OmniVoice Studio install on macOS (Apple Silicon).
### Using the DMG
- **macOS 13.3 (Ventura) or newer** — Apple Silicon (Intel: UI only, see the
- **macOS 12 (Monterey) or newer** — Apple Silicon (Intel: UI only, see the
note above).
- **~10 GB free disk** for the app, its Python environment, and model weights.
-8
View File
@@ -30,14 +30,6 @@ 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
-29
View File
@@ -101,35 +101,6 @@ torch.compile" (shown on Windows), for the rare setup where a partial Triton
install makes the probe pass but the compile attempt itself crash — see
[Windows install notes](install/windows.md).
## Warnings before a slow generation
The 300 s budget used to be discovered the hard way: you pressed Generate,
waited out the whole budget, and were then told the job was too heavy. Two
checks now run **before** the request leaves the app, at the one call every
synthesis path shares (Generate, voice previews, the compare modal, the stories
editor, profile previews, and streaming).
| Situation | What you see |
| --- | --- |
| The engine declares a VRAM floor above what this GPU has, or routing fell back to CPU | The routing caveat, naming your card, the engine's floor, and the ways around it |
| The host synthesizes on the CPU **and** the text is over 1200 characters | A heads-up that this generation may exceed the time budget |
**Why 1200 characters:** it is the same figure the budget itself uses. The first
1200 characters get the flat `OMNIVOICE_GENERATE_TIMEOUT_S`, and only past that
does the budget start growing (+1 s per 40 characters). Below the threshold you
are inside a budget the backend already considers generous, so ordinary
sentences on a CPU laptop stay quiet.
Both warnings are **advisory** — nothing is blocked. A driver can page to system
RAM, and a short input fits where a long one does not, so the engine still runs
if you want it to. Each fires **once per engine per session**, keyed on the
reason, so a genuinely different problem still gets through but the same
sentence is not repeated on every synthesis. Switching engines re-arms it.
If you are already on a CPU-tuned engine (OmniVoice GGUF, Supertonic-3) the
warning drops the "try a CPU-tuned engine" suggestion — it would be advice to
switch to what you are already using.
## Flush caches / Unload resident model
This is the feature the VRAM-starved timeout error ("TTS generate ran for more
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "omnivoice-studio",
"version": "0.4.2",
"version": "0.4.0",
"private": true,
"license": "AGPL-3.0-only",
"type": "module",
+1 -1
View File
@@ -2941,7 +2941,7 @@ dependencies = [
[[package]]
name = "omnivoice-studio"
version = "0.4.2"
version = "0.4.0"
dependencies = [
"arboard",
"dirs-next",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "omnivoice-studio"
version = "0.4.2"
version = "0.4.0"
description = "OmniVoice Studio AI voice cloning & dubbing desktop app"
authors = ["Debpalash"]
license = "AGPL-3.0-only"
+2 -104
View File
@@ -56,110 +56,8 @@ _detect_webkit_workaround() {
_detect_webkit_workaround
# ── Bundled-vs-system WebKit priority (#1258, #1244) ───────────────────────
#
# The bundled WebKitGTK links against the HOST's Mesa — the AppImage ships no
# libEGL of its own. That pairing is only tested for the Mesa of the build
# runner, and it breaks outright as hosts move ahead: on Mesa >= 26.1 the
# Ubuntu-built libwebkit2gtk calls eglGetPlatformDisplay() with parameters the
# newer driver rejects, and the app dies before it renders anything:
#
# Could not create default EGL display: EGL_BAD_PARAMETER. Aborting...
# blank window detected (#root children = -2); reload 1/3
#
# No env var helps, because the failure is in EGL display creation — it happens
# before WebKit consults any rendering-path flag. #1258 confirmed
# WEBKIT_DISABLE_DMABUF_RENDERER, WEBKIT_DMABUF_RENDERER_FORCE_SHM,
# WEBKIT_SKIA_ENABLE_CPU_RENDERING, EGL_PLATFORM=surfaceless and
# MESA_LOADER_DRIVER_OVERRIDE=swrast all fail identically.
#
# What DOES work on those machines is the host's own WebKitGTK, because the
# distro compiled it against the very Mesa it ships — which is why building
# from source works on the exact hardware where the AppImage does not.
#
# Chasing the build runner's WebKit version (#961 bumped 22.04 → 24.04) cannot
# fix this class: whatever we bundle is frozen, and host Mesa keeps moving. So
# when the host has a WebKitGTK at least as new as ours, let its copy win.
#
# Note it is NOT enough to merely stop prepending the bundle: LD_LIBRARY_PATH is
# searched ahead of the linker's default paths no matter where in that variable
# a directory sits, so on a normal launch (empty LD_LIBRARY_PATH) the bundle
# would still be the only explicit directory and still win. The host's WebKit
# libdir has to be named explicitly, ahead of ours (#1258 review).
#: Directory holding the host's libwebkit2gtk-4.1.so.0, or empty.
_system_webkit_libdir() {
local dir
# pkg-config is exact, but only present with the -dev package installed.
dir="$(pkg-config --variable=libdir webkit2gtk-4.1 2>/dev/null || echo "")"
if [ -n "$dir" ] && [ -e "$dir/libwebkit2gtk-4.1.so.0" ]; then
printf '%s' "$dir"
return 0
fi
# Runtime-only hosts (an end user who never installed -dev) have the library
# but no .pc file. ldconfig knows where it is (#1258 review).
if command -v ldconfig >/dev/null 2>&1; then
dir="$(ldconfig -p 2>/dev/null \
| awk '/libwebkit2gtk-4\.1\.so\.0 /{print $NF; exit}')"
if [ -n "$dir" ] && [ -e "$dir" ]; then
printf '%s' "$(dirname -- "$dir")"
return 0
fi
fi
return 1
}
#: Host WebKitGTK version, or empty when it can't be established.
_system_webkit_version() {
pkg-config --modversion webkit2gtk-4.1 2>/dev/null || echo ""
}
_prefer_system_webkit() {
# An explicit override for the case we cannot decide automatically: a host
# with the runtime but no pkg-config metadata, where the version is unknowable
# from here. Documented in docs/install/linux.md.
if [ "${OMNIVOICE_PREFER_SYSTEM_WEBKIT:-}" = "1" ]; then
return 0
fi
[ "${OMNIVOICE_PREFER_SYSTEM_WEBKIT:-}" = "0" ] && return 1
# Only meaningful when we know what we bundled; an unstamped bundle keeps
# the old ordering rather than guessing.
local marker="${OMNIVOICE_APPRUN_WK_MARKER:-$HERE/.bundled-webkitgtk-version}"
[ -r "$marker" ] || return 1
local bundled
bundled="$(cat "$marker" 2>/dev/null | tr -d '[:space:]')"
[ -n "$bundled" ] && [ "$bundled" != "0.0" ] || return 1
local system
system="$(_system_webkit_version)"
# Unknown host version → keep the bundle. Preferring an unverified copy could
# hand the user an OLDER WebKit than we ship, which is the #961 regression;
# OMNIVOICE_PREFER_SYSTEM_WEBKIT=1 is the escape hatch for that host.
[ -n "$system" ] || return 1
# `sort -V` puts the older version first; the host wins only on >=.
[ "$(printf '%s\n%s\n' "$bundled" "$system" | sort -V | head -1)" = "$bundled" ]
}
_SYS_WK_LIBDIR=""
if _prefer_system_webkit; then
_SYS_WK_LIBDIR="$(_system_webkit_libdir || echo "")"
fi
if [ -n "$_SYS_WK_LIBDIR" ]; then
# The host's WebKit resolves first; ours fills only what the host lacks.
export LD_LIBRARY_PATH="${_SYS_WK_LIBDIR}:${HERE}/usr/lib:${LD_LIBRARY_PATH:-}"
# The workaround above was chosen for the BUNDLED version; re-decide it
# against the copy that will actually run.
unset WEBKIT_DISABLE_COMPOSITING_MODE
case "$(_system_webkit_version)" in
2.44.*|2.46.*|"") export WEBKIT_DISABLE_COMPOSITING_MODE=1 ;;
esac
else
# Standard AppImage env that Tauri's auto-generated AppRun would have set.
export LD_LIBRARY_PATH="${HERE}/usr/lib:${LD_LIBRARY_PATH:-}"
fi
# Standard AppImage env that Tauri's auto-generated AppRun would have set.
export LD_LIBRARY_PATH="${HERE}/usr/lib:${LD_LIBRARY_PATH:-}"
export XDG_DATA_DIRS="${HERE}/usr/share:${XDG_DATA_DIRS:-/usr/local/share:/usr/share}"
exec "${HERE}/usr/bin/omnivoice-studio" "$@"
+2 -200
View File
@@ -114,212 +114,14 @@ run_marker_case() {
fi
}
# Marker says broken → workaround applies, even though host pkg-config reports
# a different number. The host here is OLDER, so the bundled lib is the one
# that runs and the marker is the only correct source.
#
# (This case used to pair marker 2.46 with host 2.48 and expect the workaround.
# Since #1258 a host that is NEWER takes over the run entirely, so the honest
# expectation for that pairing is "no workaround" — asserted directly in
# run_workaround_with_system below. The intent being pinned here, "trust the
# marker over the host for the library that actually runs", is unchanged.)
run_marker_case "marker 2.46 beats host 2.44" "2.46.1" "2.44.3" "1"
# Marker says broken → workaround applies, even though host pkg-config says healthy.
run_marker_case "marker 2.46 beats host 2.48" "2.46.1" "2.48.0" "1"
# Marker says healthy → no workaround, even though host pkg-config says broken
# (the exact #961 inversion: from-source user with old system lib, new bundle).
run_marker_case "marker 2.48 beats host 2.44" "2.48.0" "2.44.3" "unset"
# Empty marker → treated as unknown → fail-safe workaround.
run_marker_case "empty marker fails safe" "" "2.48.0" "1"
# ── System-vs-bundled WebKit priority (#1258, #1244) ────────────────────────
# The bundled WebKitGTK links against the HOST's Mesa (the AppImage ships no
# libEGL), so a host that has moved ahead of the build runner hits
# EGL_BAD_PARAMETER and a permanently blank window — with no env-var escape,
# because the failure precedes every rendering-path flag. When the host has a
# WebKitGTK at least as new as ours, its own copy must win: that is exactly
# what makes a source build work on the hardware where the AppImage does not.
run_ldpath_case() {
local label="$1" marker_content="$2" system_version="$3" expected="$4"
local marker_file wklibdir
marker_file="$(mktemp)"
printf '%s\n' "$marker_content" > "$marker_file"
# A REAL file: AppRun's libdir probe uses `[ -e ]`, which is a shell builtin
# and cannot be stubbed.
wklibdir="$(mktemp -d)"
touch "$wklibdir/libwebkit2gtk-4.1.so.0"
local actual
actual=$(
bash -c '
set +e
export OMNIVOICE_APPRUN_WK_MARKER="'"$marker_file"'"
sys="'"$system_version"'"
wklibdir="'"$wklibdir"'"
pkg-config() {
[ -n "$sys" ] || return 1
case "$1" in
--variable=libdir) echo "$wklibdir" ;;
*) echo "$sys" ;;
esac
}
export -f pkg-config
exec() { :; }
export -f exec
unset LD_LIBRARY_PATH
# shellcheck disable=SC1090
source "'"$THIS_DIR"'/AppRun" >/dev/null 2>&1 || true
# The bug this pins: LD_LIBRARY_PATH is searched BEFORE the linker default
# paths regardless of ordering within it, so merely appending the bundle
# still let the bundled WebKit win on a normal (empty) launch. The host
# libdir must be named explicitly, ahead of ours.
case "$LD_LIBRARY_PATH" in
"$wklibdir":*) echo "system-first" ;;
*) echo "bundle-first" ;;
esac
'
)
rm -rf "$marker_file" "$wklibdir"
if [[ "$actual" == "$expected" ]]; then
echo "PASS [$label]"
PASS_COUNT=$((PASS_COUNT + 1))
else
echo "FAIL [$label]: expected '$expected' got '$actual'" >&2
FAIL_COUNT=$((FAIL_COUNT + 1))
fi
}
# The #1258 machine: Arch/CachyOS ships WebKit 2.52.5, we bundle 2.44 —
# the host's copy is newer AND built against the Mesa actually installed.
run_ldpath_case "newer host WebKit wins" "2.44.3" "2.52.5" "system-first"
# Equal is still a win for the host: same version, but compiled against the
# Mesa that is actually present.
run_ldpath_case "equal host WebKit wins" "2.48.0" "2.48.0" "system-first"
# An OLDER host must not be preferred — that is the #961 regression.
run_ldpath_case "older host WebKit loses" "2.48.0" "2.44.3" "bundle-first"
# A host with no WebKitGTK at all is the case the bundle exists for.
run_ldpath_case "no host WebKit → bundle" "2.48.0" "" "bundle-first"
# An unstamped bundle can't compare, so it must not gamble.
run_ldpath_case "unknown bundle → bundle" "0.0" "2.52.5" "bundle-first"
# Runtime-only hosts (an end user who never installed the -dev package) have the
# library but no .pc file, so the version is unknowable from here. Preferring an
# unverified copy could hand the user an OLDER WebKit than we ship (#961), so
# the default stays with the bundle and an explicit opt-in exists for the
# machines where the bundle simply cannot start (#1258 review).
run_optin_case() {
local label="$1" optin="$2" marker_content="$3" system_version="$4" expected="$5"
local marker_file wklibdir
marker_file="$(mktemp)"
printf '%s\n' "$marker_content" > "$marker_file"
wklibdir="$(mktemp -d)"
touch "$wklibdir/libwebkit2gtk-4.1.so.0"
local actual
actual=$(
bash -c '
set +e
export OMNIVOICE_APPRUN_WK_MARKER="'"$marker_file"'"
export OMNIVOICE_PREFER_SYSTEM_WEBKIT="'"$optin"'"
sys="'"$system_version"'"
wklibdir="'"$wklibdir"'"
pkg-config() {
[ -n "$sys" ] || return 1
case "$1" in
--variable=libdir) echo "$wklibdir" ;;
*) echo "$sys" ;;
esac
}
export -f pkg-config
# A runtime-only host: no pkg-config metadata, but ldconfig knows the lib.
ldconfig() { echo " libwebkit2gtk-4.1.so.0 (libc6,x86-64) => $wklibdir/libwebkit2gtk-4.1.so.0"; }
export -f ldconfig
exec() { :; }
export -f exec
unset LD_LIBRARY_PATH
# shellcheck disable=SC1090
source "'"$THIS_DIR"'/AppRun" >/dev/null 2>&1 || true
case "$LD_LIBRARY_PATH" in
"$wklibdir":*) echo "system-first" ;;
*) echo "bundle-first" ;;
esac'
)
rm -rf "$marker_file" "$wklibdir"
if [[ "$actual" == "$expected" ]]; then
echo "PASS [$label]"
PASS_COUNT=$((PASS_COUNT + 1))
else
echo "FAIL [$label]: expected '$expected' got '$actual'" >&2
FAIL_COUNT=$((FAIL_COUNT + 1))
fi
}
# No pkg-config metadata → version unknowable → keep the bundle by default.
run_optin_case "runtime-only host defaults to bundle" "" "2.44.3" "" "bundle-first"
# ...and the opt-in gets that user running without building from source.
run_optin_case "opt-in overrides the unknown" "1" "2.44.3" "" "system-first"
# The opt-out is honoured even when the host would otherwise win.
run_optin_case "opt-out keeps the bundle" "0" "2.44.3" "2.52.5" "bundle-first"
# When the host's copy is chosen, the compositing workaround must be decided
# against THAT version, not the bundled one it was picked for.
run_workaround_with_system() {
local label="$1" marker_content="$2" system_version="$3" expected="$4"
local marker_file wklibdir
marker_file="$(mktemp)"
printf '%s\n' "$marker_content" > "$marker_file"
# A REAL file: AppRun's libdir probe uses `[ -e ]`, which is a shell builtin
# and cannot be stubbed.
wklibdir="$(mktemp -d)"
touch "$wklibdir/libwebkit2gtk-4.1.so.0"
local actual
actual=$(
bash -c '
set +e
export OMNIVOICE_APPRUN_WK_MARKER="'"$marker_file"'"
sys="'"$system_version"'"
wklibdir="'"$wklibdir"'"
pkg-config() {
[ -n "$sys" ] || return 1
case "$1" in
--variable=libdir) echo "$wklibdir" ;;
*) echo "$sys" ;;
esac
}
export -f pkg-config
exec() { :; }
export -f exec
# shellcheck disable=SC1090
source "'"$THIS_DIR"'/AppRun" >/dev/null 2>&1 || true
echo "${WEBKIT_DISABLE_COMPOSITING_MODE:-unset}"
'
)
rm -f "$marker_file"
if [[ "$actual" == "$expected" ]]; then
echo "PASS [$label]"
PASS_COUNT=$((PASS_COUNT + 1))
else
echo "FAIL [$label]: expected '$expected' got '$actual'" >&2
FAIL_COUNT=$((FAIL_COUNT + 1))
fi
}
# Bundle 2.44 (broken → workaround armed), host 2.52 healthy and now in charge:
# the workaround must be dropped or we software-render a healthy WebKit.
run_workaround_with_system "healthy host drops the workaround" "2.44.3" "2.52.5" "unset"
# Bundle 2.44, host 2.46 — newer, so it wins, but it is ALSO in the broken
# range: the workaround must be re-armed for the version that actually runs.
run_workaround_with_system "broken host re-arms it" "2.44.3" "2.46.1" "1"
echo
echo "─── AppRun test summary: $PASS_COUNT pass / $FAIL_COUNT fail ───"
if [[ $FAIL_COUNT -ne 0 ]]; then
-33
View File
@@ -149,39 +149,6 @@ 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) {
+7 -61
View File
@@ -260,24 +260,8 @@ 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)
// #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;
}
crate::backend::kill_orphan_on_port(backend_port());
std::thread::sleep(Duration::from_millis(500));
}
spawn_backend_and_wait(&app, &stage_handle);
});
@@ -415,24 +399,7 @@ pub fn spawn_backend_and_wait(app: &tauri::AppHandle, stage_handle: &Arc<Mutex<B
);
return;
}
// #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() {
let msg = 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)
@@ -611,31 +578,10 @@ 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. #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;
// 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));
}
let child = crate::backend::spawn_backend(app, Some(stage_handle));
track_backend_child(app, child);
+1 -1
View File
@@ -84,7 +84,7 @@
"binaries/ffprobe"
],
"macOS": {
"minimumSystemVersion": "13.3",
"minimumSystemVersion": "12.0",
"signingIdentity": "-",
"entitlements": "entitlements.plist"
}
+1 -1
View File
@@ -12,7 +12,7 @@
},
"bundle": {
"macOS": {
"minimumSystemVersion": "13.3"
"minimumSystemVersion": "12.0"
}
}
}
+6
View File
@@ -1165,6 +1165,9 @@ function App() {
return (
<div style={{ zoom: uiScale }}>
<BootstrapSplash stage={bootstrapStage} message={bootstrapMessage} />
<Suspense fallback={null}>
<LogsFooter />
</Suspense>
</div>
);
}
@@ -1201,6 +1204,9 @@ function App() {
}}
/>
</Suspense>
<Suspense fallback={null}>
<LogsFooter />
</Suspense>
</div>
);
}
+2 -49
View File
@@ -1,58 +1,11 @@
import { API, apiUrl, apiFetch, apiJson } from './client';
import { useAppStore } from '../store';
import { warnIfEngineUnderProvisioned } from '../utils/generatePreflight';
/**
* Hold the in-flight count for the duration of `fn`.
*
* Safe to nest, and streaming relies on that: generateSpeech releases its
* claim when the Response resolves i.e. when the HEADERS arrive but a
* streaming synth reads the body for as long as it takes to generate. Wrapping
* the whole stream in an outer claim keeps the count above zero throughout;
* the inner one just bumps it to 2 and back. This only works because the store
* tracks a COUNT, not a boolean (Greptile P1, #1288).
*/
export async function withTtsInflight<T>(fn: () => Promise<T>): Promise<T> {
useAppStore.getState().addTtsInflight?.(1);
try {
return await fn();
} finally {
useAppStore.getState().addTtsInflight?.(-1);
}
}
export async function generateSpeech(
formData: FormData,
{ signal }: { signal?: AbortSignal } = {},
): Promise<Response> {
// 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);
// Same chokepoint argument as the in-flight count: every synth path reaches
// /generate through here, so the under-provisioned-hardware warning fires
// whether or not the user ever re-picked an engine. Intentionally not
// awaited — the toast must not delay the request it is warning about.
// The text comes along so the preflight can also catch the CPU-host case:
// a benign routing verdict plus a long input is the shape that quietly eats
// the whole compute budget (#1299, #1260).
void warnIfEngineUnderProvisioned(
typeof formData?.get === 'function' ? String(formData.get('text') ?? '') : '',
);
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);
}
// Returns the full Response so callers can stream the WAV blob + read headers.
return apiFetch('/generate', { method: 'POST', body: formData, signal });
}
export async function listHistory(): Promise<unknown> {
-34
View File
@@ -1,34 +0,0 @@
import { apiJson } from './client';
/**
* Watermark settings + provenance detection.
*
* The backend has exposed these since watermarking shipped; nothing in the
* frontend ever called them, which is how the app came to promise a control
* ("Commercial licensees can disable it in Settings → Privacy") that did not
* exist. `backend/api/routers/watermark.py` is the contract.
*/
export interface WatermarkStatus {
/** AudioSeal invisible watermark — the provenance mark, on by default. */
invisible_enabled: boolean;
visible_audio_enabled: boolean;
visible_video_enabled: boolean;
/** False when the AudioSeal package/checkpoint isn't usable on this install. */
audioseal_available: boolean;
}
export async function getWatermarkStatus(): Promise<WatermarkStatus> {
return apiJson<WatermarkStatus>('/watermark/status');
}
/** Partial update — the endpoint takes each flag as an optional query param. */
export async function setWatermarkSettings(
patch: Partial<Pick<WatermarkStatus, 'invisible_enabled'>>,
): Promise<WatermarkStatus> {
const params = new URLSearchParams();
if (patch.invisible_enabled !== undefined) {
params.set('invisible', String(patch.invisible_enabled));
}
return apiJson<WatermarkStatus>(`/watermark/settings?${params}`, { method: 'POST' });
}
+1 -12
View File
@@ -141,18 +141,7 @@ 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');
// #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 (/port.*in use|address.*in use/i.test(all)) hints.push('bootstrap.hint_port');
if (/no error output/i.test(all)) hints.push('bootstrap.hint_silent_crash');
if (/seems stuck at|never reported ready/i.test(all)) hints.push('bootstrap.hint_stuck');
if (/blocking GitHub|couldn't download Python|python-build-standalone|dns error/i.test(all))
-101
View File
@@ -1,101 +0,0 @@
/**
* "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;
+4 -28
View File
@@ -21,7 +21,6 @@ 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';
@@ -38,11 +37,7 @@ 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);
@@ -71,18 +66,9 @@ export default function UpdatesPanel() {
if (v) useAppStore.getState().setWhatsNewSeenVersion?.(v);
}, [appVersion]);
// 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 busy = dubStep === 'generating';
const onInstall = () => {
// 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())) {
if (busy) {
toast(t('update.busy'), { icon: '⏳' });
return;
}
@@ -95,12 +81,7 @@ export default function UpdatesPanel() {
<div className="updates-panel">
<div className="updates-panel__live">
{status === 'available' && (
<button
className="updates-panel__cta"
onClick={onInstall}
disabled={busy}
title={busy ? t('update.busy') : undefined}
>
<button className="updates-panel__cta" onClick={onInstall}>
<Download size={13} /> {t('update.available', { version: version || '' })} ·{' '}
{t('update.install')}
</button>
@@ -114,12 +95,7 @@ export default function UpdatesPanel() {
</span>
)}
{status === 'ready' && (
<button
className="updates-panel__cta"
onClick={onInstall}
disabled={busy}
title={busy ? t('update.busy') : undefined}
>
<button className="updates-panel__cta" onClick={onInstall}>
<RotateCw size={13} /> {t('update.restart')}
</button>
)}
@@ -109,10 +109,6 @@ export default function GenerationProgress({ t, chapters = [], assembling = fals
<li
key={i}
className={`audiobook-progress__row status-${c.status}`}
// A failed chapter used to be a red row with no reason anywhere in
// the UI (#1321). The row is single-line/ellipsised, so the full
// text lives in the native tooltip and the head of it renders inline.
title={c.status === 'failed' && c.error ? c.error : undefined}
style={{
display: 'flex',
alignItems: 'center',
@@ -128,15 +124,7 @@ export default function GenerationProgress({ t, chapters = [], assembling = fals
{c.title || t('audiobook.chapter_n', { n: i + 1 })}
</span>
{c.status === 'cached' && <span className="muted">· {t('audiobook.cached_tag')}</span>}
{c.status === 'failed' && (
<span
className="muted"
style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}
>
· {t('audiobook.failed_tag')}
{c.error ? `: ${c.error}` : ''}
</span>
)}
{c.status === 'failed' && <span className="muted">· {t('audiobook.failed_tag')}</span>}
</li>
))}
</ol>
@@ -1,51 +0,0 @@
/**
* #1321: a failed chapter used to render as a red row with the word "failed"
* and nothing else. The reason existed only in the backend log, so the report
* for this arrived as a raw traceback pasted from a log file the user had no
* way to see what the app already knew.
*/
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import GenerationProgress from './GenerationProgress.jsx';
// `t` is a required prop (the panel takes the translator from its parent).
const t = (key, vars) => (vars ? `${key} ${JSON.stringify(vars)}` : key);
const CHAPTERS = [
{ title: 'Good chapter', status: 'done' },
{
title: 'Bad chapter',
status: 'failed',
error: 'the engine stopped without producing a result (StopIteration)',
},
];
describe('GenerationProgress — failed chapter reason (#1321)', () => {
it('shows the reason next to the failed chapter', () => {
render(<GenerationProgress t={t} chapters={CHAPTERS} />);
expect(screen.getByText(/the engine stopped without producing a result/i)).toBeTruthy();
});
it('puts the full reason in the row tooltip, since the row is ellipsised', () => {
const { container } = render(<GenerationProgress t={t} chapters={CHAPTERS} />);
const failedRow = container.querySelector('.status-failed');
expect(failedRow?.getAttribute('title')).toBe(CHAPTERS[1].error);
});
it('renders a failed chapter with no reason without printing "undefined"', () => {
const { container } = render(
<GenerationProgress t={t} chapters={[{ title: 'Bad', status: 'failed' }]} />,
);
const failedRow = container.querySelector('.status-failed');
expect(failedRow?.textContent).not.toMatch(/undefined/);
// No reason no tooltip, rather than an empty one.
expect(failedRow?.getAttribute('title')).toBeNull();
});
it('leaves successful chapters untouched', () => {
const { container } = render(<GenerationProgress t={t} chapters={CHAPTERS} />);
const doneRow = container.querySelector('.status-done');
expect(doneRow?.getAttribute('title')).toBeNull();
});
});
@@ -6,7 +6,6 @@ import { useAppStore } from '../../store';
import { SettingsSection } from './primitives';
import Row from './Row';
import AnalyticsOptIn from './AnalyticsOptIn';
import WatermarkControl from './WatermarkControl';
// Providers that send dialogue text to a third-party service vs. the ones that
// run fully on-device (backend/api/routers/dub_translate.py). Anything else
@@ -80,10 +79,6 @@ export default function PrivacyTab({ info }) {
{/* Opt-in product analytics. Renders nothing when the build ships no
destination, and is OFF until the user turns it on so the
"no tracking" default above stays true for everyone who doesn't. */}
{/* The provenance mark. ON by default (the opposite of analytics
below), and now actually controllable errors.a_watermark has told
users it lives here since watermarking shipped. */}
<WatermarkControl />
<AnalyticsOptIn />
</SettingsSection>
);
@@ -1,114 +0,0 @@
/**
* Settings Privacy "Invisible watermark" the control the app already
* promised.
*
* `errors.a_watermark` told users "Commercial licensees can disable it in
* Settings Privacy". That control did not exist: the backend endpoints
* (`/watermark/status`, `/watermark/settings`) had zero callers in the
* frontend, `is_enabled()` passes no `env=` to `resolve()` so there was no
* environment escape either, and the only way off was hand-editing prefs.json.
* A shipped instruction that cannot be followed is worse than no instruction.
*
* Deliberately mirrors AnalyticsOptIn's shape but inverts its default: analytics
* is OFF until you opt in, provenance marking is ON until you opt out. Both are
* honest about which way they point rather than hiding behind a policy page.
*
* When AudioSeal is unavailable on this install the toggle is not offered an
* inert switch claiming to control a mark that cannot be embedded would be the
* same lie in the other direction.
*/
import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import toast from 'react-hot-toast';
import { getWatermarkStatus, setWatermarkSettings } from '../../api/watermark';
import { SettingRow, SettingsToggle } from './primitives';
export default function WatermarkControl() {
const { t } = useTranslation();
const [status, setStatus] = useState(null);
const [busy, setBusy] = useState(false);
// A toggle that resolves after the tab closes must not set state or toast at
// whatever screen the user moved on to (CodeRabbit).
const alive = useRef(true);
useEffect(
() => () => {
alive.current = false;
},
[],
);
// One retry after a short delay. A single shot meant that opening Privacy
// while the backend was restarting hid the control for the rest of the
// session and the control's whole purpose is being findable, since the FAQ
// tells people it is here (Greptile P1).
useEffect(() => {
let cancelled = false;
let timer = null;
const load = (attempt = 0) => {
getWatermarkStatus()
.then((s) => {
if (!cancelled) setStatus(s);
})
.catch(() => {
if (!cancelled && attempt === 0) timer = setTimeout(() => load(1), 2000);
// Second failure: render nothing rather than an inert control.
});
};
load();
return () => {
cancelled = true;
if (timer) clearTimeout(timer);
};
}, []);
if (!status || !status.audioseal_available) return null;
const toggle = async () => {
const next = !status.invisible_enabled;
setBusy(true);
try {
const updated = await setWatermarkSettings({ invisible_enabled: next });
if (!alive.current) return;
setStatus((prev) => ({ ...prev, ...updated }));
toast.success(
next
? t('privacy.watermark_on_toast', {
defaultValue: 'Invisible watermark enabled for new audio.',
})
: t('privacy.watermark_off_toast', {
defaultValue: 'Invisible watermark disabled for new audio.',
}),
);
} catch {
if (!alive.current) return;
toast.error(
t('privacy.watermark_failed', {
defaultValue: 'Could not change the watermark setting.',
}),
);
} finally {
if (alive.current) setBusy(false);
}
};
return (
<SettingRow
title={t('privacy.watermark_title', { defaultValue: 'Invisible watermark' })}
subtitle={t('privacy.watermark_subtitle', {
defaultValue:
'On by default. Marks generated audio as AI-made so it can be identified later. Only affects audio generated from now on.',
})}
control={
<SettingsToggle
checked={!!status.invisible_enabled}
disabled={busy}
onChange={toggle}
aria-label={t('privacy.watermark_title', { defaultValue: 'Invisible watermark' })}
data-testid="watermark-toggle"
/>
}
/>
);
}
+1 -6
View File
@@ -269,13 +269,8 @@ export default function useDubWorkflow({
// forbids — so the backend PROCESS went away (on small GPUs, a VRAM
// abort while loading ASR is the usual trigger). Ask the shell's crash
// forensics rather than guessing "ASR failed to load" (#1062).
// The fallback no longer names a cause. streamDropError() checks the
// crash forensics AND whether the backend is still answering, and
// only this message survives when both are inconclusive — asserting
// "ASR failed to load" there sent #1242's reporter after a model
// that had loaded fine.
streamDropError(
'Transcribe stream ended before any segments arrived, and the backend could not be reached to say why — check the backend log, and Settings → Models if the ASR model was still downloading.',
'Transcribe stream dropped before emitting any segments. Likely ASR backend failed to load — check backend log + Settings → Models.',
).then(reject, reject);
});
}),
+10 -31
View File
@@ -2,7 +2,7 @@
"backendUnreachable": {
"contact_recent": "كان يستجيب قبل {{ago}} ثم توقف عن الاستجابة — على الأرجح أنه انهار أو تم إنهاؤه في منتصف الطلب.",
"contact_never": "لم يستجب إطلاقًا في هذه الجلسة — ربما لم يبدأ التشغيل أصلًا.",
"dev": "لا يمكن الوصول إلى خلفية OmniVoice المحلية. {{contact}} في `bun run dev` تعمل الخلفية بإعادة التحميل التلقائي، فأي تغيير في ملف — بما في ذلك الحفظ أثناء تنفيذ طلب — يعيد تشغيلها ويقطع الاتصال. أعد المحاولة أولًا؛ فإن كان الأمر إعادة تحميل فسينجح. وإن استمر الفشل، فافحص الطرفية التي تشغّل `bun run dev` بحثًا عن تتبّع Python أو رسالة خروج، وكذلك omnivoice.log في مجلد بيانات OmniVoice.",
"dev": "تعذّر الوصول إلى الواجهة الخلفية المحلية لـ OmniVoice. {{contact}} تحقّق من الطرفية التي تشغّل `bun run dev` بحثًا عن تتبّع أخطاء Python أو لافتة خروج، وراجع ملف omnivoice.log في مجلد بيانات OmniVoice لمعرفة آخر ما سجّلته الواجهة الخلفية.",
"server": "تعذّر الوصول إلى خادم الواجهة الخلفية لـ OmniVoice. {{contact}} راجع سجلات الخادم لمعرفة السبب (مثل `docker logs <container>` أو `journalctl`) — ولاحظ أنه إذا كان Docker يقدّم هذه الصفحة، فقد تتوقف الصفحة نفسها مع الواجهة الخلفية."
},
"nav": {
@@ -104,7 +104,7 @@
"update_check_failed": "فشل التحقق من التحديث: {{message}}",
"save_failed": "فشل الحفظ: {{message}}",
"clear_failed": "فشل المسح: {{message}}",
"engine_switched": "تم تبديل {{family}} إلى {{engine}}",
"engine_switched": "{{family}} {{engine}}",
"channel_set_failed": "فشل تعيين القناة: {{message}}",
"updater_downloading": "جارٍ التنزيل {{version}}…",
"updater_installed": "تم التثبيت - إعادة التشغيل.",
@@ -492,12 +492,7 @@
"local_sqlite": "سكليتي المحلية",
"translator_online": "المترجم متصل: {{provider}}",
"translator_offline": "مترجم غير متصل",
"no_tracking": "لا شيء – لا يوجد تتبع",
"watermark_title": "علامة مائية غير مرئية",
"watermark_subtitle": "مُفعّلة افتراضيًا. تُعلّم الصوت المُولَّد بأنه من صناعة الذكاء الاصطناعي ليُمكن التعرّف عليه لاحقًا. تؤثر فقط على الصوت المُولَّد من الآن.",
"watermark_on_toast": "تم تفعيل العلامة المائية غير المرئية للصوت الجديد.",
"watermark_off_toast": "تم إيقاف العلامة المائية غير المرئية للصوت الجديد.",
"watermark_failed": "لم يتمكّن من تغيير إعداد العلامة المائية."
"no_tracking": "لا شيء – لا يوجد تتبع"
},
"consent": {
"title": "المساعدة في تحسين OmniVoice؟",
@@ -583,12 +578,7 @@
"routingRemote": "بعيد",
"routingUnknown": "غير معروف",
"routingEffectiveChip": "يعمل على {{device}} على هذا الجهاز",
"routingCaveatTitle": "تم تحديد وحدة معالجة الرسومات، ولكن: {{reason}}",
"selectCpuFallback": "{{engine}}: يعمل على المعالج المركزي — {{reason}}",
"selectWithCaveat": "تم التبديل إلى {{engine}} — {{reason}}",
"generateCaveat": "قد يكون {{engine}} بطيئًا على هذا الجهاز — {{reason}}",
"cpuLongText": "يعمل {{engine}} على المعالج المركزي هنا، وهذا النص طويل — قد يتجاوز التوليد الحد الزمني. النص الأقصر أو محرك مُهيأ للمعالج (OmniVoice GGUF أو Supertonic-3) سيكون أسرع بكثير.",
"cpuLongTextTuned": "يعمل {{engine}} على المعالج المركزي هنا، وهذا النص طويل — قد يتجاوز التوليد الحد الزمني. النص الأقصر، أو تقسيمه إلى عدة مقاطع، سيكون أسرع بكثير."
"routingCaveatTitle": "تم تحديد وحدة معالجة الرسومات، ولكن: {{reason}}"
},
"capture": {
"desc": "تعمل مفاتيح التشغيل السريع العامة فقط في تطبيق سطح المكتب. تستخدم واجهة مستخدم الويب اختصارًا <1>Ctrl+Shift+Space</1> داخل الصفحة أثناء التركيز على النافذة.",
@@ -1445,14 +1435,10 @@
"desc": "لا تقلق، فباقي التطبيق لا يزال يعمل. يمكنك تبديل علامات التبويب أو المحاولة مرة أخرى أدناه.",
"tryAgain": "حاول مرة أخرى",
"openDocs": "افتح المستندات لهذا الخطأ",
"crash_port_in_use": "تعذّر بدء الخلفية لأن المنفذ 3900 قيد الاستخدام بالفعل — نسخة أخرى من OmniVoice (أو تطبيق استحوذ على المنفذ) تحتجزه. أغلق النسخة الأخرى وأعد التشغيل؛ وإذا لم يكن هناك شيء ظاهر قيد التشغيل، فإن خلفية متروكة من جلسة سابقة ما زالت تحتجز المنفذ.",
"crash_oom_kill": "تم إنهاؤه قسرًا (الإشارة 9)، وهو ما يعني عادةً أن نظام التشغيل نفدت ذاكرته (RAM) فأوقفه. أغلق التطبيقات كثيفة الذاكرة، أو اختر نموذج تعرّف على الكلام أصغر من الإعدادات ← النماذج، أو أفرغ نموذج تحويل النص إلى كلام قبل النسخ.",
"crash_native_fault": "تعطّل داخل طبقة الحوسبة بدلاً من نفاد الذاكرة — وهذا يشير إلى تعريف بطاقة رسومات لا يطابق وقت تشغيل CUDA المرفق، أو ملف نموذج لم يكتمل تنزيله. حدّث تعريف بطاقة الرسومات، ثم أعد تنزيل النموذج من الإعدادات ← النماذج (يصلح التنزيل الناقص في مكانه). وإذا تكرر ذلك، فانتقل إلى محرك معزول ضد الأعطال من الإعدادات ← المحركات — «OmniVoice (subprocess)» للتوليد، و«Faster-Whisper (crash-isolated subprocess)» للنسخ. هذه المحركات تشغّل النموذج في عملية منفصلة، فيسقط العطل تلك العملية بدل الخلفية بأكملها.",
"report": "الإبلاغ عن هذا الخطأ",
"report_failed": "تعذّر فتح تقرير الخطأ. حاول مرة أخرى، أو انسخ التفاصيل أعلاه إلى مشكلة جديدة.",
"searchIssues": "البحث عن مشكلات مشابهة",
"unexpected": "خطأ غير متوقع: {{message}}",
"backend_shutting_down": "يجري إغلاق OmniVoice. أعد فتح التطبيق وحاول مرة أخرى."
"unexpected": "خطأ غير متوقع: {{message}}"
},
"keyboard": {
"title": "اختصارات لوحة المفاتيح",
@@ -1763,7 +1749,7 @@
"q_try_before": "هل يمكنني المحاولة قبل الالتزام؟",
"a_try_before": "نعم. التطبيق الكامل مجاني للتنزيل والتشغيل والاستضافة الذاتية بموجب AGPL-3.0 — دون أي اتفاقية. عندما تكون مستعدًا لمناقشة ترخيص تجاري (للاستخدام الاحتكاري)، راسلنا عبر البريد الإلكتروني وسنرتّب التفاصيل معًا.",
"q_watermark": "ماذا عن العلامة المائية؟",
"a_watermark": "تُضمَّن علامة AudioSeal المائية غير المرئية افتراضيًا للجميع. يمكنك إيقافها من الإعدادات الخصوصية، وهي تؤثر فقط على الصوت المُولَّد بعد التغيير."
"a_watermark": "العلامة المائية غير المرئية AudioSeal مضمّنة افتراضيًا للجميع. يمكن لحاملي الترخيص التجاري تعطيلها في الإعدادات الخصوصية."
},
"gallery_extra": {
"save_prompt": "أدخل اسمًا لملف التعريف الصوتي هذا:",
@@ -1905,15 +1891,11 @@
"install_hint": "نزّل التحديث وأعد التشغيل إلى الإصدار الجديد",
"downloading": "جارٍ التحديث… {{pct}}%",
"restart": "أعد التشغيل للتحديث",
"busy": "انتظر حتى ينتهي العمل الجاري — ثم ثبّت التحديث.",
"busy": "أكمل الدبلجة أولاً — ثم ثبّت التحديث.",
"whats_new": "ما هو الجديد",
"failed": "فشل التحديث",
"retry": "أعد المحاولة",
"dismiss": "استبعاد",
"toast_available": "الإصدار {{version}} من OmniVoice Studio متاح",
"toast_install": "تثبيت وإعادة التشغيل",
"toast_whats_new": "ما الجديد",
"toast_later": "لاحقًا"
"dismiss": "استبعاد"
},
"archetypes": {
"featured": "مميز",
@@ -2267,10 +2249,7 @@
"email": "البريد الإلكتروني",
"email_desc": "الترخيص أو الشراكات أو أي شيء خاص.",
"website": "موقع الكتروني",
"website_desc": "المزيد عن المشروع والصانع.",
"follow_title": "تابِعنا على X",
"follow_desc": "ملاحظات الإصدارات، والمحركات الجديدة، ولمحات عمّا يجري بناؤه لاحقًا. مفيد إن كنت تفضّل عدم البقاء في خادم دردشة.",
"follow_cta": "تابِع على X"
"website_desc": "المزيد عن المشروع والصانع."
},
"permissions": {
"title": "الأذونات",
@@ -2300,4 +2279,4 @@
"output_title": "ما أبلغ عنه التطبيق",
"retry_hint": "جرّب \"إعادة المحاولة\" من الإعدادات → السجلات → الواجهة الخلفية. وإذا فشلت مجددًا، فإن \"تنظيف وإعادة المحاولة\" يعيد بناء البيئة من الصفر."
}
}
}
+13 -34
View File
@@ -2,7 +2,7 @@
"backendUnreachable": {
"contact_recent": "Es hat vor {{ago}} noch geantwortet und dann aufgehört zu reagieren — höchstwahrscheinlich ist es mitten in einer Anfrage abgestürzt oder wurde beendet.",
"contact_never": "Es hat in dieser Sitzung überhaupt nicht geantwortet — möglicherweise ist es nie gestartet.",
"dev": "Der lokale OmniVoice-Dienst ist nicht erreichbar. {{contact}} Bei `bun run dev` läuft das Backend mit Auto-Reload: jede Dateiänderung — auch ein Speichern während einer laufenden Anfrage — startet es neu und bricht die Verbindung ab. Wiederholen Sie die Aktion zuerst; war es ein Reload, funktioniert es einfach. Bleibt es fehlerhaft, prüfen Sie das Terminal mit `bun run dev` auf einen Python-Traceback oder eine Exit-Meldung und omnivoice.log in Ihrem OmniVoice-Datenordner.",
"dev": "Das lokale OmniVoice-Backend ist nicht erreichbar. {{contact}} Prüfe das Terminal mit `bun run dev` auf einen Python-Traceback oder ein Exit-Banner sowie die Datei omnivoice.log im OmniVoice-Datenordner auf den letzten Logeintrag des Backends.",
"server": "Der OmniVoice-Backend-Server ist nicht erreichbar. {{contact}} Prüfe die Server-Logs auf die Ursache (z. B. `docker logs <container>` oder `journalctl`) — und beachte: Wenn Docker diese Seite ausliefert, kann die Seite selbst zusammen mit dem Backend ausfallen."
},
"nav": {
@@ -104,7 +104,7 @@
"update_check_failed": "Update-Prüfung fehlgeschlagen: {{message}}",
"save_failed": "Speichern fehlgeschlagen: {{message}}",
"clear_failed": "Löschen fehlgeschlagen: {{message}}",
"engine_switched": "{{family}} auf {{engine}} umgestellt",
"engine_switched": "{{family}} {{engine}}",
"channel_set_failed": "Kanal konnte nicht festgelegt werden: {{message}}",
"updater_downloading": "{{version}} wird heruntergeladen…",
"updater_installed": "Installiert Neustart.",
@@ -308,8 +308,8 @@
"frontend": "Frontend",
"tauri": "Tauri",
"cancelOp": "Vorgang abbrechen",
"dismiss": "Schließen",
"dismissStatus": "Status schließen",
"dismiss": "Entlassen",
"dismissStatus": "Status entlassen",
"search": "Suchen…",
"no_matches": "Keine Übereinstimmungen",
"recent_and_popular": "Neu und beliebt",
@@ -492,12 +492,7 @@
"local_sqlite": "Lokales SQLite",
"translator_online": "Übersetzer ist online: {{provider}}",
"translator_offline": "Offline-Übersetzer",
"no_tracking": "Keine keine Nachverfolgung",
"watermark_title": "Unsichtbares Wasserzeichen",
"watermark_subtitle": "Standardmäßig aktiv. Kennzeichnet erzeugtes Audio als KI-generiert, damit es später erkannt werden kann. Gilt nur für ab jetzt erzeugtes Audio.",
"watermark_on_toast": "Unsichtbares Wasserzeichen für neues Audio aktiviert.",
"watermark_off_toast": "Unsichtbares Wasserzeichen für neues Audio deaktiviert.",
"watermark_failed": "Die Wasserzeichen-Einstellung konnte nicht geändert werden."
"no_tracking": "Keine keine Nachverfolgung"
},
"consent": {
"title": "OmniVoice verbessern helfen?",
@@ -583,12 +578,7 @@
"routingRemote": "Fernbedienung",
"routingUnknown": "Unbekannt",
"routingEffectiveChip": "Läuft unter {{device}} auf diesem Computer",
"routingCaveatTitle": "GPU ausgewählt, aber: {{reason}}",
"selectCpuFallback": "{{engine}}: läuft auf der CPU — {{reason}}",
"selectWithCaveat": "Zu {{engine}} gewechselt — {{reason}}",
"generateCaveat": "{{engine}} ist auf diesem Rechner möglicherweise langsam — {{reason}}",
"cpuLongText": "{{engine}} läuft hier auf der CPU und dieser Text ist lang — die Generierung überschreitet möglicherweise das Zeitbudget. Kürzerer Text oder eine CPU-optimierte Engine (OmniVoice GGUF, Supertonic-3) ist deutlich schneller.",
"cpuLongTextTuned": "{{engine}} läuft hier auf der CPU und dieser Text ist lang — die Generierung überschreitet möglicherweise das Zeitbudget. Kürzerer Text oder eine Aufteilung in mehrere Takes ist deutlich schneller."
"routingCaveatTitle": "GPU ausgewählt, aber: {{reason}}"
},
"capture": {
"desc": "Globale Hotkeys funktionieren nur in der Desktop-App. Die Web-Benutzeroberfläche verwendet eine In-Page-Verknüpfung <1>Strg+Umschalt+Leertaste</1>, während das Fenster den Fokus hat.",
@@ -1286,7 +1276,7 @@
"later": "Vielleicht später",
"star": "Stern auf GitHub",
"opt_out": "Fragen Sie nicht noch einmal",
"dismiss_aria": "Schließen"
"dismiss_aria": "Entlassen"
}
},
"enterprise": {
@@ -1445,14 +1435,10 @@
"desc": "Keine Sorge der Rest der App funktioniert weiterhin. Sie können die Registerkarten wechseln oder es unten noch einmal versuchen.",
"tryAgain": "Versuchen Sie es erneut",
"openDocs": "Öffnen Sie die Dokumentation zu diesem Fehler",
"crash_port_in_use": "Der Backend-Dienst konnte nicht starten, weil Port 3900 bereits belegt ist — eine andere OmniVoice-Instanz (oder eine App, die den Port beansprucht hat) hält ihn. Beenden Sie die andere Instanz und starten Sie neu; wenn nichts sichtbar läuft, hält ein verwaistes Backend aus einer früheren Sitzung den Port.",
"crash_oom_kill": "Er wurde zwangsbeendet (Signal 9), was in der Regel bedeutet, dass dem Betriebssystem der Arbeitsspeicher (RAM) ausging. Schließen Sie speicherintensive Apps, wählen Sie unter Einstellungen → Modelle ein kleineres ASR-Modell, oder entladen Sie das TTS-Modell vor der Transkription.",
"crash_native_fault": "Der Absturz erfolgte innerhalb des Compute-Stacks und nicht wegen Speichermangels — das deutet auf einen GPU-Treiber hin, der nicht zur mitgelieferten CUDA-Laufzeit passt, oder auf eine unvollständig heruntergeladene Modelldatei. Aktualisieren Sie den GPU-Treiber und laden Sie das Modell unter Einstellungen → Modelle erneut (ein unvollständiger Download wird dabei repariert). Wenn es weiterhin auftritt, wechseln Sie unter Einstellungen → Engines zu einer absturzisolierten Engine — „OmniVoice (subprocess)“ für die Synthese, „Faster-Whisper (crash-isolated subprocess)“ für die Transkription. Diese führen das Modell in einem eigenen Prozess aus, sodass ein solcher Absturz nur diesen Prozess beendet statt des gesamten Backends.",
"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}}",
"backend_shutting_down": "OmniVoice wird beendet. Öffnen Sie die App erneut und versuchen Sie es noch einmal."
"unexpected": "Unerwarteter Fehler: {{message}}"
},
"keyboard": {
"title": "Tastaturkürzel",
@@ -1763,7 +1749,7 @@
"q_try_before": "Kann ich es versuchen, bevor ich mich verpflichte?",
"a_try_before": "Ja. Die vollständige App lässt sich unter der AGPL-3.0 kostenlos herunterladen, ausführen und selbst hosten — ganz ohne Vertrag. Wenn Sie über eine kommerzielle Lizenz (für proprietäre Nutzung) sprechen möchten, schreiben Sie uns eine E-Mail und wir klären die Details gemeinsam.",
"q_watermark": "Was ist mit dem Wasserzeichen?",
"a_watermark": "Das unsichtbare AudioSeal-Wasserzeichen wird standardmäßig für alle eingebettet. Sie können es unter Einstellungen → Datenschutz abschalten; es betrifft nur Audio, das nach der Änderung erzeugt wird."
"a_watermark": "Das unsichtbare AudioSeal-Wasserzeichen ist standardmäßig für alle eingebettet. Kommerzielle Lizenznehmer können es unter Einstellungen → Datenschutz deaktivieren."
},
"gallery_extra": {
"save_prompt": "Geben Sie einen Namen für dieses Sprachprofil ein:",
@@ -1905,15 +1891,11 @@
"install_hint": "Update herunterladen und in die neue Version neu starten",
"downloading": "Wird aktualisiert… {{pct}} %",
"restart": "Zum Aktualisieren neu starten",
"busy": "Warten Sie, bis die laufende Arbeit fertig ist dann installieren Sie das Update.",
"busy": "Beende zuerst deine Synchronisation dann installiere das Update.",
"whats_new": "Was ist neu?",
"failed": "Update fehlgeschlagen",
"retry": "Versuchen Sie es noch einmal",
"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"
"dismiss": "Entlassen"
},
"archetypes": {
"featured": "Hervorgehoben",
@@ -2267,10 +2249,7 @@
"email": "E-Mail",
"email_desc": "Lizenzen, Partnerschaften oder irgendetwas Privates.",
"website": "Website",
"website_desc": "Mehr über das Projekt und den Macher.",
"follow_title": "Auf X folgen",
"follow_desc": "Release-Notes, neue Engines und gelegentliche Einblicke in das, was als Nächstes entsteht. Praktisch, wenn Sie nicht in einem Chat-Server sitzen möchten.",
"follow_cta": "Auf X folgen"
"website_desc": "Mehr über das Projekt und den Macher."
},
"permissions": {
"title": "Berechtigungen",
@@ -2300,4 +2279,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."
}
}
}
+9 -29
View File
@@ -43,15 +43,11 @@
"install_hint": "Download the update and restart into the new version",
"downloading": "Updating… {{pct}}%",
"restart": "Restart to update",
"busy": "Wait for the work in progress to finish — then install the update.",
"busy": "Finish your dub first — then install the update.",
"whats_new": "What's new",
"failed": "Update failed",
"retry": "Retry",
"dismiss": "Dismiss",
"toast_available": "OmniVoice Studio {{version}} is available",
"toast_install": "Install and restart",
"toast_whats_new": "What's new",
"toast_later": "Later"
"dismiss": "Dismiss"
},
"updates": {
"tab": "Updates",
@@ -523,7 +519,7 @@
"update_check_failed": "Update check failed: {{message}}",
"save_failed": "Save failed: {{message}}",
"clear_failed": "Clear failed: {{message}}",
"engine_switched": "Switched {{family}} to {{engine}}",
"engine_switched": "{{family}} {{engine}}",
"channel_set_failed": "Failed to set channel: {{message}}",
"updater_downloading": "Downloading {{version}}…",
"updater_installed": "Installed — relaunching.",
@@ -809,12 +805,7 @@
"analytics_never": "Never sent: the text you type, your audio, your file names, your voice names, or anything identifying you. Not your name, not your email, not your IP.",
"analytics_off_anytime": "You can turn this off again at any time, and nothing further is sent.",
"analytics_on": "Thanks — anonymous usage stats are on.",
"analytics_off": "Analytics off. Nothing is sent.",
"watermark_title": "Invisible watermark",
"watermark_subtitle": "On by default. Marks generated audio as AI-made so it can be identified later. Only affects audio generated from now on.",
"watermark_on_toast": "Invisible watermark enabled for new audio.",
"watermark_off_toast": "Invisible watermark disabled for new audio.",
"watermark_failed": "Could not change the watermark setting."
"analytics_off": "Analytics off. Nothing is sent."
},
"consent": {
"title": "Help improve OmniVoice?",
@@ -1910,25 +1901,17 @@
"installStep_verify": "Verifying the environment",
"installStep_fetch_weights": "Downloading model weights",
"installStep_persist": "Saving configuration",
"installAria": "Install {{engine}}",
"selectWithCaveat": "Switched to {{engine}} — {{reason}}",
"generateCaveat": "{{engine}} may be slow on this machine — {{reason}}",
"cpuLongText": "{{engine}} runs on the CPU here, and this text is long — the generation may exceed the time budget. Shorter text, or a CPU-tuned engine (OmniVoice GGUF, Supertonic-3), will be much faster.",
"cpuLongTextTuned": "{{engine}} runs on the CPU here, and this text is long — the generation may exceed the time budget. Shorter text, or splitting it into a few takes, will be much faster."
"installAria": "Install {{engine}}"
},
"errors": {
"title": "This tab hit a snag.",
"desc": "Don't worry — the rest of the app still works. You can switch tabs, or try again below.",
"tryAgain": "Try again",
"openDocs": "Open docs for this error",
"crash_port_in_use": "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.",
"crash_oom_kill": "It was force-killed (signal 9), which usually means the operating system ran out of memory (RAM) and stopped it. Close memory-heavy apps, pick a smaller ASR model in Settings → Models, or flush the TTS model before transcribing.",
"crash_native_fault": "It crashed inside the compute stack rather than running out of memory — that points at a GPU driver that does not match the bundled CUDA runtime, or a model file that downloaded incompletely. Update your GPU driver, then re-download the model from Settings → Models (it repairs a partial download in place). If it keeps happening, switch to a crash-isolated engine in Settings → Engines — \"OmniVoice (subprocess)\" for synthesis, \"Faster-Whisper (crash-isolated subprocess)\" for transcription. Those run the model in a separate process, so a crash like this takes down that process instead of the whole backend.",
"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}}",
"backend_shutting_down": "OmniVoice is shutting down. Reopen the app and try again."
"unexpected": "Unexpected error: {{message}}"
},
"crash": {
"notice": "The voice backend crashed ({{exit}}) {{ago}} ago and is being restarted automatically.",
@@ -1955,7 +1938,7 @@
"backendUnreachable": {
"contact_recent": "It was answering {{ago}} ago and then stopped responding — it most likely crashed or was killed mid-request.",
"contact_never": "It has not answered at all this session — it may never have started.",
"dev": "Can't reach the local OmniVoice backend. {{contact}} In `bun run dev` the backend runs with auto-reload, so any file change — including a save while a request was in flight — restarts it and drops the connection. Retry the action first; if it was a reload, it just works. If it keeps failing, check the terminal running `bun run dev` for a Python traceback or an exit banner, and omnivoice.log in your OmniVoice data folder for the last thing the backend logged.",
"dev": "Can't reach the local OmniVoice backend. {{contact}} Check the terminal running `bun run dev` for a Python traceback or an exit banner, and the omnivoice.log file in your OmniVoice data folder for the last thing the backend logged.",
"server": "Can't reach the OmniVoice backend server. {{contact}} Check the server logs for the cause (e.g. `docker logs <container>` or `journalctl`) — and note that if Docker serves this page, the page itself can go down with the backend."
},
"backend": {
@@ -2442,7 +2425,7 @@
"q_try_before": "Can I try before committing?",
"a_try_before": "Yes. The full app is free to download, run, and self-host under the AGPL-3.0 — no agreement required. When you're ready to discuss a commercial (proprietary-use) license, email us and we'll work through the details together.",
"q_watermark": "What about the watermark?",
"a_watermark": "The invisible AudioSeal watermark is embedded by default for everyone. You can turn it off in Settings → Privacy; it only affects audio generated after the change."
"a_watermark": "The invisible AudioSeal watermark is embedded by default for everyone. Commercial licensees can disable it in Settings → Privacy."
},
"gallery_extra": {
"save_prompt": "Enter a name for this voice profile:",
@@ -2679,10 +2662,7 @@
"email": "Email",
"email_desc": "Email — licensing, partnerships, or anything private",
"website": "Website",
"website_desc": "Website — more about the project and the maker",
"follow_title": "Follow along on X",
"follow_desc": "Release notes, new engines, and the occasional look at what is being built next. Handy if you would rather not sit in a chat server.",
"follow_cta": "Follow on X"
"website_desc": "Website — more about the project and the maker"
},
"firstrun": {
"loading": "Preparing setup…",
+10 -31
View File
@@ -2,7 +2,7 @@
"backendUnreachable": {
"contact_recent": "Estaba respondiendo hace {{ago}} y dejó de responder — lo más probable es que se haya bloqueado o haya sido terminado en mitad de una petición.",
"contact_never": "No ha respondido en toda esta sesión — puede que nunca haya llegado a iniciarse.",
"dev": "No se puede contactar con el backend local de OmniVoice. {{contact}} Con `bun run dev` el backend se ejecuta con recarga automática: cualquier cambio de archivo — incluido guardar mientras había una petición en curso — lo reinicia y corta la conexión. Reintente la acción primero; si fue una recarga, funcionará. Si sigue fallando, revise la terminal con `bun run dev` por un traceback de Python o un mensaje de salida, y omnivoice.log en su carpeta de datos de OmniVoice.",
"dev": "No se puede conectar con el backend local de OmniVoice. {{contact}} Revisa el terminal que ejecuta `bun run dev` en busca de un traceback de Python o un aviso de salida, y el archivo omnivoice.log en tu carpeta de datos de OmniVoice para ver lo último que registró el backend.",
"server": "No se puede conectar con el servidor backend de OmniVoice. {{contact}} Revisa los registros del servidor para encontrar la causa (p. ej. `docker logs <contenedor>` o `journalctl`) — y ten en cuenta que si Docker sirve esta página, la propia página puede caerse junto con el backend."
},
"nav": {
@@ -104,7 +104,7 @@
"update_check_failed": "Error en la verificación de actualización: {{message}}",
"save_failed": "Error al guardar: {{message}}",
"clear_failed": "Borrado fallido: {{message}}",
"engine_switched": "{{family}} cambiado a {{engine}}",
"engine_switched": "{{family}} {{engine}}",
"channel_set_failed": "No se pudo configurar el canal: {{message}}",
"updater_downloading": "Descargando {{version}}…",
"updater_installed": "Instalado - relanzando.",
@@ -492,12 +492,7 @@
"local_sqlite": "SQLite local",
"translator_online": "El traductor está en línea: {{provider}}",
"translator_offline": "Traductor sin conexión",
"no_tracking": "Ninguno: sin seguimiento",
"watermark_title": "Marca de agua invisible",
"watermark_subtitle": "Activada por defecto. Marca el audio generado como creado por IA para poder identificarlo después. Solo afecta al audio generado a partir de ahora.",
"watermark_on_toast": "Marca de agua invisible activada para el audio nuevo.",
"watermark_off_toast": "Marca de agua invisible desactivada para el audio nuevo.",
"watermark_failed": "No se pudo cambiar el ajuste de la marca de agua."
"no_tracking": "Ninguno: sin seguimiento"
},
"consent": {
"title": "¿Ayudar a mejorar OmniVoice?",
@@ -583,12 +578,7 @@
"routingRemote": "Remoto",
"routingUnknown": "Desconocido",
"routingEffectiveChip": "Se ejecuta en {{device}} en esta máquina",
"routingCaveatTitle": "GPU seleccionada, pero: {{reason}}",
"selectCpuFallback": "{{engine}}: ejecutándose en la CPU — {{reason}}",
"selectWithCaveat": "Se cambió a {{engine}}: {{reason}}",
"generateCaveat": "{{engine}} puede ir lento en este equipo: {{reason}}",
"cpuLongText": "{{engine}} se ejecuta en la CPU aquí y este texto es largo: la generación puede superar el límite de tiempo. Un texto más corto o un motor optimizado para CPU (OmniVoice GGUF, Supertonic-3) será mucho más rápido.",
"cpuLongTextTuned": "{{engine}} se ejecuta en la CPU aquí y este texto es largo: la generación puede superar el límite de tiempo. Un texto más corto, o dividirlo en varias tomas, será mucho más rápido."
"routingCaveatTitle": "GPU seleccionada, pero: {{reason}}"
},
"capture": {
"desc": "Las teclas de acceso rápido globales solo funcionan en la aplicación de escritorio. La interfaz de usuario web utiliza un acceso directo <1>Ctrl+Shift+Espacio</1> en la página mientras la ventana tiene el foco.",
@@ -1445,14 +1435,10 @@
"desc": "No te preocupes, el resto de la aplicación sigue funcionando. Puedes cambiar de pestaña o volver a intentarlo a continuación.",
"tryAgain": "Inténtalo de nuevo",
"openDocs": "Abrir documentos para este error",
"crash_port_in_use": "El backend no pudo iniciarse porque el puerto 3900 ya está en uso: otra copia de OmniVoice (o una aplicación que reclamó ese puerto) lo está ocupando. Cierre la otra instancia y vuelva a iniciar; si no hay nada visible en ejecución, un backend huérfano de una sesión anterior sigue ocupando el puerto.",
"crash_oom_kill": "Se cerró a la fuerza (señal 9), lo que suele significar que el sistema operativo se quedó sin memoria (RAM) y lo detuvo. Cierre aplicaciones que consuman mucha memoria, elija un modelo ASR más pequeño en Ajustes → Modelos, o descargue el modelo TTS antes de transcribir.",
"crash_native_fault": "Se bloqueó dentro de la pila de cómputo en lugar de quedarse sin memoria: eso apunta a un controlador de GPU que no coincide con el runtime de CUDA incluido, o a un archivo de modelo descargado de forma incompleta. Actualice el controlador de la GPU y vuelva a descargar el modelo en Ajustes → Modelos (repara una descarga parcial en el sitio). Si sigue ocurriendo, cambie a un motor aislado en Ajustes → Motores: «OmniVoice (subprocess)» para la síntesis y «Faster-Whisper (crash-isolated subprocess)» para la transcripción. Estos ejecutan el modelo en un proceso aparte, así que un fallo así derriba ese proceso en lugar de todo el backend.",
"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}}",
"backend_shutting_down": "OmniVoice se está cerrando. Vuelve a abrir la aplicación e inténtalo de nuevo."
"unexpected": "Error inesperado: {{message}}"
},
"keyboard": {
"title": "Atajos de teclado",
@@ -1763,7 +1749,7 @@
"q_try_before": "¿Puedo intentarlo antes de comprometerme?",
"a_try_before": "Sí. La aplicación completa se puede descargar, ejecutar y autoalojar gratis bajo la AGPL-3.0, sin ningún acuerdo. Cuando quieras hablar de una licencia comercial (uso propietario), escríbenos y resolveremos los detalles juntos.",
"q_watermark": "¿Qué pasa con la marca de agua?",
"a_watermark": "La marca de agua invisible AudioSeal se incrusta de forma predeterminada para todos. Puede desactivarla en Ajustes → Privacidad; solo afecta al audio generado después del cambio."
"a_watermark": "La marca de agua invisible AudioSeal está incrustada de forma predeterminada para todos. Los licenciatarios comerciales pueden desactivarla en Configuración → Privacidad."
},
"gallery_extra": {
"save_prompt": "Ingrese un nombre para este perfil de voz:",
@@ -1905,15 +1891,11 @@
"install_hint": "Descarga la actualización y reinicia en la nueva versión",
"downloading": "Actualizando… {{pct}}%",
"restart": "Reiniciar para actualizar",
"busy": "Espera a que termine el trabajo en curso — luego instala la actualización.",
"busy": "Termina tu doblaje primero — luego instala la actualización.",
"whats_new": "¿Qué hay de nuevo?",
"failed": "La actualización falló",
"retry": "Reintentar",
"dismiss": "Descartar",
"toast_available": "OmniVoice Studio {{version}} está disponible",
"toast_install": "Instalar y reiniciar",
"toast_whats_new": "Novedades",
"toast_later": "Más tarde"
"dismiss": "Descartar"
},
"archetypes": {
"featured": "Destacado",
@@ -2267,10 +2249,7 @@
"email": "Correo electrónico",
"email_desc": "Licencias, asociaciones o cualquier cosa privada.",
"website": "Sitio web",
"website_desc": "Más sobre el proyecto y el creador.",
"follow_title": "Síguenos en X",
"follow_desc": "Notas de versión, nuevos motores y algún vistazo a lo que se está construyendo. Útil si prefiere no estar en un servidor de chat.",
"follow_cta": "Seguir en X"
"website_desc": "Más sobre el proyecto y el creador."
},
"permissions": {
"title": "Permisos",
@@ -2300,4 +2279,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."
}
}
}
+11 -32
View File
@@ -2,7 +2,7 @@
"backendUnreachable": {
"contact_recent": "Il répondait il y a {{ago}} puis a cessé de répondre — il a très probablement planté ou a été arrêté en pleine requête.",
"contact_never": "Il n'a pas répondu du tout durant cette session — il n'a peut-être jamais démarré.",
"dev": "Impossible de joindre le backend OmniVoice local. {{contact}} Avec `bun run dev`, le backend tourne en rechargement automatique : toute modification de fichier — y compris une sauvegarde pendant une requête en cours — le redémarre et coupe la connexion. Réessayez d'abord l'action ; s'il s'agissait d'un rechargement, cela fonctionnera. Si l'échec persiste, consultez le terminal exécutant `bun run dev` pour un traceback Python ou un message de sortie, et omnivoice.log dans votre dossier de données OmniVoice.",
"dev": "Impossible de joindre le backend OmniVoice local. {{contact}} Vérifiez le terminal exécutant `bun run dev` pour un traceback Python ou une bannière de sortie, ainsi que le fichier omnivoice.log dans votre dossier de données OmniVoice pour la dernière trace du backend.",
"server": "Impossible de joindre le serveur backend OmniVoice. {{contact}} Consultez les journaux du serveur pour en trouver la cause (p. ex. `docker logs <conteneur>` ou `journalctl`) — et notez que si Docker sert cette page, la page elle-même peut tomber avec le backend."
},
"nav": {
@@ -104,7 +104,7 @@
"update_check_failed": "Échec de la vérification de la mise à jour : {{message}}",
"save_failed": "Échec de l'enregistrement : {{message}}",
"clear_failed": "Échec de la suppression : {{message}}",
"engine_switched": "{{family}} basculé vers {{engine}}",
"engine_switched": "{{family}} {{engine}}",
"channel_set_failed": "Échec de la définition du canal : {{message}}",
"updater_downloading": "Téléchargement de {{version}}…",
"updater_installed": "Installé — relance.",
@@ -309,7 +309,7 @@
"tauri": "Taureau",
"cancelOp": "Annuler l'opération",
"dismiss": "Rejeter",
"dismissStatus": "Ignorer le statut",
"dismissStatus": "Statut de rejet",
"search": "Rechercher…",
"no_matches": "Aucune correspondance",
"recent_and_popular": "Récents et populaires",
@@ -492,12 +492,7 @@
"local_sqlite": "SQLite local",
"translator_online": "Le traducteur est en ligne : {{provider}}",
"translator_offline": "Traducteur hors ligne",
"no_tracking": "Aucun — pas de suivi",
"watermark_title": "Filigrane invisible",
"watermark_subtitle": "Activé par défaut. Marque l'audio généré comme créé par IA afin qu'il puisse être identifié plus tard. N'affecte que l'audio généré à partir de maintenant.",
"watermark_on_toast": "Filigrane invisible activé pour les nouveaux audios.",
"watermark_off_toast": "Filigrane invisible désactivé pour les nouveaux audios.",
"watermark_failed": "Impossible de modifier le réglage du filigrane."
"no_tracking": "Aucun — pas de suivi"
},
"consent": {
"title": "Aider à améliorer OmniVoice ?",
@@ -583,12 +578,7 @@
"routingRemote": "À distance",
"routingUnknown": "Inconnu",
"routingEffectiveChip": "Fonctionne le {{device}} sur cette machine",
"routingCaveatTitle": "GPU sélectionné, mais : {{reason}}",
"selectCpuFallback": "{{engine}} : fonctionne sur le processeur — {{reason}}",
"selectWithCaveat": "Basculé vers {{engine}} — {{reason}}",
"generateCaveat": "{{engine}} risque d'être lent sur cette machine — {{reason}}",
"cpuLongText": "{{engine}} fonctionne ici sur le processeur et ce texte est long — la génération risque de dépasser le budget de temps. Un texte plus court ou un moteur optimisé CPU (OmniVoice GGUF, Supertonic-3) sera bien plus rapide.",
"cpuLongTextTuned": "{{engine}} fonctionne ici sur le processeur et ce texte est long — la génération risque de dépasser le budget de temps. Un texte plus court, ou découpé en plusieurs prises, sera bien plus rapide."
"routingCaveatTitle": "GPU sélectionné, mais : {{reason}}"
},
"capture": {
"desc": "Les raccourcis clavier globaux ne fonctionnent que dans l'application de bureau. L'interface utilisateur Web utilise un raccourci <1>Ctrl+Shift+Espace</1> sur la page lorsque la fenêtre a le focus.",
@@ -1445,14 +1435,10 @@
"desc": "Ne vous inquiétez pas, le reste de l'application fonctionne toujours. Vous pouvez changer d'onglet ou réessayer ci-dessous.",
"tryAgain": "Réessayez",
"openDocs": "Ouvrir la documentation pour cette erreur",
"crash_port_in_use": "Le backend n'a pas pu démarrer car le port 3900 est déjà utilisé — une autre copie d'OmniVoice (ou une application ayant réclamé ce port) l'occupe. Quittez l'autre instance et relancez ; si rien ne tourne visiblement, un backend orphelin d'une session précédente occupe encore le port.",
"crash_oom_kill": "Il a été arrêté de force (signal 9), ce qui signifie généralement que le système d'exploitation a manqué de mémoire (RAM). Fermez les applications gourmandes en mémoire, choisissez un modèle ASR plus petit dans Réglages → Modèles, ou déchargez le modèle TTS avant de transcrire.",
"crash_native_fault": "Le plantage s'est produit dans la pile de calcul et non par manque de mémoire — cela indique un pilote GPU qui ne correspond pas au runtime CUDA fourni, ou un fichier de modèle téléchargé de façon incomplète. Mettez à jour votre pilote GPU, puis retéléchargez le modèle depuis Réglages → Modèles (un téléchargement partiel est réparé sur place). Si cela persiste, passez à un moteur isolé dans Réglages → Moteurs : « OmniVoice (subprocess) » pour la synthèse, « Faster-Whisper (crash-isolated subprocess) » pour la transcription. Ils exécutent le modèle dans un processus séparé, si bien qu'un tel plantage n'emporte que ce processus et non tout le backend.",
"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}}",
"backend_shutting_down": "OmniVoice est en cours de fermeture. Rouvrez lapplication et réessayez."
"unexpected": "Erreur inattendue : {{message}}"
},
"keyboard": {
"title": "Raccourcis clavier",
@@ -1763,7 +1749,7 @@
"q_try_before": "Puis-je essayer avant de m'engager ?",
"a_try_before": "Oui. L'application complète est gratuite à télécharger, exécuter et auto-héberger sous AGPL-3.0 — aucun accord requis. Quand vous serez prêt à discuter d'une licence commerciale (usage propriétaire), écrivez-nous et nous verrons les détails ensemble.",
"q_watermark": "Et le filigrane ?",
"a_watermark": "Le filigrane invisible AudioSeal est intégré par défaut pour tout le monde. Vous pouvez le désactiver dans Réglages → Confidentialité ; il n'affecte que l'audio généré après la modification."
"a_watermark": "Le filigrane invisible AudioSeal est intégré par défaut pour tout le monde. Les titulaires de licence commerciale peuvent le désactiver dans Paramètres → Confidentialité."
},
"gallery_extra": {
"save_prompt": "Saisissez un nom pour ce profil vocal :",
@@ -1905,15 +1891,11 @@
"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": "Attendez la fin du travail en cours, puis installez la mise à jour.",
"busy": "Terminez d'abord votre doublage, puis installez la mise à jour.",
"whats_new": "Quoi de neuf",
"failed": "La mise à jour a échoué",
"retry": "Réessayer",
"dismiss": "Rejeter",
"toast_available": "OmniVoice Studio {{version}} est disponible",
"toast_install": "Installer et redémarrer",
"toast_whats_new": "Nouveautés",
"toast_later": "Plus tard"
"dismiss": "Rejeter"
},
"archetypes": {
"featured": "En vedette",
@@ -2267,10 +2249,7 @@
"email": "Courriel",
"email_desc": "Licences, partenariats ou tout ce qui est privé.",
"website": "Site Web",
"website_desc": "En savoir plus sur le projet et le créateur.",
"follow_title": "Suivez-nous sur X",
"follow_desc": "Notes de version, nouveaux moteurs et un aperçu occasionnel de ce qui arrive. Pratique si vous préférez ne pas rester dans un serveur de discussion.",
"follow_cta": "Suivre sur X"
"website_desc": "En savoir plus sur le projet et le créateur."
},
"permissions": {
"title": "Autorisations",
@@ -2300,4 +2279,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."
}
}
}
+10 -31
View File
@@ -2,7 +2,7 @@
"backendUnreachable": {
"contact_recent": "यह {{ago}} पहले तक जवाब दे रहा था और फिर जवाब देना बंद कर दिया — बहुत संभव है कि यह अनुरोध के बीच में क्रैश हो गया या बंद कर दिया गया।",
"contact_never": "इस सत्र में इसने अभी तक कोई जवाब नहीं दिया — हो सकता है कि यह कभी शुरू ही न हुआ हो।",
"dev": "स्थानीय OmniVoice बैकएंड से संपर्क नहीं हो पा रहा। {{contact}} `bun run dev` में बैकएंड ऑटो-रीलोड के साथ चलता है, इसलिए कोई भी फ़ाइल बदलाव — अनुरोध चलते समय सेव करना भी — उसे पुनः आरंभ कर देता है और कनेक्शन टूट जाता है। पहले क्रिया दोबारा करें; यदि रीलोड था तो यह चल जाएगा। यदि फिर भी विफल हो, तो `bun run dev` चला रहे टर्मिनल में Python ट्रेसबैक या exit संदेश देखें, और अपने OmniVoice डेटा फ़ोल्डर की omnivoice.log देखें।",
"dev": "लोकल OmniVoice बैकएंड से संपर्क नहीं हो पा रहा है। {{contact}} `bun run dev` चला रहे टर्मिनल में Python traceback या exit बैनर देखें, और बैकएंड के आख़िरी लॉग के लिए अपने OmniVoice डेटा फ़ोल्डर की omnivoice.log फ़ाइल देखें।",
"server": "OmniVoice बैकएंड सर्वर से संपर्क नहीं हो पा रहा है। {{contact}} कारण जानने के लिए सर्वर लॉग देखें (जैसे `docker logs <container>` या `journalctl`) — और ध्यान रखें कि अगर Docker यह पेज सर्व कर रहा है, तो बैकएंड के साथ यह पेज भी बंद हो सकता है।"
},
"nav": {
@@ -104,7 +104,7 @@
"update_check_failed": "अद्यतन जाँच विफल: {{message}}",
"save_failed": "सहेजना विफल: {{message}}",
"clear_failed": "साफ़ विफल: {{message}}",
"engine_switched": "{{family}} को {{engine}} में बदला गया",
"engine_switched": "{{family}} {{engine}}",
"channel_set_failed": "चैनल सेट करने में विफल: {{message}}",
"updater_downloading": "डाउनलोड हो रहा है {{version}}…",
"updater_installed": "इंस्टॉल किया गया - पुनः लॉन्च किया जा रहा है।",
@@ -492,12 +492,7 @@
"local_sqlite": "स्थानीय SQLite",
"translator_online": "अनुवादक ऑनलाइन है: {{provider}}",
"translator_offline": "ऑफ़लाइन अनुवादक",
"no_tracking": "कोई नहीं - कोई ट्रैकिंग नहीं",
"watermark_title": "अदृश्य वॉटरमार्क",
"watermark_subtitle": "डिफ़ॉल्ट रूप से चालू। बनाए गए ऑडियो को AI-निर्मित के रूप में चिह्नित करता है ताकि बाद में पहचाना जा सके। केवल अब से बनाए गए ऑडियो पर लागू।",
"watermark_on_toast": "नए ऑडियो के लिए अदृश्य वॉटरमार्क चालू किया गया।",
"watermark_off_toast": "नए ऑडियो के लिए अदृश्य वॉटरमार्क बंद किया गया।",
"watermark_failed": "वॉटरमार्क सेटिंग बदली नहीं जा सकी।"
"no_tracking": "कोई नहीं - कोई ट्रैकिंग नहीं"
},
"consent": {
"title": "OmniVoice को बेहतर बनाने में मदद करें?",
@@ -583,12 +578,7 @@
"routingRemote": "रिमोट",
"routingUnknown": "अज्ञात",
"routingEffectiveChip": "इस मशीन पर {{device}} पर चलता है",
"routingCaveatTitle": "GPU चयनित, लेकिन: {{reason}}",
"selectCpuFallback": "{{engine}}: CPU पर चल रहा है — {{reason}}",
"selectWithCaveat": "{{engine}} पर स्विच किया गया — {{reason}}",
"generateCaveat": "इस मशीन पर {{engine}} धीमा हो सकता है — {{reason}}",
"cpuLongText": "यहाँ {{engine}} CPU पर चलता है और यह टेक्स्ट लंबा है — जनरेशन समय सीमा पार कर सकता है। छोटा टेक्स्ट या CPU के लिए अनुकूलित इंजन (OmniVoice GGUF, Supertonic-3) कहीं तेज़ रहेगा।",
"cpuLongTextTuned": "यहाँ {{engine}} CPU पर चलता है और यह टेक्स्ट लंबा है — जनरेशन समय सीमा पार कर सकता है। छोटा टेक्स्ट, या इसे कुछ हिस्सों में बाँटना, कहीं तेज़ रहेगा।"
"routingCaveatTitle": "GPU चयनित, लेकिन: {{reason}}"
},
"capture": {
"desc": "ग्लोबल हॉटकीज़ केवल डेस्कटॉप ऐप में काम करती हैं। वेब यूआई इन-पेज <1>Ctrl+Shift+Space</1> शॉर्टकट का उपयोग करता है जबकि विंडो पर फोकस होता है।",
@@ -1445,14 +1435,10 @@
"desc": "चिंता न करें - बाकी ऐप अभी भी काम करता है। आप टैब स्विच कर सकते हैं, या नीचे पुनः प्रयास कर सकते हैं।",
"tryAgain": "पुनः प्रयास करें",
"openDocs": "इस त्रुटि के लिए दस्तावेज़ खोलें",
"crash_port_in_use": "बैकएंड शुरू नहीं हो सका क्योंकि पोर्ट 3900 पहले से उपयोग में है — OmniVoice की दूसरी प्रति (या उस पोर्ट पर दावा करने वाला ऐप) उसे रोके हुए है। दूसरी प्रति बंद करें और फिर से लॉन्च करें; यदि कुछ भी चलता दिखाई न दे, तो पिछले सत्र का छूटा हुआ बैकएंड अब भी पोर्ट रोके हुए है।",
"crash_oom_kill": "इसे जबरन बंद किया गया (सिग्नल 9), जिसका आमतौर पर मतलब है कि ऑपरेटिंग सिस्टम की मेमोरी (RAM) खत्म हो गई। मेमोरी-भारी ऐप बंद करें, सेटिंग्स → मॉडल्स में छोटा ASR मॉडल चुनें, या ट्रांसक्राइब करने से पहले TTS मॉडल हटाएँ।",
"crash_native_fault": "यह मेमोरी खत्म होने के बजाय कंप्यूट स्टैक के भीतर क्रैश हुआ — यह ऐसे GPU ड्राइवर की ओर संकेत करता है जो साथ दिए गए CUDA रनटाइम से मेल नहीं खाता, या ऐसी मॉडल फ़ाइल जो अधूरी डाउनलोड हुई। अपना GPU ड्राइवर अपडेट करें, फिर सेटिंग्स → मॉडल्स से मॉडल दोबारा डाउनलोड करें (यह अधूरे डाउनलोड को वहीं ठीक कर देता है)। यदि यह बार-बार हो, तो सेटिंग्स → इंजन में क्रैश-आइसोलेटेड इंजन पर जाएँ — संश्लेषण के लिए \"OmniVoice (subprocess)\", ट्रांसक्रिप्शन के लिए \"Faster-Whisper (crash-isolated subprocess)\"। ये मॉडल को अलग प्रोसेस में चलाते हैं, इसलिए ऐसा क्रैश पूरे बैकएंड के बजाय सिर्फ़ उस प्रोसेस को गिराता है।",
"report": "इस बग की रिपोर्ट करें",
"report_failed": "बग रिपोर्ट नहीं खुल सकी। कृपया फिर से कोशिश करें, या ऊपर दिए विवरण को नए इशू में कॉपी करें।",
"searchIssues": "मिलते-जुलते मुद्दे खोजें",
"unexpected": "अप्रत्याशित त्रुटि: {{message}}",
"backend_shutting_down": "OmniVoice बंद हो रहा है। ऐप दोबारा खोलें और फिर कोशिश करें।"
"unexpected": "अप्रत्याशित त्रुटि: {{message}}"
},
"keyboard": {
"title": "कीबोर्ड शॉर्टकट",
@@ -1763,7 +1749,7 @@
"q_try_before": "क्या मैं प्रतिबद्ध होने से पहले प्रयास कर सकता हूँ?",
"a_try_before": "हाँ। पूरा ऐप AGPL-3.0 के अंतर्गत मुफ़्त डाउनलोड, चलाने और सेल्फ़-होस्ट करने के लिए उपलब्ध है — किसी समझौते की ज़रूरत नहीं। जब आप व्यावसायिक (प्रोप्राइटरी-उपयोग) लाइसेंस पर चर्चा के लिए तैयार हों, तो हमें ईमेल करें और हम मिलकर विवरण तय करेंगे।",
"q_watermark": "वॉटरमार्क के बारे में क्या?",
"a_watermark": "अदृश्य AudioSeal वॉटरमार्क सभी के लिए डिफ़ॉल्ट रूप से जोड़ा जाता है। आप इसे सेटिंग्स → गोपनीयता में बंद कर सकते हैं; यह केवल बदलाव के बाद बनाए गए ऑडियो पर लागू होता है।"
"a_watermark": "अदृश्य AudioSeal वॉटरमार्क डिफ़ॉल्ट रूप से सभी के लिए एम्बेडेड है। व्यावसायिक लाइसेंसधारी इसे सेटिंग्स → गोपनीयता में अक्षम कर सकते हैं।"
},
"gallery_extra": {
"save_prompt": "इस ध्वनि प्रोफ़ाइल के लिए एक नाम दर्ज करें:",
@@ -1905,15 +1891,11 @@
"install_hint": "अपडेट डाउनलोड करें और नए संस्करण में पुनः आरंभ करें",
"downloading": "अपडेट हो रहा है… {{pct}}%",
"restart": "अपडेट करने के लिए पुनः आरंभ करें",
"busy": "चल रहे काम के पूरा होने का इंतज़ार करें — फिर अपडेट इंस्टॉल करें।",
"busy": "पहले अपनी डबिंग पूरी करें — फिर अपडेट इंस्टॉल करें।",
"whats_new": "नया क्या है",
"failed": "अद्यतन विफल रहा",
"retry": "पुनः प्रयास करें",
"dismiss": "ख़ारिज करें",
"toast_available": "OmniVoice Studio {{version}} उपलब्ध है",
"toast_install": "इंस्टॉल करें और पुनः आरंभ करें",
"toast_whats_new": "नया क्या है",
"toast_later": "बाद में"
"dismiss": "ख़ारिज करें"
},
"archetypes": {
"featured": "विशेष रुप से प्रदर्शित",
@@ -2267,10 +2249,7 @@
"email": "ईमेल",
"email_desc": "लाइसेंसिंग, साझेदारी, या कुछ भी निजी।",
"website": "वेबसाइट",
"website_desc": "परियोजना और निर्माता के बारे में अधिक जानकारी.",
"follow_title": "X पर फ़ॉलो करें",
"follow_desc": "रिलीज़ नोट्स, नए इंजन, और आगे क्या बन रहा है उसकी झलक। अगर आप चैट सर्वर में नहीं रहना चाहते तो यह सुविधाजनक है।",
"follow_cta": "X पर फ़ॉलो करें"
"website_desc": "परियोजना और निर्माता के बारे में अधिक जानकारी."
},
"permissions": {
"title": "अनुमतियाँ",
@@ -2300,4 +2279,4 @@
"output_title": "ऐप ने क्या बताया",
"retry_hint": "सेटिंग्स → लॉग → बैकएंड में \"पुनः प्रयास\" आज़माएँ। फिर भी विफल हो तो \"साफ़ करें और पुनः प्रयास\" वातावरण को नए सिरे से बनाता है।"
}
}
}
+10 -31
View File
@@ -2,7 +2,7 @@
"backendUnreachable": {
"contact_recent": "Backend masih merespons {{ago}} yang lalu lalu berhenti merespons — kemungkinan besar crash atau dihentikan di tengah permintaan.",
"contact_never": "Backend sama sekali belum merespons pada sesi ini — mungkin tidak pernah berhasil dijalankan.",
"dev": "Tidak dapat menghubungi backend OmniVoice lokal. {{contact}} Pada `bun run dev` backend berjalan dengan muat-ulang otomatis: setiap perubahan berkas — termasuk menyimpan saat ada permintaan berjalan — membuatnya dimulai ulang dan memutus koneksi. Coba ulangi tindakan dulu; jika itu muat-ulang, akan langsung berhasil. Jika terus gagal, periksa terminal `bun run dev` untuk traceback Python atau pesan keluar, dan omnivoice.log di folder data OmniVoice Anda.",
"dev": "Tidak dapat menghubungi backend OmniVoice lokal. {{contact}} Periksa terminal yang menjalankan `bun run dev` untuk traceback Python atau banner keluar, dan berkas omnivoice.log di folder data OmniVoice Anda untuk catatan terakhir backend.",
"server": "Tidak dapat menghubungi server backend OmniVoice. {{contact}} Periksa log server untuk penyebabnya (mis. `docker logs <container>` atau `journalctl`) — dan perlu diketahui, jika Docker menyajikan halaman ini, halaman ini sendiri bisa ikut mati bersama backend."
},
"nav": {
@@ -104,7 +104,7 @@
"update_check_failed": "Pemeriksaan pembaruan gagal: {{message}}",
"save_failed": "Gagal menyimpan: {{message}}",
"clear_failed": "Hapus gagal: {{message}}",
"engine_switched": "{{family}} dialihkan ke {{engine}}",
"engine_switched": "{{family}} {{engine}}",
"channel_set_failed": "Gagal menyetel saluran: {{message}}",
"updater_downloading": "Mengunduh {{version}}…",
"updater_installed": "Dipasang — diluncurkan kembali.",
@@ -492,12 +492,7 @@
"local_sqlite": "SQLite lokal",
"translator_online": "Penerjemah sedang online: {{provider}}",
"translator_offline": "Penerjemah luring",
"no_tracking": "Tidak ada — tidak ada pelacakan",
"watermark_title": "Tanda air tak terlihat",
"watermark_subtitle": "Aktif secara bawaan. Menandai audio hasil buatan sebagai dibuat AI agar dapat dikenali nanti. Hanya memengaruhi audio yang dibuat mulai sekarang.",
"watermark_on_toast": "Tanda air tak terlihat diaktifkan untuk audio baru.",
"watermark_off_toast": "Tanda air tak terlihat dimatikan untuk audio baru.",
"watermark_failed": "Tidak dapat mengubah pengaturan tanda air."
"no_tracking": "Tidak ada — tidak ada pelacakan"
},
"consent": {
"title": "Bantu meningkatkan OmniVoice?",
@@ -583,12 +578,7 @@
"routingRemote": "Terpencil",
"routingUnknown": "Tidak diketahui",
"routingEffectiveChip": "Berjalan pada {{device}} di mesin ini",
"routingCaveatTitle": "GPU dipilih, tetapi: {{reason}}",
"selectCpuFallback": "{{engine}}: berjalan di CPU — {{reason}}",
"selectWithCaveat": "Beralih ke {{engine}} — {{reason}}",
"generateCaveat": "{{engine}} mungkin lambat di komputer ini — {{reason}}",
"cpuLongText": "{{engine}} berjalan di CPU di sini dan teks ini panjang — proses mungkin melampaui batas waktu. Teks lebih pendek atau mesin yang dioptimalkan untuk CPU (OmniVoice GGUF, Supertonic-3) akan jauh lebih cepat.",
"cpuLongTextTuned": "{{engine}} berjalan di CPU di sini dan teks ini panjang — proses mungkin melampaui batas waktu. Teks lebih pendek, atau membaginya menjadi beberapa bagian, akan jauh lebih cepat."
"routingCaveatTitle": "GPU dipilih, tetapi: {{reason}}"
},
"capture": {
"desc": "Tombol pintas global hanya berfungsi di aplikasi desktop. UI web menggunakan pintasan <1>Ctrl+Shift+Space</1> dalam halaman saat jendela memiliki fokus.",
@@ -1445,14 +1435,10 @@
"desc": "Jangan khawatir — aplikasi lainnya masih berfungsi. Anda dapat berpindah tab, atau coba lagi di bawah.",
"tryAgain": "Coba lagi",
"openDocs": "Buka dokumen untuk kesalahan ini",
"crash_port_in_use": "Backend tidak dapat dijalankan karena port 3900 sudah digunakan — salinan OmniVoice lain (atau aplikasi yang mengklaim port itu) sedang menahannya. Tutup instance lain lalu jalankan ulang; jika tidak ada yang terlihat berjalan, backend yatim dari sesi sebelumnya masih menahan port tersebut.",
"crash_oom_kill": "Proses dihentikan paksa (sinyal 9), yang biasanya berarti sistem operasi kehabisan memori (RAM). Tutup aplikasi yang boros memori, pilih model ASR yang lebih kecil di Pengaturan → Model, atau bongkar model TTS sebelum mentranskripsi.",
"crash_native_fault": "Proses mogok di dalam tumpukan komputasi, bukan karena kehabisan memori — itu menunjuk pada driver GPU yang tidak cocok dengan runtime CUDA bawaan, atau berkas model yang terunduh tidak lengkap. Perbarui driver GPU Anda, lalu unduh ulang model dari Pengaturan → Model (unduhan parsial diperbaiki di tempat). Jika terus terjadi, beralihlah ke mesin terisolasi di Pengaturan → Mesin — \"OmniVoice (subprocess)\" untuk sintesis, \"Faster-Whisper (crash-isolated subprocess)\" untuk transkripsi. Keduanya menjalankan model di proses terpisah, sehingga kerusakan seperti ini menjatuhkan proses itu saja, bukan seluruh backend.",
"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}}",
"backend_shutting_down": "OmniVoice sedang ditutup. Buka kembali aplikasinya lalu coba lagi."
"unexpected": "Kesalahan tak terduga: {{message}}"
},
"keyboard": {
"title": "Pintasan keyboard",
@@ -1763,7 +1749,7 @@
"q_try_before": "Bisakah saya mencoba sebelum melakukan?",
"a_try_before": "Ya. Aplikasi lengkap gratis untuk diunduh, dijalankan, dan di-host sendiri di bawah AGPL-3.0 — tanpa perjanjian apa pun. Saat Anda siap membahas lisensi komersial (penggunaan proprietary), kirim email kepada kami dan kita akan menyelesaikan detailnya bersama.",
"q_watermark": "Bagaimana dengan tanda airnya?",
"a_watermark": "Tanda air AudioSeal yang tak terlihat disematkan secara bawaan untuk semua orang. Anda dapat mematikannya di Pengaturan → Privasi; ini hanya memengaruhi audio yang dibuat setelah perubahan."
"a_watermark": "Tanda air AudioSeal yang tidak terlihat tertanam secara default untuk semua orang. Pemegang lisensi komersial dapat menonaktifkannya di Pengaturan → Privasi."
},
"gallery_extra": {
"save_prompt": "Masukkan nama untuk profil suara ini:",
@@ -1905,15 +1891,11 @@
"install_hint": "Unduh pembaruan dan mulai ulang ke versi baru",
"downloading": "Memperbarui… {{pct}}%",
"restart": "Mulai ulang untuk memperbarui",
"busy": "Tunggu pekerjaan yang sedang berjalan selesai — lalu instal pembaruannya.",
"busy": "Selesaikan sulih suara Anda dulu — lalu instal pembaruannya.",
"whats_new": "Apa yang baru",
"failed": "Pembaruan gagal",
"retry": "Coba lagi",
"dismiss": "Singkirkan",
"toast_available": "OmniVoice Studio {{version}} tersedia",
"toast_install": "Instal dan mulai ulang",
"toast_whats_new": "Yang baru",
"toast_later": "Nanti"
"dismiss": "Singkirkan"
},
"archetypes": {
"featured": "Unggulan",
@@ -2267,10 +2249,7 @@
"email": "Surel",
"email_desc": "Perizinan, kemitraan, atau apa pun yang bersifat pribadi.",
"website": "Situs web",
"website_desc": "Lebih lanjut tentang proyek dan pembuatnya.",
"follow_title": "Ikuti di X",
"follow_desc": "Catatan rilis, mesin baru, dan sesekali intip apa yang sedang dibangun. Praktis jika Anda tidak ingin berada di server obrolan.",
"follow_cta": "Ikuti di X"
"website_desc": "Lebih lanjut tentang proyek dan pembuatnya."
},
"permissions": {
"title": "Izin",
@@ -2300,4 +2279,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."
}
}
}
+10 -31
View File
@@ -2,7 +2,7 @@
"backendUnreachable": {
"contact_recent": "Rispondeva {{ago}} fa e poi ha smesso di rispondere — molto probabilmente è andato in crash o è stato terminato durante una richiesta.",
"contact_never": "Non ha risposto affatto in questa sessione — forse non si è mai avviato.",
"dev": "Impossibile raggiungere il backend OmniVoice locale. {{contact}} Con `bun run dev` il backend gira con ricarica automatica: qualsiasi modifica a un file — incluso un salvataggio mentre una richiesta era in corso — lo riavvia e interrompe la connessione. Riprovi prima l'operazione; se era una ricarica, funzionerà. Se continua a fallire, controlli il terminale con `bun run dev` per un traceback Python o un messaggio di uscita, e omnivoice.log nella sua cartella dati OmniVoice.",
"dev": "Impossibile raggiungere il backend locale di OmniVoice. {{contact}} Controlla il terminale che esegue `bun run dev` per un traceback Python o un banner di uscita, e il file omnivoice.log nella cartella dati di OmniVoice per l'ultima cosa registrata dal backend.",
"server": "Impossibile raggiungere il server backend di OmniVoice. {{contact}} Controlla i log del server per la causa (es. `docker logs <container>` o `journalctl`) — e tieni presente che se Docker serve questa pagina, la pagina stessa può cadere insieme al backend."
},
"nav": {
@@ -104,7 +104,7 @@
"update_check_failed": "Controllo aggiornamento non riuscito: {{message}}",
"save_failed": "Salvataggio non riuscito: {{message}}",
"clear_failed": "Cancellazione non riuscita: {{message}}",
"engine_switched": "{{family}} passato a {{engine}}",
"engine_switched": "{{family}} {{engine}}",
"channel_set_failed": "Impossibile impostare il canale: {{message}}",
"updater_downloading": "Download in corso di {{version}}...",
"updater_installed": "Installato: riavvio.",
@@ -492,12 +492,7 @@
"local_sqlite": "SQLite locale",
"translator_online": "Il traduttore è online: {{provider}}",
"translator_offline": "Traduttore offline",
"no_tracking": "Nessuno: nessun tracciamento",
"watermark_title": "Filigrana invisibile",
"watermark_subtitle": "Attiva per impostazione predefinita. Contrassegna l'audio generato come creato dall'IA per poterlo identificare in seguito. Riguarda solo l'audio generato da ora in poi.",
"watermark_on_toast": "Filigrana invisibile attivata per i nuovi audio.",
"watermark_off_toast": "Filigrana invisibile disattivata per i nuovi audio.",
"watermark_failed": "Non è stato possibile modificare l'impostazione della filigrana."
"no_tracking": "Nessuno: nessun tracciamento"
},
"consent": {
"title": "Aiutare a migliorare OmniVoice?",
@@ -583,12 +578,7 @@
"routingRemote": "Remoto",
"routingUnknown": "Sconosciuto",
"routingEffectiveChip": "Funziona su {{device}} su questa macchina",
"routingCaveatTitle": "GPU selezionata, ma: {{reason}}",
"selectCpuFallback": "{{engine}}: in esecuzione sulla CPU — {{reason}}",
"selectWithCaveat": "Passato a {{engine}} — {{reason}}",
"generateCaveat": "{{engine}} potrebbe essere lento su questo computer — {{reason}}",
"cpuLongText": "{{engine}} qui viene eseguito sulla CPU e questo testo è lungo: la generazione potrebbe superare il limite di tempo. Un testo più breve o un motore ottimizzato per CPU (OmniVoice GGUF, Supertonic-3) sarà molto più veloce.",
"cpuLongTextTuned": "{{engine}} qui viene eseguito sulla CPU e questo testo è lungo: la generazione potrebbe superare il limite di tempo. Un testo più breve, o diviso in più riprese, sarà molto più veloce."
"routingCaveatTitle": "GPU selezionata, ma: {{reason}}"
},
"capture": {
"desc": "I tasti di scelta rapida globali funzionano solo nell'app desktop. L'interfaccia utente Web utilizza una scorciatoia <1>Ctrl+Shift+Spazio</1> nella pagina mentre la finestra è attiva.",
@@ -1445,14 +1435,10 @@
"desc": "Non preoccuparti: il resto dell'app funziona ancora. Puoi cambiare scheda o riprovare di seguito.",
"tryAgain": "Riprova",
"openDocs": "Apri i documenti per questo errore",
"crash_port_in_use": "Il backend non è riuscito ad avviarsi perché la porta 3900 è già in uso: un'altra copia di OmniVoice (o un'app che ha richiesto quella porta) la sta occupando. Chiuda l'altra istanza e riavvii; se non risulta nulla in esecuzione, un backend orfano di una sessione precedente occupa ancora la porta.",
"crash_oom_kill": "È stato terminato forzatamente (segnale 9), il che di solito significa che il sistema operativo ha esaurito la memoria (RAM). Chiuda le app che usano molta memoria, scelga un modello ASR più piccolo in Impostazioni → Modelli, oppure scarichi il modello TTS prima di trascrivere.",
"crash_native_fault": "Si è arrestato all'interno dello stack di calcolo anziché per esaurimento della memoria: questo indica un driver GPU non compatibile con il runtime CUDA incluso, oppure un file del modello scaricato in modo incompleto. Aggiorni il driver della GPU, poi riscarichi il modello da Impostazioni → Modelli (ripara sul posto un download parziale). Se continua a succedere, passi a un motore isolato in Impostazioni → Motori: «OmniVoice (subprocess)» per la sintesi, «Faster-Whisper (crash-isolated subprocess)» per la trascrizione. Eseguono il modello in un processo separato, così un arresto come questo abbatte quel processo invece dell'intero backend.",
"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}}",
"backend_shutting_down": "OmniVoice si sta chiudendo. Riapri lapp e riprova."
"unexpected": "Errore imprevisto: {{message}}"
},
"keyboard": {
"title": "Scorciatoie da tastiera",
@@ -1763,7 +1749,7 @@
"q_try_before": "Posso provare prima di impegnarmi?",
"a_try_before": "Sì. L'app completa è gratuita da scaricare, eseguire e self-hostare sotto AGPL-3.0, senza alcun accordo. Quando vorrai parlare di una licenza commerciale (uso proprietario), scrivici e definiremo i dettagli insieme.",
"q_watermark": "E la filigrana?",
"a_watermark": "La filigrana invisibile AudioSeal viene incorporata per impostazione predefinita per tutti. Può disattivarla in Impostazioni → Privacy; riguarda solo l'audio generato dopo la modifica."
"a_watermark": "La filigrana invisibile AudioSeal è incorporata per impostazione predefinita per tutti. I licenziatari commerciali possono disabilitarla in Impostazioni → Privacy."
},
"gallery_extra": {
"save_prompt": "Inserisci un nome per questo profilo vocale:",
@@ -1905,15 +1891,11 @@
"install_hint": "Scarica l'aggiornamento e riavvia nella nuova versione",
"downloading": "Aggiornamento… {{pct}}%",
"restart": "Riavvia per aggiornare",
"busy": "Attendi il completamento del lavoro in corso, poi installa laggiornamento.",
"busy": "Completa prima il tuo doppiaggio, poi installa l'aggiornamento.",
"whats_new": "Cosa c'è di nuovo",
"failed": "Aggiornamento non riuscito",
"retry": "Riprova",
"dismiss": "Ignora",
"toast_available": "OmniVoice Studio {{version}} è disponibile",
"toast_install": "Installa e riavvia",
"toast_whats_new": "Novità",
"toast_later": "Più tardi"
"dismiss": "Ignora"
},
"archetypes": {
"featured": "In primo piano",
@@ -2267,10 +2249,7 @@
"email": "E-mail",
"email_desc": "Licenze, partnership o qualsiasi cosa privata.",
"website": "Sito web",
"website_desc": "Maggiori informazioni sul progetto e sul produttore.",
"follow_title": "Segui su X",
"follow_desc": "Note di rilascio, nuovi motori e qualche anticipazione su ciò che sta arrivando. Comodo se preferisce non stare in un server di chat.",
"follow_cta": "Segui su X"
"website_desc": "Maggiori informazioni sul progetto e sul produttore."
},
"permissions": {
"title": "Autorizzazioni",
@@ -2300,4 +2279,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."
}
}
}
+13 -34
View File
@@ -2,7 +2,7 @@
"backendUnreachable": {
"contact_recent": "{{ago}}前までは応答していましたが、応答が止まりました — リクエストの途中でクラッシュしたか、強制終了された可能性が高いです。",
"contact_never": "このセッションでは一度も応答していません — そもそも起動していない可能性があります。",
"dev": "ローカルの OmniVoice バックエンドに接続できません。{{contact}} `bun run dev` ではバックエンドが自動リロードで動作するため、ファイルの変更(リクエスト中の保存も含む)で再起動し、接続が切れます。まず操作をやり直してください。リロードが原因ならそれで動きます。それでも失敗する場合は、`bun run dev` を実行しているターミナル Python トレースバックや終了メッセージ、および OmniVoice データフォルダーの omnivoice.log を確認してください。",
"dev": "ローカルの OmniVoice バックエンドに接続できません。{{contact}} `bun run dev` を実行しているターミナル Python トレースバックや終了バナーを確認し、OmniVoice データフォルダーの omnivoice.log でバックエンドの最後のログを確認してください。",
"server": "OmniVoice バックエンドサーバーに接続できません。{{contact}} サーバーログで原因を確認してください(例: `docker logs <container>` や `journalctl`)。なお、このページを Docker が配信している場合、バックエンドと一緒にページ自体も落ちることがあります。"
},
"nav": {
@@ -104,7 +104,7 @@
"update_check_failed": "更新チェックに失敗しました: {{message}}",
"save_failed": "保存に失敗しました: {{message}}",
"clear_failed": "クリアに失敗しました: {{message}}",
"engine_switched": "{{family}} {{engine}} に切り替えました",
"engine_switched": "{{family}} {{engine}}",
"channel_set_failed": "チャネルの設定に失敗しました: {{message}}",
"updater_downloading": "{{version}} をダウンロード中…",
"updater_installed": "インストール済み — 再起動中。",
@@ -308,8 +308,8 @@
"frontend": "フロントエンド",
"tauri": "タウリ",
"cancelOp": "操作をキャンセルする",
"dismiss": "閉じる",
"dismissStatus": "ステータスを閉じる",
"dismiss": "解雇する",
"dismissStatus": "ステータスを却下",
"search": "検索…",
"no_matches": "一致しません",
"recent_and_popular": "最近の人気の",
@@ -492,12 +492,7 @@
"local_sqlite": "ローカルSQLite",
"translator_online": "翻訳者はオンラインです: {{provider}}",
"translator_offline": "オフライン翻訳者",
"no_tracking": "なし — 追跡なし",
"watermark_title": "不可視の透かし",
"watermark_subtitle": "既定で有効。生成した音声を AI 生成として印付けし、後から識別できるようにします。今後生成する音声にのみ適用されます。",
"watermark_on_toast": "新しい音声に不可視の透かしを有効にしました。",
"watermark_off_toast": "新しい音声の不可視の透かしを無効にしました。",
"watermark_failed": "透かしの設定を変更できませんでした。"
"no_tracking": "なし — 追跡なし"
},
"consent": {
"title": "OmniVoice の改善に協力しますか?",
@@ -583,12 +578,7 @@
"routingRemote": "リモート",
"routingUnknown": "不明",
"routingEffectiveChip": "このマシンの {{device}} で実行されます",
"routingCaveatTitle": "GPU が選択されましたが: {{reason}}",
"selectCpuFallback": "{{engine}}: CPU で実行中 — {{reason}}",
"selectWithCaveat": "{{engine}} に切り替えました — {{reason}}",
"generateCaveat": "このマシンでは {{engine}} の生成が遅くなる可能性があります — {{reason}}",
"cpuLongText": "ここでは {{engine}} が CPU で実行され、このテキストは長いため、生成が時間制限を超える可能性があります。短いテキスト、または CPU 向けエンジン(OmniVoice GGUF、Supertonic-3)のほうが大幅に高速です。",
"cpuLongTextTuned": "ここでは {{engine}} が CPU で実行され、このテキストは長いため、生成が時間制限を超える可能性があります。テキストを短くするか、数回に分けたほうが大幅に高速です。"
"routingCaveatTitle": "GPU が選択されましたが: {{reason}}"
},
"capture": {
"desc": "グローバル ホットキーはデスクトップ アプリでのみ機能します。 Web UI は、ウィンドウにフォーカスがある間、ページ内 <1>Ctrl+Shift+Space</1> ショートカットを使用します。",
@@ -1286,7 +1276,7 @@
"later": "たぶん後で",
"star": "GitHub でスターを付ける",
"opt_out": "二度と聞かないでください",
"dismiss_aria": "閉じる"
"dismiss_aria": "解雇する"
}
},
"enterprise": {
@@ -1445,14 +1435,10 @@
"desc": "心配しないでください。アプリの残りの部分は引き続き動作します。タブを切り替えるか、以下でもう一度お試しください。",
"tryAgain": "もう一度試してください",
"openDocs": "このエラーのドキュメントを開く",
"crash_port_in_use": "ポート 3900 がすでに使用されているため、バックエンドを起動できませんでした。別の OmniVoice(またはそのポートを取得したアプリ)が保持しています。もう一方を終了して再起動してください。見えている実行中のものがない場合は、前のセッションの取り残されたバックエンドがポートを保持しています。",
"crash_oom_kill": "強制終了されました(シグナル 9)。通常はオペレーティングシステムのメモリ(RAM)が不足して停止したことを意味します。メモリを多く使うアプリを閉じ、設定 → モデルでより小さい ASR モデルを選ぶか、文字起こしの前に TTS モデルをアンロードしてください。",
"crash_native_fault": "メモリ不足ではなく、計算スタックの内部でクラッシュしました。同梱の CUDA ランタイムと一致しない GPU ドライバー、またはダウンロードが不完全なモデルファイルが原因と考えられます。GPU ドライバーを更新し、設定 → モデルからモデルを再ダウンロードしてください(不完全なダウンロードはその場で修復されます)。繰り返す場合は、設定 → エンジンでクラッシュ分離エンジンに切り替えてください — 音声合成には「OmniVoice (subprocess)」、文字起こしには「Faster-Whisper (crash-isolated subprocess)」。これらはモデルを別プロセスで実行するため、このようなクラッシュはバックエンド全体ではなくそのプロセスだけを停止させます。",
"report": "このバグを報告",
"report_failed": "バグレポートを開けませんでした。もう一度お試しいただくか、上記の内容を新しい Issue にコピーしてください。",
"searchIssues": "類似の問題を検索",
"unexpected": "予期しないエラー: {{message}}",
"backend_shutting_down": "OmniVoice を終了しています。アプリを開き直してからもう一度お試しください。"
"unexpected": "予期しないエラー: {{message}}"
},
"keyboard": {
"title": "キーボードショートカット",
@@ -1763,7 +1749,7 @@
"q_try_before": "コミットする前に試してみることはできますか?",
"a_try_before": "はい。アプリ全体を AGPL-3.0 の下で無料でダウンロード・実行・セルフホストでき、契約は不要です。商用(プロプライエタリ利用)ライセンスについて相談する準備ができましたら、メールでご連絡ください。詳細を一緒に詰めていきましょう。",
"q_watermark": "透かしについてはどうですか?",
"a_watermark": "不可視の AudioSeal 透かしは、既定ですべてのユーザーに埋め込まれます。設定 → プライバシーで無効にできます。変更後に生成された音声にのみ適用されます。"
"a_watermark": "目に見えない AudioSeal ウォーターマークは、デフォルトで全員に埋め込まれます。商用ライセンシーは「設定」→「プライバシーで無効にできます。"
},
"gallery_extra": {
"save_prompt": "この音声プロファイルの名前を入力してください:",
@@ -1905,15 +1891,11 @@
"install_hint": "アップデートをダウンロードして新しいバージョンで再起動します",
"downloading": "更新中… {{pct}}%",
"restart": "再起動してアップデート",
"busy": "実行中の処理が終わってから、アップデートをインストールしてください。",
"busy": "先に吹き替えを完了してから、アップデートをインストールしてください。",
"whats_new": "新機能",
"failed": "アップデートに失敗しました",
"retry": "再試行",
"dismiss": "閉じる",
"toast_available": "OmniVoice Studio {{version}} が利用可能です",
"toast_install": "インストールして再起動",
"toast_whats_new": "新機能",
"toast_later": "後で"
"dismiss": "解雇する"
},
"archetypes": {
"featured": "注目の",
@@ -2267,10 +2249,7 @@
"email": "電子メール",
"email_desc": "ライセンス、パートナーシップ、その他プライベートなもの。",
"website": "ウェブサイト",
"website_desc": "プロジェクトとメーカーについて詳しく説明します。",
"follow_title": "X でフォローする",
"follow_desc": "リリースノート、新しいエンジン、そして次に作っているものの様子をときどき。チャットサーバーに常駐したくない方に便利です。",
"follow_cta": "X でフォロー"
"website_desc": "プロジェクトとメーカーについて詳しく説明します。"
},
"permissions": {
"title": "権限",
@@ -2300,4 +2279,4 @@
"output_title": "アプリが報告した内容",
"retry_hint": "設定 → ログ → バックエンド の「再試行」をお試しください。再度失敗する場合は「クリーンして再試行」で環境を一から再構築します。"
}
}
}
+10 -31
View File
@@ -2,7 +2,7 @@
"backendUnreachable": {
"contact_recent": "{{ago}} 전까지는 응답하다가 응답을 멈췄습니다 — 요청 처리 중에 크래시했거나 강제 종료되었을 가능성이 큽니다.",
"contact_never": "이번 세션에서 한 번도 응답하지 않았습니다 — 아예 시작되지 않았을 수 있습니다.",
"dev": "로컬 OmniVoice 백엔드에 연결할 수 없습니다. {{contact}} `bun run dev`에서는 백엔드가 자동 리로드로 실행되므로, 파일 변경(요청 도중 저장 포함)이 백엔드를 재시작하고 연결을 끊습니다. 먼저 작업을 다시 시도하세요. 리로드였다면 바로 됩니다. 계속 실패하면 `bun run dev`를 실행한 터미널 Python 트레이스백이나 종료 메시지, 그리고 OmniVoice 데이터 폴더의 omnivoice.log를 확인하세요.",
"dev": "로컬 OmniVoice 백엔드에 연결할 수 없습니다. {{contact}} `bun run dev`를 실행 중인 터미널에서 Python 트레이스백이나 종료 배너를 확인하고, OmniVoice 데이터 폴더의 omnivoice.log 파일에서 백엔드의 마지막 로그를 확인하세요.",
"server": "OmniVoice 백엔드 서버에 연결할 수 없습니다. {{contact}} 서버 로그에서 원인을 확인하세요(예: `docker logs <container>` 또는 `journalctl`) — Docker가 이 페이지를 제공하는 경우 백엔드와 함께 페이지 자체가 중단될 수 있습니다."
},
"nav": {
@@ -104,7 +104,7 @@
"update_check_failed": "업데이트 확인 실패: {{message}}",
"save_failed": "저장 실패: {{message}}",
"clear_failed": "지우기 실패: {{message}}",
"engine_switched": "{{family}}을(를) {{engine}}(으)로 전환했습니다",
"engine_switched": "{{family}} {{engine}}",
"channel_set_failed": "채널 설정 실패: {{message}}",
"updater_downloading": "{{version}} 다운로드 중…",
"updater_installed": "설치됨 - 다시 시작하는 중입니다.",
@@ -492,12 +492,7 @@
"local_sqlite": "로컬 SQLite",
"translator_online": "번역가가 온라인 상태입니다: {{provider}}",
"translator_offline": "오프라인 번역기",
"no_tracking": "없음 — 추적 없음",
"watermark_title": "보이지 않는 워터마크",
"watermark_subtitle": "기본으로 켜져 있습니다. 생성된 오디오를 AI 제작물로 표시해 나중에 식별할 수 있게 합니다. 지금부터 생성되는 오디오에만 적용됩니다.",
"watermark_on_toast": "새 오디오에 보이지 않는 워터마크를 켰습니다.",
"watermark_off_toast": "새 오디오의 보이지 않는 워터마크를 껐습니다.",
"watermark_failed": "워터마크 설정을 변경할 수 없습니다."
"no_tracking": "없음 — 추적 없음"
},
"consent": {
"title": "OmniVoice 개선에 참여하시겠어요?",
@@ -583,12 +578,7 @@
"routingRemote": "원격",
"routingUnknown": "알 수 없음",
"routingEffectiveChip": "이 머신의 {{device}}에서 실행됩니다.",
"routingCaveatTitle": "GPU가 선택되었지만: {{reason}}",
"selectCpuFallback": "{{engine}}: CPU에서 실행 중 — {{reason}}",
"selectWithCaveat": "{{engine}}(으)로 전환했습니다 — {{reason}}",
"generateCaveat": "이 컴퓨터에서는 {{engine}}이(가) 느릴 수 있습니다 — {{reason}}",
"cpuLongText": "여기서는 {{engine}}이(가) CPU에서 실행되며 이 텍스트는 길어 생성이 시간 제한을 초과할 수 있습니다. 더 짧은 텍스트나 CPU에 최적화된 엔진(OmniVoice GGUF, Supertonic-3)이 훨씬 빠릅니다.",
"cpuLongTextTuned": "여기서는 {{engine}}이(가) CPU에서 실행되며 이 텍스트는 길어 생성이 시간 제한을 초과할 수 있습니다. 텍스트를 줄이거나 여러 번에 나누면 훨씬 빠릅니다."
"routingCaveatTitle": "GPU가 선택되었지만: {{reason}}"
},
"capture": {
"desc": "전역 단축키는 데스크톱 앱에서만 작동합니다. 웹 UI는 창에 포커스가 있는 동안 페이지 내 <1>Ctrl+Shift+Space</1> 단축키를 사용합니다.",
@@ -1445,14 +1435,10 @@
"desc": "걱정하지 마세요. 앱의 나머지 부분은 여전히 작동합니다. 탭을 전환하거나 아래에서 다시 시도해 보세요.",
"tryAgain": "다시 시도하세요",
"openDocs": "이 오류에 대한 문서 열기",
"crash_port_in_use": "포트 3900이 이미 사용 중이어서 백엔드를 시작하지 못했습니다 — 다른 OmniVoice 사본(또는 해당 포트를 점유한 앱)이 붙잡고 있습니다. 다른 인스턴스를 종료한 뒤 다시 실행하세요. 눈에 띄게 실행 중인 것이 없다면 이전 세션에서 남은 백엔드가 아직 포트를 점유하고 있습니다.",
"crash_oom_kill": "강제 종료되었습니다(시그널 9). 보통 운영체제의 메모리(RAM)가 부족해 중단되었다는 뜻입니다. 메모리를 많이 쓰는 앱을 닫거나, 설정 → 모델에서 더 작은 ASR 모델을 선택하거나, 전사 전에 TTS 모델을 언로드하세요.",
"crash_native_fault": "메모리 부족이 아니라 연산 스택 내부에서 충돌했습니다. 이는 번들된 CUDA 런타임과 맞지 않는 GPU 드라이버이거나, 불완전하게 내려받은 모델 파일을 가리킵니다. GPU 드라이버를 업데이트한 뒤 설정 → 모델에서 모델을 다시 내려받으세요(부분 다운로드를 그 자리에서 복구합니다). 계속 발생하면 설정 → 엔진에서 충돌 격리 엔진으로 전환하세요 — 합성은 \"OmniVoice (subprocess)\", 전사는 \"Faster-Whisper (crash-isolated subprocess)\". 이들은 모델을 별도 프로세스에서 실행하므로 이런 충돌이 백엔드 전체가 아니라 해당 프로세스만 중단시킵니다.",
"report": "이 버그 신고",
"report_failed": "버그 리포트를 열지 못했습니다. 다시 시도하거나 위 내용을 새 이슈에 복사해 주세요.",
"searchIssues": "유사한 문제 검색",
"unexpected": "예기치 않은 오류: {{message}}",
"backend_shutting_down": "OmniVoice를 종료하는 중입니다. 앱을 다시 열고 시도하세요."
"unexpected": "예기치 않은 오류: {{message}}"
},
"keyboard": {
"title": "키보드 단축키",
@@ -1763,7 +1749,7 @@
"q_try_before": "커밋하기 전에 시도해 볼 수 있나요?",
"a_try_before": "예. 전체 앱은 AGPL-3.0에 따라 무료로 다운로드, 실행, 자체 호스팅할 수 있으며 별도의 계약이 필요 없습니다. 상업용(독점 사용) 라이선스에 대해 논의할 준비가 되면 이메일로 연락해 주세요. 함께 세부 사항을 조율하겠습니다.",
"q_watermark": "워터마크는 어떻습니까?",
"a_watermark": "보이지 않는 AudioSeal 워터마크는 모든 사용자에게 기본으로 삽입됩니다. 설정 → 개인정보에서 끌 수 있으며, 변경 이후에 생성된 오디오에만 적용됩니다."
"a_watermark": "보이지 않는 AudioSeal 워터마크는 기본적으로 모든 사용자에게 내장됩니다. 상업용 라이선스 사용자는 설정 → 개인정보 보호에서 비활성화할 수 있습니다."
},
"gallery_extra": {
"save_prompt": "이 음성 프로필의 이름을 입력하세요.",
@@ -1905,15 +1891,11 @@
"install_hint": "업데이트를 다운로드하고 새 버전으로 다시 시작합니다",
"downloading": "업데이트 중… {{pct}}%",
"restart": "다시 시작하여 업데이트",
"busy": "진행 중인 작업이 끝난 후 업데이트를 설치하세요.",
"busy": "먼저 더빙을 완료한 후 업데이트를 설치하세요.",
"whats_new": "새로운 소식",
"failed": "업데이트 실패",
"retry": "재시도",
"dismiss": "닫기",
"toast_available": "OmniVoice Studio {{version}}을(를) 사용할 수 있습니다",
"toast_install": "설치 후 재시작",
"toast_whats_new": "새로운 기능",
"toast_later": "나중에"
"dismiss": "닫기"
},
"archetypes": {
"featured": "추천",
@@ -2267,10 +2249,7 @@
"email": "이메일",
"email_desc": "라이센스, 파트너십 또는 개인적인 모든 것.",
"website": "웹사이트",
"website_desc": "프로젝트와 제작자에 대해 자세히 알아보세요.",
"follow_title": "X에서 팔로우하기",
"follow_desc": "릴리스 노트, 새 엔진, 그리고 다음에 만들고 있는 것들에 대한 이야기. 채팅 서버에 머무르고 싶지 않다면 편리합니다.",
"follow_cta": "X에서 팔로우"
"website_desc": "프로젝트와 제작자에 대해 자세히 알아보세요."
},
"permissions": {
"title": "권한",
@@ -2300,4 +2279,4 @@
"output_title": "앱이 보고한 내용",
"retry_hint": "설정 → 로그 → 백엔드에서 \"다시 시도\"를 눌러 보세요. 다시 실패하면 \"정리 후 다시 시도\"가 환경을 처음부터 다시 구성합니다."
}
}
}
+10 -31
View File
@@ -2,7 +2,7 @@
"backendUnreachable": {
"contact_recent": "Het reageerde {{ago}} geleden nog en stopte toen met reageren — waarschijnlijk is het midden in een verzoek gecrasht of beëindigd.",
"contact_never": "Het heeft deze sessie helemaal niet gereageerd — mogelijk is het nooit gestart.",
"dev": "Kan de lokale OmniVoice-backend niet bereiken. {{contact}} Bij `bun run dev` draait de backend met automatisch herladen: elke bestandswijziging — ook opslaan terwijl er een verzoek liep — herstart hem en verbreekt de verbinding. Probeer de actie eerst opnieuw; als het een herlaad was, werkt het gewoon. Blijft het mislukken, kijk dan in de terminal met `bun run dev` naar een Python-traceback of een exit-melding, en in omnivoice.log in uw OmniVoice-gegevensmap.",
"dev": "Kan de lokale OmniVoice-backend niet bereiken. {{contact}} Controleer de terminal met `bun run dev` op een Python-traceback of exit-banner, en het bestand omnivoice.log in je OmniVoice-gegevensmap voor de laatste logregel van de backend.",
"server": "Kan de OmniVoice-backendserver niet bereiken. {{contact}} Controleer de serverlogs voor de oorzaak (bijv. `docker logs <container>` of `journalctl`) — en let op: als Docker deze pagina serveert, kan de pagina zelf samen met de backend uitvallen."
},
"nav": {
@@ -104,7 +104,7 @@
"update_check_failed": "Updatecontrole mislukt: {{message}}",
"save_failed": "Opslaan mislukt: {{message}}",
"clear_failed": "Wissen mislukt: {{message}}",
"engine_switched": "{{family}} overgeschakeld naar {{engine}}",
"engine_switched": "{{family}} {{engine}}",
"channel_set_failed": "Kan kanaal niet instellen: {{message}}",
"updater_downloading": "{{version}} downloaden…",
"updater_installed": "Geïnstalleerd - opnieuw gestart.",
@@ -492,12 +492,7 @@
"local_sqlite": "Lokale SQLite",
"translator_online": "Vertaler is online: {{provider}}",
"translator_offline": "Offline vertaler",
"no_tracking": "Geen — geen tracking",
"watermark_title": "Onzichtbaar watermerk",
"watermark_subtitle": "Standaard aan. Markeert gegenereerde audio als AI-gemaakt zodat het later herkend kan worden. Geldt alleen voor audio die vanaf nu wordt gegenereerd.",
"watermark_on_toast": "Onzichtbaar watermerk aan voor nieuwe audio.",
"watermark_off_toast": "Onzichtbaar watermerk uit voor nieuwe audio.",
"watermark_failed": "Kon de watermerkinstelling niet wijzigen."
"no_tracking": "Geen — geen tracking"
},
"consent": {
"title": "Helpen OmniVoice te verbeteren?",
@@ -583,12 +578,7 @@
"routingRemote": "Op afstand",
"routingUnknown": "Onbekend",
"routingEffectiveChip": "Draait op {{device}} op deze machine",
"routingCaveatTitle": "GPU geselecteerd, maar: {{reason}}",
"selectCpuFallback": "{{engine}}: draait op de CPU — {{reason}}",
"selectWithCaveat": "Overgeschakeld naar {{engine}} — {{reason}}",
"generateCaveat": "{{engine}} kan traag zijn op deze machine — {{reason}}",
"cpuLongText": "{{engine}} draait hier op de CPU en deze tekst is lang — de generatie overschrijdt mogelijk het tijdsbudget. Kortere tekst of een CPU-geoptimaliseerde engine (OmniVoice GGUF, Supertonic-3) is veel sneller.",
"cpuLongTextTuned": "{{engine}} draait hier op de CPU en deze tekst is lang — de generatie overschrijdt mogelijk het tijdsbudget. Kortere tekst, of opsplitsen in meerdere takes, is veel sneller."
"routingCaveatTitle": "GPU geselecteerd, maar: {{reason}}"
},
"capture": {
"desc": "Globale sneltoetsen werken alleen in de desktop-app. De webinterface gebruikt een sneltoets <1>Ctrl+Shift+Spatie</1> op de pagina terwijl het venster focus heeft.",
@@ -1445,14 +1435,10 @@
"desc": "Maak je geen zorgen: de rest van de app werkt nog steeds. U kunt van tabblad wisselen of het hieronder opnieuw proberen.",
"tryAgain": "Probeer het opnieuw",
"openDocs": "Open documenten voor deze fout",
"crash_port_in_use": "De backend kon niet starten omdat poort 3900 al in gebruik is — een andere kopie van OmniVoice (of een app die die poort claimde) houdt hem bezet. Sluit de andere instantie en start opnieuw; als er niets zichtbaar draait, houdt een verweesde backend uit een eerdere sessie de poort nog vast.",
"crash_oom_kill": "Het proces werd geforceerd afgesloten (signaal 9), wat meestal betekent dat het besturingssysteem geen geheugen (RAM) meer had. Sluit geheugenintensieve apps, kies een kleiner ASR-model bij Instellingen → Modellen, of ontlaad het TTS-model vóór het transcriberen.",
"crash_native_fault": "Het crashte binnen de compute-stack in plaats van door geheugengebrek — dat wijst op een GPU-stuurprogramma dat niet overeenkomt met de meegeleverde CUDA-runtime, of op een modelbestand dat onvolledig is gedownload. Werk uw GPU-stuurprogramma bij en download het model opnieuw via Instellingen → Modellen (een gedeeltelijke download wordt ter plekke hersteld). Blijft het gebeuren, schakel dan in Instellingen → Engines over op een crash-geïsoleerde engine — \"OmniVoice (subprocess)\" voor synthese, \"Faster-Whisper (crash-isolated subprocess)\" voor transcriptie. Die draaien het model in een apart proces, zodat zo'n crash dat proces neerhaalt in plaats van de hele backend.",
"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}}",
"backend_shutting_down": "OmniVoice wordt afgesloten. Open de app opnieuw en probeer het nog eens."
"unexpected": "Onverwachte fout: {{message}}"
},
"keyboard": {
"title": "Sneltoetsen",
@@ -1763,7 +1749,7 @@
"q_try_before": "Kan ik het proberen voordat ik me vastleg?",
"a_try_before": "Ja. De volledige app is gratis te downloaden, draaien en zelf te hosten onder de AGPL-3.0 — geen overeenkomst nodig. Wil je een commerciële licentie (propriëtair gebruik) bespreken, mail ons dan en we werken de details samen uit.",
"q_watermark": "Hoe zit het met het watermerk?",
"a_watermark": "Het onzichtbare AudioSeal-watermerk wordt standaard voor iedereen ingesloten. U kunt het uitschakelen bij Instellingen → Privacy; het geldt alleen voor audio die na de wijziging wordt gegenereerd."
"a_watermark": "Het onzichtbare AudioSeal-watermerk is standaard voor iedereen ingesloten. Commerciële licentiehouders kunnen dit uitschakelen via Instellingen → Privacy."
},
"gallery_extra": {
"save_prompt": "Voer een naam in voor dit stemprofiel:",
@@ -1905,15 +1891,11 @@
"install_hint": "Download de update en start opnieuw op in de nieuwe versie",
"downloading": "Bijwerken… {{pct}}%",
"restart": "Opnieuw opstarten om bij te werken",
"busy": "Wacht tot het lopende werk klaar is — installeer daarna de update.",
"busy": "Voltooi eerst je nasynchronisatie — installeer daarna de update.",
"whats_new": "Wat is er nieuw",
"failed": "Update mislukt",
"retry": "Opnieuw proberen",
"dismiss": "Negeren",
"toast_available": "OmniVoice Studio {{version}} is beschikbaar",
"toast_install": "Installeren en herstarten",
"toast_whats_new": "Wat is er nieuw",
"toast_later": "Later"
"dismiss": "Negeren"
},
"archetypes": {
"featured": "Uitgelicht",
@@ -2267,10 +2249,7 @@
"email": "E-mail",
"email_desc": "Licenties, partnerschappen of iets privés.",
"website": "Website",
"website_desc": "Meer over het project en de maker.",
"follow_title": "Volg ons op X",
"follow_desc": "Release-notities, nieuwe engines en af en toe een blik op wat er volgt. Handig als u liever niet in een chatserver zit.",
"follow_cta": "Volgen op X"
"website_desc": "Meer over het project en de maker."
},
"permissions": {
"title": "Machtigingen",
@@ -2300,4 +2279,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."
}
}
}
+10 -31
View File
@@ -2,7 +2,7 @@
"backendUnreachable": {
"contact_recent": "Jeszcze {{ago}} temu odpowiadał, a potem przestał — najprawdopodobniej uległ awarii lub został zakończony w trakcie żądania.",
"contact_never": "W tej sesji nie odpowiedział ani razu — możliwe, że w ogóle się nie uruchomił.",
"dev": "Nie można połączyć się z lokalnym backendem OmniVoice. {{contact}} W `bun run dev` backend działa z automatycznym przeładowaniem: każda zmiana pliku — w tym zapis w trakcie żądania — restartuje go i przerywa połączenie. Najpierw ponów akcję; jeśli to było przeładowanie, po prostu zadziała. Jeśli nadal się nie udaje, sprawdź terminal z `bun run dev` pod kątem tracebacku Pythona lub komunikatu wyjścia oraz omnivoice.log w folderze danych OmniVoice.",
"dev": "Nie można połączyć się z lokalnym backendem OmniVoice. {{contact}} Sprawdź terminal z `bun run dev` pod kątem tracebacku Pythona lub banera wyjścia oraz plik omnivoice.log w folderze danych OmniVoice, aby zobaczyć ostatni wpis backendu.",
"server": "Nie można połączyć się z serwerem backendu OmniVoice. {{contact}} Sprawdź logi serwera, aby znaleźć przyczynę (np. `docker logs <kontener>` lub `journalctl`) — pamiętaj też, że jeśli Docker serwuje tę stronę, sama strona może przestać działać razem z backendem."
},
"nav": {
@@ -104,7 +104,7 @@
"update_check_failed": "Sprawdzanie aktualizacji nie powiodło się: {{message}}",
"save_failed": "Zapis nie powiódł się: {{message}}",
"clear_failed": "Wyczyszczenie nie powiodło się: {{message}}",
"engine_switched": "Przełączono {{family}} na {{engine}}",
"engine_switched": "{{family}} {{engine}}",
"channel_set_failed": "Nie udało się ustawić kanału: {{message}}",
"updater_downloading": "Pobieranie {{version}}…",
"updater_installed": "Zainstalowano — uruchamiam ponownie.",
@@ -492,12 +492,7 @@
"local_sqlite": "Lokalny SQLite",
"translator_online": "Tłumacz jest online: {{provider}}",
"translator_offline": "Tłumacz offline",
"no_tracking": "Brak — brak śledzenia",
"watermark_title": "Niewidoczny znak wodny",
"watermark_subtitle": "Domyślnie włączony. Oznacza wygenerowany dźwięk jako utworzony przez AI, aby można go było później rozpoznać. Dotyczy tylko dźwięku wygenerowanego od teraz.",
"watermark_on_toast": "Niewidoczny znak wodny włączony dla nowego dźwięku.",
"watermark_off_toast": "Niewidoczny znak wodny wyłączony dla nowego dźwięku.",
"watermark_failed": "Nie udało się zmienić ustawienia znaku wodnego."
"no_tracking": "Brak — brak śledzenia"
},
"consent": {
"title": "Pomóc ulepszyć OmniVoice?",
@@ -583,12 +578,7 @@
"routingRemote": "Zdalny",
"routingUnknown": "Nieznany",
"routingEffectiveChip": "Działa na {{device}} na tym komputerze",
"routingCaveatTitle": "Wybrano procesor graficzny, ale: {{reason}}",
"selectCpuFallback": "{{engine}}: działa na CPU — {{reason}}",
"selectWithCaveat": "Przełączono na {{engine}} — {{reason}}",
"generateCaveat": "{{engine}} może działać wolno na tym komputerze — {{reason}}",
"cpuLongText": "{{engine}} działa tutaj na CPU, a ten tekst jest długi — generowanie może przekroczyć limit czasu. Krótszy tekst lub silnik zoptymalizowany pod CPU (OmniVoice GGUF, Supertonic-3) będzie znacznie szybszy.",
"cpuLongTextTuned": "{{engine}} działa tutaj na CPU, a ten tekst jest długi — generowanie może przekroczyć limit czasu. Krótszy tekst lub podzielenie go na kilka części będzie znacznie szybsze."
"routingCaveatTitle": "Wybrano procesor graficzny, ale: {{reason}}"
},
"capture": {
"desc": "Globalne skróty klawiszowe działają tylko w aplikacji komputerowej. Interfejs WWW korzysta ze skrótu <1>Ctrl+Shift+Spacja</1> znajdującego się na stronie, gdy okno jest aktywne.",
@@ -1445,14 +1435,10 @@
"desc": "Nie martw się — reszta aplikacji nadal działa. Możesz przełączyć karty lub spróbować ponownie poniżej.",
"tryAgain": "Spróbuj ponownie",
"openDocs": "Otwórz dokumenty dotyczące tego błędu",
"crash_port_in_use": "Nie udało się uruchomić backendu, ponieważ port 3900 jest już zajęty — trzyma go inna kopia OmniVoice (lub aplikacja, która przejęła ten port). Zamknij drugą instancję i uruchom ponownie; jeśli nic widocznie nie działa, port nadal blokuje osierocony backend z poprzedniej sesji.",
"crash_oom_kill": "Proces został zabity (sygnał 9), co zwykle oznacza, że systemowi zabrakło pamięci (RAM). Zamknij aplikacje zużywające dużo pamięci, wybierz mniejszy model ASR w Ustawieniach → Modele albo zwolnij model TTS przed transkrypcją.",
"crash_native_fault": "Awaria nastąpiła wewnątrz stosu obliczeniowego, a nie z powodu braku pamięci — wskazuje to na sterownik GPU niezgodny z dołączonym środowiskiem CUDA albo na niekompletnie pobrany plik modelu. Zaktualizuj sterownik GPU, a następnie pobierz model ponownie w Ustawieniach → Modele (naprawia częściowe pobranie w miejscu). Jeśli problem się powtarza, przełącz się na silnik izolowany w Ustawieniach → Silniki — „OmniVoice (subprocess)” do syntezy, „Faster-Whisper (crash-isolated subprocess)” do transkrypcji. Uruchamiają one model w osobnym procesie, więc taka awaria kończy ten proces zamiast całego backendu.",
"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}}",
"backend_shutting_down": "OmniVoice się zamyka. Otwórz aplikację ponownie i spróbuj jeszcze raz."
"unexpected": "Nieoczekiwany błąd: {{message}}"
},
"keyboard": {
"title": "Skróty klawiaturowe",
@@ -1763,7 +1749,7 @@
"q_try_before": "Czy mogę spróbować przed zatwierdzeniem?",
"a_try_before": "Tak. Pełną aplikację można bezpłatnie pobrać, uruchomić i hostować samodzielnie na licencji AGPL-3.0 — bez żadnej umowy. Gdy zechcesz porozmawiać o licencji komercyjnej (użycie własnościowe), napisz do nas, a wspólnie ustalimy szczegóły.",
"q_watermark": "A co ze znakiem wodnym?",
"a_watermark": "Niewidoczny znak wodny AudioSeal jest domyślnie osadzany u wszystkich. Możesz go wyłączyć w Ustawieniach → Prywatność; dotyczy tylko dźwięku wygenerowanego po zmianie."
"a_watermark": "Niewidoczny znak wodny AudioSeal jest domyślnie osadzany dla wszystkich. Licencjobiorcy komercyjni mogą go wyłączyć w Ustawieniach → Prywatność."
},
"gallery_extra": {
"save_prompt": "Wprowadź nazwę tego profilu głosowego:",
@@ -1905,15 +1891,11 @@
"install_hint": "Pobierz aktualizację i uruchom ponownie w nowej wersji",
"downloading": "Aktualizowanie… {{pct}}%",
"restart": "Uruchom ponownie, aby zaktualizować",
"busy": "Poczekaj na zakończenie trwającej pracy — potem zainstaluj aktualizację.",
"busy": "Najpierw dokończ dubbing — potem zainstaluj aktualizację.",
"whats_new": "Co nowego?",
"failed": "Aktualizacja nie powiodła się",
"retry": "Spróbuj ponownie",
"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"
"dismiss": "Odrzuć"
},
"archetypes": {
"featured": "Polecane",
@@ -2267,10 +2249,7 @@
"email": "E-mail",
"email_desc": "Licencje, partnerstwa lub cokolwiek prywatnego.",
"website": "Strona internetowa",
"website_desc": "Więcej o projekcie i twórcy.",
"follow_title": "Obserwuj na X",
"follow_desc": "Informacje o wydaniach, nowe silniki i od czasu do czasu zapowiedzi tego, co powstaje. Wygodne, jeśli wolisz nie siedzieć na serwerze czatu.",
"follow_cta": "Obserwuj na X"
"website_desc": "Więcej o projekcie i twórcy."
},
"permissions": {
"title": "Uprawnienia",
@@ -2300,4 +2279,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."
}
}
}
+10 -31
View File
@@ -2,7 +2,7 @@
"backendUnreachable": {
"contact_recent": "Estava respondendo há {{ago}} e depois parou de responder — muito provavelmente travou ou foi encerrado no meio de uma requisição.",
"contact_never": "Não respondeu nenhuma vez nesta sessão — pode ser que nunca tenha iniciado.",
"dev": "Não é possível contactar o backend local do OmniVoice. {{contact}} Com `bun run dev` o backend corre com recarga automática: qualquer alteração de ficheiro — incluindo gravar enquanto havia um pedido em curso — reinicia-o e corta a ligação. Tente a ação novamente primeiro; se foi uma recarga, funciona. Se continuar a falhar, verifique o terminal com `bun run dev` para um traceback de Python ou uma mensagem de saída, e omnivoice.log na sua pasta de dados do OmniVoice.",
"dev": "Não foi possível conectar ao backend local do OmniVoice. {{contact}} Verifique o terminal executando `bun run dev` em busca de um traceback do Python ou um aviso de saída, e o arquivo omnivoice.log na sua pasta de dados do OmniVoice para ver o último registro do backend.",
"server": "Não foi possível conectar ao servidor backend do OmniVoice. {{contact}} Verifique os logs do servidor para encontrar a causa (ex.: `docker logs <container>` ou `journalctl`) — e note que, se o Docker serve esta página, a própria página pode cair junto com o backend."
},
"nav": {
@@ -104,7 +104,7 @@
"update_check_failed": "Falha na verificação de atualização: {{message}}",
"save_failed": "Falha ao salvar: {{message}}",
"clear_failed": "Falha na limpeza: {{message}}",
"engine_switched": "{{family}} alterado para {{engine}}",
"engine_switched": "{{family}} {{engine}}",
"channel_set_failed": "Falha ao definir canal: {{message}}",
"updater_downloading": "Baixando {{version}}…",
"updater_installed": "Instalado relançando.",
@@ -492,12 +492,7 @@
"local_sqlite": "SQLite local",
"translator_online": "O tradutor está online: {{provider}}",
"translator_offline": "Tradutor off-line",
"no_tracking": "Nenhum sem rastreamento",
"watermark_title": "Marca de água invisível",
"watermark_subtitle": "Ativada por predefinição. Marca o áudio gerado como criado por IA para poder ser identificado mais tarde. Afeta apenas o áudio gerado a partir de agora.",
"watermark_on_toast": "Marca de água invisível ativada para novo áudio.",
"watermark_off_toast": "Marca de água invisível desativada para novo áudio.",
"watermark_failed": "Não foi possível alterar a definição da marca de água."
"no_tracking": "Nenhum sem rastreamento"
},
"consent": {
"title": "Ajudar a melhorar o OmniVoice?",
@@ -583,12 +578,7 @@
"routingRemote": "Remoto",
"routingUnknown": "Desconhecido",
"routingEffectiveChip": "Funciona em {{device}} nesta máquina",
"routingCaveatTitle": "GPU selecionada, mas: {{reason}}",
"selectCpuFallback": "{{engine}}: em execução na CPU — {{reason}}",
"selectWithCaveat": "Alterado para {{engine}} — {{reason}}",
"generateCaveat": "{{engine}} pode ficar lento nesta máquina — {{reason}}",
"cpuLongText": "{{engine}} é executado na CPU aqui e este texto é longo — a geração pode ultrapassar o limite de tempo. Um texto mais curto ou um motor otimizado para CPU (OmniVoice GGUF, Supertonic-3) será bem mais rápido.",
"cpuLongTextTuned": "{{engine}} é executado na CPU aqui e este texto é longo — a geração pode ultrapassar o limite de tempo. Um texto mais curto, ou dividido em algumas partes, será bem mais rápido."
"routingCaveatTitle": "GPU selecionada, mas: {{reason}}"
},
"capture": {
"desc": "As teclas de atalho globais funcionam apenas no aplicativo de desktop. A IU da web usa um atalho <1>Ctrl+Shift+Space</1> na página enquanto a janela está em foco.",
@@ -1445,14 +1435,10 @@
"desc": "Não se preocupe o resto do aplicativo ainda funciona. Você pode alternar as guias ou tentar novamente abaixo.",
"tryAgain": "Tente novamente",
"openDocs": "Abra documentos para este erro",
"crash_port_in_use": "O backend não conseguiu iniciar porque a porta 3900 já está em uso — outra cópia do OmniVoice (ou um app que reivindicou essa porta) está a ocupá-la. Feche a outra instância e reinicie; se nada estiver visivelmente em execução, um backend órfão de uma sessão anterior ainda ocupa a porta.",
"crash_oom_kill": "Foi terminado à força (sinal 9), o que normalmente significa que o sistema operativo ficou sem memória (RAM). Feche aplicações que consomem muita memória, escolha um modelo ASR mais pequeno em Definições → Modelos, ou descarregue o modelo TTS antes de transcrever.",
"crash_native_fault": "Falhou dentro da pilha de computação em vez de ficar sem memória — isso aponta para um controlador de GPU que não corresponde ao runtime CUDA incluído, ou para um ficheiro de modelo descarregado de forma incompleta. Atualize o controlador da GPU e volte a descarregar o modelo em Definições → Modelos (repara um download parcial no local). Se continuar a acontecer, mude para um motor isolado em Definições → Motores: «OmniVoice (subprocess)» para síntese e «Faster-Whisper (crash-isolated subprocess)» para transcrição. Estes executam o modelo num processo separado, pelo que uma falha destas derruba esse processo em vez de todo o backend.",
"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}}",
"backend_shutting_down": "O OmniVoice está sendo encerrado. Reabra o aplicativo e tente novamente."
"unexpected": "Erro inesperado: {{message}}"
},
"keyboard": {
"title": "Atalhos de teclado",
@@ -1763,7 +1749,7 @@
"q_try_before": "Posso tentar antes de me comprometer?",
"a_try_before": "Sim. O aplicativo completo é gratuito para baixar, executar e auto-hospedar sob a AGPL-3.0 — nenhum acordo necessário. Quando quiser discutir uma licença comercial (uso proprietário), envie um e-mail e resolveremos os detalhes juntos.",
"q_watermark": "E a marca d’água?",
"a_watermark": "A marca de água invisível AudioSeal é incorporada por predefinição para todos. Pode desativá-la em Definições → Privacidade; afeta apenas o áudio gerado após a alteração."
"a_watermark": "A marca d'água invisível AudioSeal é incorporada por padrão para todos. Licenciados comerciais podem desativá-la em Configurações → Privacidade."
},
"gallery_extra": {
"save_prompt": "Digite um nome para este perfil de voz:",
@@ -1905,15 +1891,11 @@
"install_hint": "Baixe a atualização e reinicie na nova versão",
"downloading": "Atualizando… {{pct}}%",
"restart": "Reiniciar para atualizar",
"busy": "Aguarde o trabalho em andamento terminar — depois instale a atualização.",
"busy": "Termine sua dublagem primeiro — depois instale a atualização.",
"whats_new": "O que há de novo",
"failed": "Falha na atualização",
"retry": "Tentar novamente",
"dismiss": "Dispensar",
"toast_available": "OmniVoice Studio {{version}} está disponível",
"toast_install": "Instalar e reiniciar",
"toast_whats_new": "Novidades",
"toast_later": "Mais tarde"
"dismiss": "Dispensar"
},
"archetypes": {
"featured": "Destaque",
@@ -2267,10 +2249,7 @@
"email": "E-mail",
"email_desc": "Licenciamento, parcerias ou qualquer coisa privada.",
"website": "Site",
"website_desc": "Mais sobre o projeto e o criador.",
"follow_title": "Siga no X",
"follow_desc": "Notas de versão, novos motores e, de vez em quando, uma espreitadela ao que vem a seguir. Prático se preferir não estar num servidor de chat.",
"follow_cta": "Seguir no X"
"website_desc": "Mais sobre o projeto e o criador."
},
"permissions": {
"title": "Permissões",
@@ -2300,4 +2279,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."
}
}
}
+13 -34
View File
@@ -2,7 +2,7 @@
"backendUnreachable": {
"contact_recent": "Он отвечал {{ago}} назад, а затем перестал — скорее всего, он аварийно завершился или был остановлен посреди запроса.",
"contact_never": "За эту сессию он не ответил ни разу — возможно, он вообще не запустился.",
"dev": "Не удаётся связаться с локальным бэкендом OmniVoice. {{contact}} В `bun run dev` бэкенд работает с автоперезагрузкой: любое изменение файла — включая сохранение во время запроса — перезапускает его и обрывает соединение. Сначала повторите действие; если это была перезагрузка, всё заработает. Если ошибка повторяется, посмотрите в терминале с `bun run dev` трассировку Python или сообщение о выходе, а также omnivoice.log в папке данных OmniVoice.",
"dev": "Не удаётся подключиться к локальному бэкенду OmniVoice. {{contact}} Проверьте терминал с `bun run dev` на Python-трейсбек или баннер завершения, а также файл omnivoice.log в папке данных OmniVoice — там будет последняя запись бэкенда.",
"server": "Не удаётся подключиться к серверу бэкенда OmniVoice. {{contact}} Проверьте логи сервера, чтобы найти причину (например, `docker logs <container>` или `journalctl`) — и учтите: если эта страница обслуживается Docker, она может упасть вместе с бэкендом."
},
"nav": {
@@ -104,7 +104,7 @@
"update_check_failed": "Проверка обновлений не удалась: {{message}}",
"save_failed": "Не удалось сохранить: {{message}}",
"clear_failed": "Очистить не удалось: {{message}}",
"engine_switched": "{{family}} переключён на {{engine}}",
"engine_switched": "{{family}} {{engine}}",
"channel_set_failed": "Не удалось установить канал: {{message}}.",
"updater_downloading": "Загрузка {{version}}…",
"updater_installed": "Установил — перезапускаю.",
@@ -308,8 +308,8 @@
"frontend": "Внешний интерфейс",
"tauri": "Тавр",
"cancelOp": "Отменить операцию",
"dismiss": "Закрыть",
"dismissStatus": "Закрыть статус",
"dismiss": "Увольнять",
"dismissStatus": "Отклонить статус",
"search": "Поиск…",
"no_matches": "Нет совпадений",
"recent_and_popular": "Последние и популярные",
@@ -492,12 +492,7 @@
"local_sqlite": "Локальный SQLite",
"translator_online": "Переводчик онлайн: {{provider}}",
"translator_offline": "Оффлайн переводчик",
"no_tracking": "Нет — нет отслеживания",
"watermark_title": "Невидимый водяной знак",
"watermark_subtitle": "Включён по умолчанию. Помечает созданное аудио как сгенерированное ИИ, чтобы его можно было опознать позже. Касается только аудио, созданного с этого момента.",
"watermark_on_toast": "Невидимый водяной знак включён для нового аудио.",
"watermark_off_toast": "Невидимый водяной знак отключён для нового аудио.",
"watermark_failed": "Не удалось изменить настройку водяного знака."
"no_tracking": "Нет — нет отслеживания"
},
"consent": {
"title": "Помочь улучшить OmniVoice?",
@@ -583,12 +578,7 @@
"routingRemote": "Удаленный",
"routingUnknown": "Неизвестный",
"routingEffectiveChip": "Работает на {{device}} на этом компьютере",
"routingCaveatTitle": "Графический процессор выбран, но: {{reason}}",
"selectCpuFallback": "{{engine}}: работает на CPU — {{reason}}",
"selectWithCaveat": "Переключено на {{engine}} — {{reason}}",
"generateCaveat": "{{engine}} может работать медленно на этом компьютере — {{reason}}",
"cpuLongText": "Здесь {{engine}} работает на CPU, а этот текст длинный — генерация может превысить лимит времени. Более короткий текст или движок, оптимизированный под CPU (OmniVoice GGUF, Supertonic-3), будет намного быстрее.",
"cpuLongTextTuned": "Здесь {{engine}} работает на CPU, а этот текст длинный — генерация может превысить лимит времени. Более короткий текст или разбиение на несколько частей будет намного быстрее."
"routingCaveatTitle": "Графический процессор выбран, но: {{reason}}"
},
"capture": {
"desc": "Глобальные горячие клавиши работают только в настольном приложении. Веб-интерфейс использует внутристраничный ярлык <1>Ctrl+Shift+Space</1>, пока окно находится в фокусе.",
@@ -1286,7 +1276,7 @@
"later": "Может быть, позже",
"star": "Звезда на GitHub",
"opt_out": "Не спрашивай больше",
"dismiss_aria": "Закрыть"
"dismiss_aria": "Увольнять"
}
},
"enterprise": {
@@ -1445,14 +1435,10 @@
"desc": "Не волнуйтесь — остальная часть приложения по-прежнему работает. Вы можете переключить вкладки или повторить попытку ниже.",
"tryAgain": "Попробуйте еще раз",
"openDocs": "Открыть документацию по этой ошибке",
"crash_port_in_use": "Бэкенд не смог запуститься, потому что порт 3900 уже занят — его удерживает другая копия OmniVoice (или приложение, занявшее этот порт). Закройте другой экземпляр и запустите снова; если ничего видимого не запущено, порт всё ещё удерживает осиротевший бэкенд из предыдущего сеанса.",
"crash_oom_kill": "Процесс был принудительно завершён (сигнал 9), что обычно означает нехватку оперативной памяти (RAM) в системе. Закройте приложения, потребляющие много памяти, выберите меньшую модель ASR в Настройках → Модели или выгрузите модель TTS перед расшифровкой.",
"crash_native_fault": "Сбой произошёл внутри вычислительного стека, а не из-за нехватки памяти — это указывает на драйвер GPU, не соответствующий встроенной среде CUDA, либо на не полностью загруженный файл модели. Обновите драйвер GPU, затем повторно загрузите модель в Настройках → Модели (частичная загрузка чинится на месте). Если это повторяется, переключитесь на изолированный движок в Настройках → Движки — «OmniVoice (subprocess)» для синтеза, «Faster-Whisper (crash-isolated subprocess)» для расшифровки. Они запускают модель в отдельном процессе, поэтому такой сбой завершает этот процесс, а не весь бэкенд.",
"report": "Сообщить об этой ошибке",
"report_failed": "Не удалось открыть отчёт об ошибке. Попробуйте ещё раз или скопируйте детали выше в новое обращение.",
"searchIssues": "Искать похожие проблемы",
"unexpected": "Непредвиденная ошибка: {{message}}",
"backend_shutting_down": "OmniVoice завершает работу. Откройте приложение заново и повторите попытку."
"unexpected": "Непредвиденная ошибка: {{message}}"
},
"keyboard": {
"title": "Сочетания клавиш",
@@ -1763,7 +1749,7 @@
"q_try_before": "Могу ли я попробовать, прежде чем совершать?",
"a_try_before": "Да. Полную версию приложения можно бесплатно скачать, запускать и размещать самостоятельно по лицензии AGPL-3.0 — без какого-либо договора. Когда будете готовы обсудить коммерческую лицензию (проприетарное использование), напишите нам — вместе разберём детали.",
"q_watermark": "А что насчет водяного знака?",
"a_watermark": "Невидимый водяной знак AudioSeal по умолчанию встраивается для всех. Его можно отключить в Настройках → Конфиденциальность; это касается только аудио, созданного после изменения."
"a_watermark": "Невидимый водяной знак AudioSeal встроен по умолчанию для всех. Обладатели коммерческих лицензий могут отключить его в «Настройки»«Конфиденциальность»."
},
"gallery_extra": {
"save_prompt": "Введите имя для этого голосового профиля:",
@@ -1905,15 +1891,11 @@
"install_hint": "Загрузить обновление и перезапустить в новой версии",
"downloading": "Обновление… {{pct}}%",
"restart": "Перезапустить для обновления",
"busy": "Дождитесь завершения текущей задачи — затем установите обновление.",
"busy": "Сначала завершите дубляж — затем установите обновление.",
"whats_new": "Что нового",
"failed": "Обновление не удалось",
"retry": "Повторить попытку",
"dismiss": "Закрыть",
"toast_available": "OmniVoice Studio {{version}} доступна",
"toast_install": "Установить и перезапустить",
"toast_whats_new": "Что нового",
"toast_later": "Позже"
"dismiss": "Увольнять"
},
"archetypes": {
"featured": "Рекомендуемые",
@@ -2267,10 +2249,7 @@
"email": "Электронная почта",
"email_desc": "Лицензирование, партнерство или что-то частное.",
"website": "Веб-сайт",
"website_desc": "Подробнее о проекте и создателе.",
"follow_title": "Следите в X",
"follow_desc": "Заметки о выпусках, новые движки и иногда — о том, что делается дальше. Удобно, если не хочется сидеть в чат-сервере.",
"follow_cta": "Подписаться в X"
"website_desc": "Подробнее о проекте и создателе."
},
"permissions": {
"title": "Разрешения",
@@ -2300,4 +2279,4 @@
"output_title": "Что сообщило приложение",
"retry_hint": "Попробуйте «Повторить» в Настройки → Журналы → Бэкенд. Если снова не выйдет, «Очистить и повторить» пересоберёт окружение с нуля."
}
}
}
+10 -31
View File
@@ -2,7 +2,7 @@
"backendUnreachable": {
"contact_recent": "Den svarade för {{ago}} sedan och slutade sedan svara — den kraschade eller avslutades troligen mitt i en förfrågan.",
"contact_never": "Den har inte svarat alls under den här sessionen — den kanske aldrig startade.",
"dev": "Kan inte nå den lokala OmniVoice-backend. {{contact}} Med `bun run dev` körs backend med automatisk omladdning: varje filändring — även en sparning medan en förfrågan pågick — startar om den och bryter anslutningen. Försök med åtgärden igen först; var det en omladdning fungerar det. Om det fortsätter att misslyckas, titta i terminalen med `bun run dev` efter en Python-traceback eller ett avslutsmeddelande, och i omnivoice.log i din OmniVoice-datamapp.",
"dev": "Kan inte nå den lokala OmniVoice-backenden. {{contact}} Kontrollera terminalen som kör `bun run dev` efter en Python-traceback eller exit-banner, och filen omnivoice.log i din OmniVoice-datamapp för backendens senaste loggrad.",
"server": "Kan inte nå OmniVoice-backendservern. {{contact}} Kontrollera serverloggarna för orsaken (t.ex. `docker logs <container>` eller `journalctl`) — och observera att om Docker serverar den här sidan kan själva sidan gå ner tillsammans med backenden."
},
"nav": {
@@ -104,7 +104,7 @@
"update_check_failed": "Uppdateringskontrollen misslyckades: {{message}}",
"save_failed": "Det gick inte att spara: {{message}}",
"clear_failed": "Rensa misslyckades: {{message}}",
"engine_switched": "{{family}} bytt till {{engine}}",
"engine_switched": "{{family}} {{engine}}",
"channel_set_failed": "Det gick inte att ställa in kanal: {{message}}",
"updater_downloading": "Laddar ner {{version}}...",
"updater_installed": "Installerad — omstart.",
@@ -492,12 +492,7 @@
"local_sqlite": "Lokal SQLite",
"translator_online": "Översättaren är online: {{provider}}",
"translator_offline": "Offlineöversättare",
"no_tracking": "Ingen ingen spårning",
"watermark_title": "Osynlig vattenstämpel",
"watermark_subtitle": "På som standard. Märker genererat ljud som AI-skapat så att det kan identifieras senare. Gäller bara ljud som genereras från och med nu.",
"watermark_on_toast": "Osynlig vattenstämpel aktiverad för nytt ljud.",
"watermark_off_toast": "Osynlig vattenstämpel avstängd för nytt ljud.",
"watermark_failed": "Kunde inte ändra inställningen för vattenstämpeln."
"no_tracking": "Ingen ingen spårning"
},
"consent": {
"title": "Hjälpa till att förbättra OmniVoice?",
@@ -583,12 +578,7 @@
"routingRemote": "Fjärrkontroll",
"routingUnknown": "Okänd",
"routingEffectiveChip": "Körs på {{device}} på den här maskinen",
"routingCaveatTitle": "GPU vald, men: {{reason}}",
"selectCpuFallback": "{{engine}}: körs på CPU — {{reason}}",
"selectWithCaveat": "Bytte till {{engine}} — {{reason}}",
"generateCaveat": "{{engine}} kan vara långsam på den här datorn — {{reason}}",
"cpuLongText": "{{engine}} körs på CPU här och den här texten är lång — genereringen kan överskrida tidsbudgeten. Kortare text eller en CPU-optimerad motor (OmniVoice GGUF, Supertonic-3) går betydligt snabbare.",
"cpuLongTextTuned": "{{engine}} körs på CPU här och den här texten är lång — genereringen kan överskrida tidsbudgeten. Kortare text, eller uppdelad i flera tagningar, går betydligt snabbare."
"routingCaveatTitle": "GPU vald, men: {{reason}}"
},
"capture": {
"desc": "Globala snabbtangenter fungerar bara i skrivbordsappen. Webbgränssnittet använder en genväg <1>Ctrl+Skift+Mellanslag</1> på sidan medan fönstret har fokus.",
@@ -1445,14 +1435,10 @@
"desc": "Oroa dig inte resten av appen fungerar fortfarande. Du kan byta flik eller försöka igen nedan.",
"tryAgain": "Försök igen",
"openDocs": "Öppna dokument för detta fel",
"crash_port_in_use": "Backend kunde inte starta eftersom port 3900 redan används — en annan kopia av OmniVoice (eller en app som tagit porten) håller den. Avsluta den andra instansen och starta om; om inget syns köra håller en föräldralös backend från en tidigare session fortfarande porten.",
"crash_oom_kill": "Processen tvångsavslutades (signal 9), vilket vanligtvis betyder att operativsystemet fick slut på minne (RAM). Stäng minneskrävande appar, välj en mindre ASR-modell under Inställningar → Modeller, eller ladda ur TTS-modellen före transkribering.",
"crash_native_fault": "Kraschen skedde inne i beräkningsstacken snarare än på grund av slut på minne — det pekar på en GPU-drivrutin som inte matchar den medföljande CUDA-körtiden, eller en modellfil som laddats ner ofullständigt. Uppdatera GPU-drivrutinen och ladda sedan ner modellen på nytt under Inställningar → Modeller (en ofullständig nedladdning repareras på plats). Om det fortsätter, byt till en kraschisolerad motor under Inställningar → Motorer — ”OmniVoice (subprocess)” för syntes, ”Faster-Whisper (crash-isolated subprocess)” för transkribering. De kör modellen i en separat process, så en sådan krasch fäller den processen i stället för hela backend.",
"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}}",
"backend_shutting_down": "OmniVoice stängs av. Öppna appen igen och försök på nytt."
"unexpected": "Oväntat fel: {{message}}"
},
"keyboard": {
"title": "Kortkommandon",
@@ -1763,7 +1749,7 @@
"q_try_before": "Kan jag prova innan jag binder mig?",
"a_try_before": "Ja. Hela appen är gratis att ladda ner, köra och självhosta under AGPL-3.0 — inget avtal krävs. När du vill diskutera en kommersiell licens (proprietär användning), mejla oss så går vi igenom detaljerna tillsammans.",
"q_watermark": "Hur är det med vattenstämpeln?",
"a_watermark": "Den osynliga AudioSeal-vattenstämpeln bäddas in som standard för alla. Du kan stänga av den under Inställningar → Integritet; den påverkar bara ljud som genereras efter ändringen."
"a_watermark": "Den osynliga AudioSeal-vattenstämpeln är inbäddad som standard för alla. Kommersiella licenstagare kan inaktivera den i Inställningar → Sekretess."
},
"gallery_extra": {
"save_prompt": "Ange ett namn för denna röstprofil:",
@@ -1905,15 +1891,11 @@
"install_hint": "Ladda ner uppdateringen och starta om i den nya versionen",
"downloading": "Uppdaterar… {{pct}} %",
"restart": "Starta om för att uppdatera",
"busy": "Vänta tills det pågående arbetet är klart installera sedan uppdateringen.",
"busy": "Slutför din dubbning först installera sedan uppdateringen.",
"whats_new": "Vad är nytt",
"failed": "Uppdateringen misslyckades",
"retry": "Försök igen",
"dismiss": "Avvisa",
"toast_available": "OmniVoice Studio {{version}} är tillgänglig",
"toast_install": "Installera och starta om",
"toast_whats_new": "Nyheter",
"toast_later": "Senare"
"dismiss": "Avvisa"
},
"archetypes": {
"featured": "Utvalda",
@@ -2267,10 +2249,7 @@
"email": "E-post",
"email_desc": "Licensiering, partnerskap eller något privat.",
"website": "Webbplats",
"website_desc": "Mer om projektet och skaparen.",
"follow_title": "Följ på X",
"follow_desc": "Release-noteringar, nya motorer och ibland en titt på vad som byggs härnäst. Praktiskt om du hellre slipper sitta i en chattserver.",
"follow_cta": "Följ på X"
"website_desc": "Mer om projektet och skaparen."
},
"permissions": {
"title": "Behörigheter",
@@ -2300,4 +2279,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."
}
}
}
+10 -31
View File
@@ -2,7 +2,7 @@
"backendUnreachable": {
"contact_recent": "เมื่อ {{ago}} ก่อนยังตอบสนองอยู่ แล้วก็หยุดตอบสนอง — มีแนวโน้มสูงว่าแครชหรือถูกปิดกลางคันระหว่างคำขอ",
"contact_never": "ยังไม่เคยตอบสนองเลยในเซสชันนี้ — อาจไม่เคยเริ่มทำงานเลย",
"dev": "ไม่สามารถติดต่อแบ็กเอนด์ OmniVoice ในเครื่องได้ {{contact}} ใน `bun run dev` แบ็กเอนด์ทำงานแบบรีโหลดอัตโนมัติ การเปลี่ยนไฟล์ใด ๆ — รวมถึงการบันทึกขณะมีคำขอค้างอยู่ — จะรีสตาร์ตและตัดการเชื่อมต่อ ลองทำอีกครั้งก่อน หากเป็นการรีโหลดก็จะใช้งานได้ หากยังล้มเหลว ให้ดูเทอร์มินัลที่รัน `bun run dev` ว่ามี Python traceback หรือข้อความออกจากโปรแกรม และดู omnivoice.log ในโฟลเดอร์ข้อมูล OmniVoice",
"dev": "ไม่สามารถเชื่อมต่อกับแบ็กเอนด์ OmniVoice ในเครื่องได้ {{contact}} ตรวจสอบเทอร์มินัลที่รัน `bun run dev` เพื่อหา Python traceback หรือข้อความแจ้งการปิดตัว และดูไฟล์ omnivoice.log ในโฟลเดอร์ข้อมูล OmniVoice เพื่อดูบันทึกล่าสุดของแบ็กเอนด์",
"server": "ไม่สามารถเชื่อมต่อกับเซิร์ฟเวอร์แบ็กเอนด์ OmniVoice ได้ {{contact}} ตรวจสอบล็อกของเซิร์ฟเวอร์เพื่อหาสาเหตุ (เช่น `docker logs <container>` หรือ `journalctl`) — และโปรดทราบว่าถ้า Docker เป็นผู้ให้บริการหน้านี้ ตัวหน้าเว็บเองก็อาจล่มไปพร้อมกับแบ็กเอนด์"
},
"nav": {
@@ -104,7 +104,7 @@
"update_check_failed": "การตรวจสอบการอัปเดตล้มเหลว: {{message}}",
"save_failed": "บันทึกล้มเหลว: {{message}}",
"clear_failed": "การเคลียร์ล้มเหลว: {{message}}",
"engine_switched": "สลับ {{family}} ไปที่ {{engine}}",
"engine_switched": "{{family}} {{engine}}",
"channel_set_failed": "ไม่สามารถตั้งค่าช่อง: {{message}}",
"updater_downloading": "กำลังดาวน์โหลด {{version}}…",
"updater_installed": "ติดตั้งแล้ว — เปิดตัวใหม่",
@@ -492,12 +492,7 @@
"local_sqlite": "SQLite ท้องถิ่น",
"translator_online": "นักแปลออนไลน์อยู่: {{provider}}",
"translator_offline": "นักแปลออฟไลน์",
"no_tracking": "ไม่มี — ไม่มีการติดตาม",
"watermark_title": "ลายน้ำที่มองไม่เห็น",
"watermark_subtitle": "เปิดอยู่โดยค่าเริ่มต้น ทำเครื่องหมายเสียงที่สร้างขึ้นว่าเป็นผลงานของ AI เพื่อให้ระบุได้ในภายหลัง มีผลกับเสียงที่สร้างจากนี้ไปเท่านั้น",
"watermark_on_toast": "เปิดลายน้ำที่มองไม่เห็นสำหรับเสียงใหม่แล้ว",
"watermark_off_toast": "ปิดลายน้ำที่มองไม่เห็นสำหรับเสียงใหม่แล้ว",
"watermark_failed": "ไม่สามารถเปลี่ยนการตั้งค่าลายน้ำได้"
"no_tracking": "ไม่มี — ไม่มีการติดตาม"
},
"consent": {
"title": "ช่วยปรับปรุง OmniVoice ไหม?",
@@ -583,12 +578,7 @@
"routingRemote": "ระยะไกล",
"routingUnknown": "ไม่ทราบ",
"routingEffectiveChip": "ทำงานบน {{device}} บนเครื่องนี้",
"routingCaveatTitle": "เลือก GPU แล้ว แต่: {{reason}}",
"selectCpuFallback": "{{engine}}: กำลังทำงานบน CPU — {{reason}}",
"selectWithCaveat": "สลับไปที่ {{engine}} — {{reason}}",
"generateCaveat": "{{engine}} อาจทำงานช้าบนเครื่องนี้ — {{reason}}",
"cpuLongText": "{{engine}} ทำงานบน CPU ที่นี่ และข้อความนี้ยาว — การสร้างอาจเกินงบประมาณเวลา ข้อความที่สั้นลงหรือเอนจินที่ปรับสำหรับ CPU (OmniVoice GGUF, Supertonic-3) จะเร็วกว่ามาก",
"cpuLongTextTuned": "{{engine}} ทำงานบน CPU ที่นี่ และข้อความนี้ยาว — การสร้างอาจเกินงบประมาณเวลา ข้อความที่สั้นลง หรือแบ่งเป็นหลายส่วน จะเร็วกว่ามาก"
"routingCaveatTitle": "เลือก GPU แล้ว แต่: {{reason}}"
},
"capture": {
"desc": "ปุ่มลัดส่วนกลางใช้งานได้ในแอปเดสก์ท็อปเท่านั้น UI ของเว็บใช้ทางลัด <1>Ctrl+Shift+Space</1> ในเพจในขณะที่หน้าต่างมีโฟกัส",
@@ -1445,14 +1435,10 @@
"desc": "ไม่ต้องกังวล แอปที่เหลือยังใช้งานได้ คุณสามารถสลับแท็บหรือลองอีกครั้งด้านล่าง",
"tryAgain": "ลองอีกครั้ง",
"openDocs": "เปิดเอกสารเพื่อดูข้อผิดพลาดนี้",
"crash_port_in_use": "ไม่สามารถเริ่มแบ็กเอนด์ได้เพราะพอร์ต 3900 ถูกใช้งานอยู่แล้ว — OmniVoice อีกชุดหนึ่ง (หรือแอปที่จองพอร์ตนั้น) กำลังถือครองอยู่ ปิดอินสแตนซ์อื่นแล้วเปิดใหม่ หากไม่มีอะไรทำงานให้เห็น แสดงว่าแบ็กเอนด์ที่ค้างจากเซสชันก่อนยังถือพอร์ตอยู่",
"crash_oom_kill": "ถูกบังคับปิด (สัญญาณ 9) ซึ่งมักหมายความว่าระบบปฏิบัติการมีหน่วยความจำ (RAM) ไม่พอจึงหยุดการทำงาน ปิดแอปที่ใช้หน่วยความจำมาก เลือกโมเดล ASR ที่เล็กลงใน การตั้งค่า → โมเดล หรือปลดโมเดล TTS ก่อนถอดเสียง",
"crash_native_fault": "โปรแกรมล่มภายในชั้นการประมวลผล ไม่ใช่เพราะหน่วยความจำหมด ซึ่งชี้ไปที่ไดรเวอร์ GPU ที่ไม่ตรงกับ CUDA runtime ที่มาพร้อมกัน หรือไฟล์โมเดลที่ดาวน์โหลดไม่ครบ อัปเดตไดรเวอร์ GPU แล้วดาวน์โหลดโมเดลใหม่จาก การตั้งค่า → โมเดล (ระบบจะซ่อมไฟล์ที่ดาวน์โหลดไม่ครบให้) หากยังเกิดซ้ำ ให้เปลี่ยนไปใช้เอนจินแบบแยกกระบวนการใน การตั้งค่า → เอนจิน — \"OmniVoice (subprocess)\" สำหรับการสังเคราะห์เสียง และ \"Faster-Whisper (crash-isolated subprocess)\" สำหรับการถอดเสียง เอนจินเหล่านี้รันโมเดลในกระบวนการแยก การล่มแบบนี้จึงทำให้กระบวนการนั้นหยุดแทนที่จะเป็นแบ็กเอนด์ทั้งหมด",
"report": "รายงานข้อบกพร่องนี้",
"report_failed": "เปิดรายงานข้อบกพร่องไม่ได้ โปรดลองอีกครั้ง หรือคัดลอกรายละเอียดด้านบนไปยังรายงานใหม่",
"searchIssues": "ค้นหาปัญหาที่คล้ายกัน",
"unexpected": "ข้อผิดพลาดที่ไม่คาดคิด: {{message}}",
"backend_shutting_down": "OmniVoice กำลังปิดอยู่ เปิดแอปอีกครั้งแล้วลองใหม่"
"unexpected": "ข้อผิดพลาดที่ไม่คาดคิด: {{message}}"
},
"keyboard": {
"title": "แป้นพิมพ์ลัด",
@@ -1763,7 +1749,7 @@
"q_try_before": "ฉันสามารถลองก่อนที่จะกระทำได้หรือไม่?",
"a_try_before": "ได้ แอปฉบับเต็มดาวน์โหลด ใช้งาน และโฮสต์เองได้ฟรีภายใต้ AGPL-3.0 — ไม่ต้องมีข้อตกลงใด ๆ เมื่อคุณพร้อมจะหารือเรื่องใบอนุญาตเชิงพาณิชย์ (การใช้งานแบบกรรมสิทธิ์) ส่งอีเมลถึงเรา แล้วเราจะช่วยจัดการรายละเอียดร่วมกัน",
"q_watermark": "แล้วลายน้ำล่ะ?",
"a_watermark": "ลายน้ำ AudioSeal แบบมองไม่เห็นถูกฝังไว้เป็นค่าเริ่มต้นสำหรับทุกคน คุณสามารถปิดได้ที่ การตั้งค่า → ความเป็นส่วนตัว โดยมีผลเฉพาะกับเสียงที่สร้างหลังจากเปลี่ยนค่าเท่านั้น"
"a_watermark": "ลายน้ำ AudioSeal ที่มองไม่เห็นจะถูกฝังไว้ตามค่าเริ่มต้นสำหรับทุกคน ผู้ได้รับใบอนุญาตเชิงพาณิชย์สามารถปิดการใช้งานได้ในการตั้งค่า → ความเป็นส่วนตัว"
},
"gallery_extra": {
"save_prompt": "ป้อนชื่อโปรไฟล์เสียงนี้:",
@@ -1905,15 +1891,11 @@
"install_hint": "ดาวน์โหลดอัปเดตและรีสตาร์ทเป็นเวอร์ชันใหม่",
"downloading": "กำลังอัปเดต… {{pct}}%",
"restart": "รีสตาร์ทเพื่ออัปเดต",
"busy": "รอให้งานที่กำลังทำอยู่เสร็จก่อน แล้วจึงติดตั้งอัปเดต",
"busy": "ทำการพากย์เสียงของคุณให้เสร็จก่อน แล้วจึงติดตั้งอัปเดต",
"whats_new": "มีอะไรใหม่",
"failed": "การอัปเดตล้มเหลว",
"retry": "ลองอีกครั้ง",
"dismiss": "ยกเลิก",
"toast_available": "OmniVoice Studio {{version}} พร้อมใช้งาน",
"toast_install": "ติดตั้งและรีสตาร์ท",
"toast_whats_new": "มีอะไรใหม่",
"toast_later": "ภายหลัง"
"dismiss": "ยกเลิก"
},
"archetypes": {
"featured": "จุดเด่น",
@@ -2267,10 +2249,7 @@
"email": "อีเมล",
"email_desc": "ใบอนุญาต ห้างหุ้นส่วน หรือสิ่งใดก็ตามที่เป็นส่วนตัว",
"website": "เว็บไซต์",
"website_desc": "ข้อมูลเพิ่มเติมเกี่ยวกับโครงการและผู้สร้าง",
"follow_title": "ติดตามบน X",
"follow_desc": "บันทึกการอัปเดต เอนจินใหม่ ๆ และภาพบางส่วนของสิ่งที่กำลังพัฒนาต่อไป สะดวกหากคุณไม่อยากอยู่ในเซิร์ฟเวอร์แชท",
"follow_cta": "ติดตามบน X"
"website_desc": "ข้อมูลเพิ่มเติมเกี่ยวกับโครงการและผู้สร้าง"
},
"permissions": {
"title": "สิทธิ์การเข้าถึง",
@@ -2300,4 +2279,4 @@
"output_title": "สิ่งที่แอปรายงาน",
"retry_hint": "ลอง \"ลองใหม่\" ใน การตั้งค่า → บันทึก → แบ็กเอนด์ หากยังล้มเหลว \"ล้างและลองใหม่\" จะสร้างสภาพแวดล้อมขึ้นใหม่ทั้งหมด"
}
}
}
+10 -31
View File
@@ -2,7 +2,7 @@
"backendUnreachable": {
"contact_recent": "{{ago}} önce yanıt veriyordu, sonra yanıt vermeyi bıraktı — büyük olasılıkla bir istek sırasında çöktü veya sonlandırıldı.",
"contact_never": "Bu oturumda hiç yanıt vermedi — hiç başlamamış olabilir.",
"dev": "Yerel OmniVoice arka ucuna ulaşılamıyor. {{contact}} `bun run dev` ile arka uç otomatik yeniden yükleme ile çalışır: herhangi bir dosya değişikliği — bir istek sürerken kaydetmek dâhil — onu yeniden başlatır ve bağlantıyı koparır. Önce işlemi yeniden deneyin; yeniden yükleme olduysa çalışacaktır. Hata sürerse `bun run dev` çalıştıran terminalde bir Python izini veya çıkış mesajını ve OmniVoice veri klasörünüzdeki omnivoice.log dosyasını kontrol edin.",
"dev": "Yerel OmniVoice arka ucuna ulaşılamıyor. {{contact}} `bun run dev` çalıştıran terminalde bir Python traceback'i veya çıkış afişi olup olmadığını kontrol edin; arka ucun son kaydı için OmniVoice veri klasörünüzdeki omnivoice.log dosyasına bakın.",
"server": "OmniVoice arka uç sunucusuna ulaşılamıyor. {{contact}} Nedenini görmek için sunucu günlüklerini kontrol edin (ör. `docker logs <container>` veya `journalctl`) — ve bu sayfayı Docker sunuyorsa sayfanın kendisinin de arka uçla birlikte kapanabileceğini unutmayın."
},
"nav": {
@@ -104,7 +104,7 @@
"update_check_failed": "Güncelleme kontrolü başarısız oldu: {{message}}",
"save_failed": "Kaydetme başarısız oldu: {{message}}",
"clear_failed": "Temizleme başarısız oldu: {{message}}",
"engine_switched": "{{family}} motoru {{engine}} olarak değiştirildi",
"engine_switched": "{{family}} {{engine}}",
"channel_set_failed": "Kanal ayarlanamadı: {{message}}",
"updater_downloading": "{{version}} indiriliyor…",
"updater_installed": "Yüklendi - yeniden başlatılıyor.",
@@ -492,12 +492,7 @@
"local_sqlite": "Yerel SQLite",
"translator_online": "Çevirmen çevrimiçi: {{provider}}",
"translator_offline": "Çevrimdışı çevirmen",
"no_tracking": "Yok — izleme yok",
"watermark_title": "Görünmez filigran",
"watermark_subtitle": "Varsayılan olarak açık. Üretilen sesi, sonradan tanınabilmesi için yapay zekâ üretimi olarak işaretler. Yalnızca bundan sonra üretilen sesi etkiler.",
"watermark_on_toast": "Yeni sesler için görünmez filigran etkinleştirildi.",
"watermark_off_toast": "Yeni sesler için görünmez filigran devre dışı bırakıldı.",
"watermark_failed": "Filigran ayarı değiştirilemedi."
"no_tracking": "Yok — izleme yok"
},
"consent": {
"title": "OmniVoice'un gelişmesine yardım eder misiniz?",
@@ -583,12 +578,7 @@
"routingRemote": "Uzaktan",
"routingUnknown": "Bilinmiyor",
"routingEffectiveChip": "Bu makinede {{device}} tarihinde çalışıyor",
"routingCaveatTitle": "GPU seçildi, ancak: {{reason}}",
"selectCpuFallback": "{{engine}}: CPU üzerinde çalışıyor — {{reason}}",
"selectWithCaveat": "{{engine}} motoruna geçildi — {{reason}}",
"generateCaveat": "{{engine}} bu bilgisayarda yavaş çalışabilir — {{reason}}",
"cpuLongText": "{{engine}} burada CPU üzerinde çalışıyor ve bu metin uzun — üretim süre bütçesini aşabilir. Daha kısa bir metin ya da CPU için ayarlanmış bir motor (OmniVoice GGUF, Supertonic-3) çok daha hızlı olacaktır.",
"cpuLongTextTuned": "{{engine}} burada CPU üzerinde çalışıyor ve bu metin uzun — üretim süre bütçesini aşabilir. Daha kısa bir metin ya da birkaç parçaya bölmek çok daha hızlı olacaktır."
"routingCaveatTitle": "GPU seçildi, ancak: {{reason}}"
},
"capture": {
"desc": "Genel kısayol tuşları yalnızca masaüstü uygulamasında çalışır. Web kullanıcı arayüzü, pencere odaktayken sayfa içi <1>Ctrl+Shift+Space</1> kısayolunu kullanır.",
@@ -1445,14 +1435,10 @@
"desc": "Endişelenmeyin; uygulamanın geri kalanı hala çalışıyor. Sekmeleri değiştirebilir veya aşağıda tekrar deneyebilirsiniz.",
"tryAgain": "Tekrar dene",
"openDocs": "Bu hata için dokümanları açın",
"crash_port_in_use": "3900 numaralı bağlantı noktası zaten kullanımda olduğu için arka uç başlatılamadı — OmniVoice'ın başka bir kopyası (ya da o bağlantı noktasını alan bir uygulama) onu tutuyor. Diğer örneği kapatıp yeniden başlatın; görünürde çalışan bir şey yoksa, önceki oturumdan kalan bir arka uç bağlantı noktasını hâlâ tutuyordur.",
"crash_oom_kill": "Süreç zorla sonlandırıldı (sinyal 9); bu genellikle işletim sisteminin belleğinin (RAM) tükendiği anlamına gelir. Belleği çok kullanan uygulamaları kapatın, Ayarlar → Modeller bölümünden daha küçük bir ASR modeli seçin veya deşifre etmeden önce TTS modelini bellekten kaldırın.",
"crash_native_fault": "Bellek yetersizliğinden değil, hesaplama yığınının içinde çöktü — bu, pakete dâhil CUDA çalışma zamanıyla uyuşmayan bir GPU sürücüsüne ya da eksik indirilmiş bir model dosyasına işaret eder. GPU sürücünüzü güncelleyin, ardından Ayarlar → Modeller bölümünden modeli yeniden indirin (eksik indirmeyi yerinde onarır). Tekrarlamaya devam ederse Ayarlar → Motorlar bölümünden çökme yalıtımlı bir motora geçin — sentez için \"OmniVoice (subprocess)\", deşifre için \"Faster-Whisper (crash-isolated subprocess)\". Bunlar modeli ayrı bir süreçte çalıştırır, böylece böyle bir çökme arka ucun tamamını değil yalnızca o süreci düşürür.",
"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}}",
"backend_shutting_down": "OmniVoice kapanıyor. Uygulamayı yeniden açıp tekrar dene."
"unexpected": "Beklenmeyen hata: {{message}}"
},
"keyboard": {
"title": "Klavye kısayolları",
@@ -1763,7 +1749,7 @@
"q_try_before": "Taahhüt etmeden önce deneyebilir miyim?",
"a_try_before": "Evet. Uygulamanın tamamı AGPL-3.0 kapsamında ücretsiz indirilebilir, çalıştırılabilir ve kendi sunucunuzda barındırılabilir — hiçbir sözleşme gerekmez. Ticari (tescilli kullanım) lisansını görüşmeye hazır olduğunuzda bize e-posta gönderin; ayrıntıları birlikte netleştirelim.",
"q_watermark": "Filigran ne olacak?",
"a_watermark": "Görünmez AudioSeal filigranı varsayılan olarak herkes için gömülür. Ayarlar → Gizlilik bölümünden kapatabilirsiniz; yalnızca değişiklikten sonra üretilen sesi etkiler."
"a_watermark": "Görünmez AudioSeal filigranı varsayılan olarak herkes için gömülür. Ticari lisans sahipleri bunu Ayarlar → Gizlilik bölümünden devre dışı bırakabilir."
},
"gallery_extra": {
"save_prompt": "Bu ses profili için bir ad girin:",
@@ -1905,15 +1891,11 @@
"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": "Devam eden işlem bitene kadar bekle — sonra güncellemeyi yükle.",
"busy": "Önce dublajını bitir — sonra güncellemeyi yükle.",
"whats_new": "Yenilikler",
"failed": "Güncelleme başarısız oldu",
"retry": "Yeniden dene",
"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"
"dismiss": "Reddet"
},
"archetypes": {
"featured": "Öne Çıkanlar",
@@ -2267,10 +2249,7 @@
"email": "E-posta",
"email_desc": "Lisanslama, ortaklıklar veya özel herhangi bir şey.",
"website": "Web sitesi",
"website_desc": "Proje ve yapımcı hakkında daha fazla bilgi.",
"follow_title": "X'te takip edin",
"follow_desc": "Sürüm notları, yeni motorlar ve sırada ne olduğuna dair ara ara bakışlar. Bir sohbet sunucusunda durmak istemiyorsanız pratik.",
"follow_cta": "X'te takip et"
"website_desc": "Proje ve yapımcı hakkında daha fazla bilgi."
},
"permissions": {
"title": "İzinler",
@@ -2300,4 +2279,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."
}
}
}
+10 -31
View File
@@ -2,7 +2,7 @@
"backendUnreachable": {
"contact_recent": "Він відповідав {{ago}} тому, а потім перестав — найімовірніше, він аварійно завершився або був примусово зупинений посеред запиту.",
"contact_never": "У цій сесії він не відповів жодного разу — можливо, він узагалі не запустився.",
"dev": "Не вдається зв'язатися з локальним бекендом OmniVoice. {{contact}} У `bun run dev` бекенд працює з автоперезавантаженням: будь-яка зміна файлу — зокрема збереження під час запиту — перезапускає його й обриває з'єднання. Спершу повторіть дію; якщо це було перезавантаження, усе спрацює. Якщо помилка повторюється, перевірте термінал із `bun run dev` на трасування Python або повідомлення про вихід, а також omnivoice.log у теці даних OmniVoice.",
"dev": "Не вдається з'єднатися з локальним бекендом OmniVoice. {{contact}} Перевірте термінал із `bun run dev` на Python-трейсбек чи банер завершення, а також файл omnivoice.log у теці даних OmniVoice — там буде останній запис бекенда.",
"server": "Не вдається з'єднатися із сервером бекенда OmniVoice. {{contact}} Перегляньте логи сервера, щоб знайти причину (наприклад, `docker logs <container>` або `journalctl`) — і майте на увазі: якщо цю сторінку обслуговує Docker, сама сторінка може впасти разом із бекендом."
},
"nav": {
@@ -104,7 +104,7 @@
"update_check_failed": "Не вдалося перевірити оновлення: {{message}}",
"save_failed": "Не вдалося зберегти: {{message}}",
"clear_failed": "Помилка очищення: {{message}}",
"engine_switched": "{{family}} перемкнено на {{engine}}",
"engine_switched": "{{family}} {{engine}}",
"channel_set_failed": "Не вдалося встановити канал: {{message}}",
"updater_downloading": "Завантаження {{version}}…",
"updater_installed": "Встановлено — перезапуск.",
@@ -492,12 +492,7 @@
"local_sqlite": "Локальний SQLite",
"translator_online": "Перекладач онлайн: {{provider}}",
"translator_offline": "Офлайн перекладач",
"no_tracking": "None — немає відстеження",
"watermark_title": "Невидимий водяний знак",
"watermark_subtitle": "Увімкнено за умовчанням. Позначає створене аудіо як згенероване ШІ, щоб його можна було розпізнати згодом. Стосується лише аудіо, створеного від цього моменту.",
"watermark_on_toast": "Невидимий водяний знак увімкнено для нового аудіо.",
"watermark_off_toast": "Невидимий водяний знак вимкнено для нового аудіо.",
"watermark_failed": "Не вдалося змінити налаштування водяного знака."
"no_tracking": "None — немає відстеження"
},
"consent": {
"title": "Допомогти покращити OmniVoice?",
@@ -583,12 +578,7 @@
"routingRemote": "Дистанційний",
"routingUnknown": "Невідомий",
"routingEffectiveChip": "Працює на {{device}} на цій машині",
"routingCaveatTitle": "Графічний процесор вибрано, але: {{reason}}",
"selectCpuFallback": "{{engine}}: працює на CPU — {{reason}}",
"selectWithCaveat": "Перемкнено на {{engine}} — {{reason}}",
"generateCaveat": "{{engine}} може працювати повільно на цьому комп'ютері — {{reason}}",
"cpuLongText": "Тут {{engine}} працює на CPU, а цей текст довгий — генерація може перевищити ліміт часу. Коротший текст або оптимізований під CPU рушій (OmniVoice GGUF, Supertonic-3) буде значно швидшим.",
"cpuLongTextTuned": "Тут {{engine}} працює на CPU, а цей текст довгий — генерація може перевищити ліміт часу. Коротший текст або розбиття на кілька частин буде значно швидшим."
"routingCaveatTitle": "Графічний процесор вибрано, але: {{reason}}"
},
"capture": {
"desc": "Глобальні гарячі клавіші працюють лише в настільній програмі. Веб-інтерфейс використовує ярлик <1>Ctrl+Shift+Пробіл</1> на сторінці, коли вікно має фокус.",
@@ -1445,14 +1435,10 @@
"desc": "Не хвилюйтеся — решта програми все ще працює. Ви можете змінити вкладку або спробувати ще раз нижче.",
"tryAgain": "Спробуйте знову",
"openDocs": "Відкрийте документи для цієї помилки",
"crash_port_in_use": "Бекенд не зміг запуститися, бо порт 3900 уже зайнято — його утримує інша копія OmniVoice (або застосунок, що захопив цей порт). Закрийте інший екземпляр і запустіть знову; якщо нічого видимого не працює, порт досі утримує осиротілий бекенд із попереднього сеансу.",
"crash_oom_kill": "Процес було примусово завершено (сигнал 9), що зазвичай означає брак оперативної пам'яті (RAM). Закрийте застосунки, що споживають багато пам'яті, виберіть меншу модель ASR у Налаштуваннях → Моделі або вивантажте модель TTS перед розшифруванням.",
"crash_native_fault": "Збій стався всередині обчислювального стека, а не через брак пам'яті — це вказує на драйвер GPU, який не відповідає вбудованому середовищу CUDA, або на не до кінця завантажений файл моделі. Оновіть драйвер GPU, потім повторно завантажте модель у Налаштуваннях → Моделі (часткове завантаження лагодиться на місці). Якщо це повторюється, перейдіть на ізольований рушій у Налаштуваннях → Рушії — «OmniVoice (subprocess)» для синтезу, «Faster-Whisper (crash-isolated subprocess)» для розшифрування. Вони запускають модель в окремому процесі, тож такий збій завершує лише цей процес, а не весь бекенд.",
"report": "Повідомити про цю помилку",
"report_failed": "Не вдалося відкрити звіт про помилку. Спробуйте ще раз або скопіюйте деталі вище в нове звернення.",
"searchIssues": "Шукати схожі проблеми",
"unexpected": "Неочікувана помилка: {{message}}",
"backend_shutting_down": "OmniVoice завершує роботу. Відкрийте застосунок знову та спробуйте ще раз."
"unexpected": "Неочікувана помилка: {{message}}"
},
"keyboard": {
"title": "Комбінації клавіш",
@@ -1763,7 +1749,7 @@
"q_try_before": "Чи можу я спробувати перед тим, як взяти участь?",
"a_try_before": "Так. Повну версію застосунку можна безкоштовно завантажити, запускати та хостити самостійно за AGPL-3.0 — без жодної угоди. Коли будете готові обговорити комерційну ліцензію (пропрієтарне використання), напишіть нам — разом узгодимо деталі.",
"q_watermark": "А як щодо водяного знака?",
"a_watermark": "Невидимий водяний знак AudioSeal вбудовується за умовчанням для всіх. Його можна вимкнути в Налаштуваннях → Приватність; це стосується лише аудіо, створеного після зміни."
"a_watermark": "Невидимий водяний знак AudioSeal вбудовано за замовчуванням для всіх. Комерційні ліцензіати можуть вимкнути його в Налаштуваннях → Конфіденційність."
},
"gallery_extra": {
"save_prompt": "Введіть назву для цього голосового профілю:",
@@ -1905,15 +1891,11 @@
"install_hint": "Завантажити оновлення та перезапустити в новій версії",
"downloading": "Оновлення… {{pct}}%",
"restart": "Перезапустити для оновлення",
"busy": "Зачекайте, доки завершиться поточна робота — потім встановіть оновлення.",
"busy": "Спочатку завершіть дубляж — потім встановіть оновлення.",
"whats_new": "Що нового",
"failed": "Не вдалося оновити",
"retry": "Повторіть спробу",
"dismiss": "Відхилити",
"toast_available": "OmniVoice Studio {{version}} доступна",
"toast_install": "Встановити та перезапустити",
"toast_whats_new": "Що нового",
"toast_later": "Пізніше"
"dismiss": "Відхилити"
},
"archetypes": {
"featured": "Рекомендовані",
@@ -2267,10 +2249,7 @@
"email": "Електронна пошта",
"email_desc": "Ліцензування, партнерство чи щось приватне.",
"website": "Веб-сайт",
"website_desc": "Детальніше про проект та виробника.",
"follow_title": "Стежте в X",
"follow_desc": "Нотатки до випусків, нові рушії та час від часу — що будується далі. Зручно, якщо не хочеться сидіти в чат-сервері.",
"follow_cta": "Стежити в X"
"website_desc": "Детальніше про проект та виробника."
},
"permissions": {
"title": "Дозволи",
@@ -2300,4 +2279,4 @@
"output_title": "Що повідомив застосунок",
"retry_hint": "Спробуйте «Повторити» в Налаштування → Журнали → Бекенд. Якщо знову не вдасться, «Очистити й повторити» перебудує середовище з нуля."
}
}
}
+10 -31
View File
@@ -2,7 +2,7 @@
"backendUnreachable": {
"contact_recent": "Nó vẫn phản hồi cách đây {{ago}} rồi ngừng phản hồi — rất có thể nó đã gặp sự cố hoặc bị buộc dừng giữa một yêu cầu.",
"contact_never": "Nó chưa hề phản hồi trong phiên này — có thể nó chưa bao giờ khởi động.",
"dev": "Không thể kết nối tới backend OmniVoice cục bộ. {{contact}} Với `bun run dev`, backend chạy ở chế độ tự tải lại: bất kỳ thay đổi tệp nào — kể cả lưu khi đang có yêu cầu — sẽ khởi động lại nó và làm mất kết nối. Hãy thử lại thao tác trước; nếu là do tải lại thì sẽ chạy được. Nếu vẫn lỗi, xem terminal đang chạy `bun run dev` để tìm traceback Python hoặc thông báo thoát, và omnivoice.log trong thư mục dữ liệu OmniVoice.",
"dev": "Không thể kết nối tới backend OmniVoice cục bộ. {{contact}} Hãy kiểm tra terminal đang chạy `bun run dev` để tìm traceback Python hoặc thông báo thoát, và xem tệp omnivoice.log trong thư mục dữ liệu OmniVoice để biết log cuối cùng của backend.",
"server": "Không thể kết nối tới máy chủ backend OmniVoice. {{contact}} Hãy kiểm tra log máy chủ để tìm nguyên nhân (ví dụ `docker logs <container>` hoặc `journalctl`) — và lưu ý nếu Docker phục vụ trang này thì chính trang này cũng có thể sập cùng backend."
},
"nav": {
@@ -104,7 +104,7 @@
"update_check_failed": "Kiểm tra cập nhật không thành công: {{message}}",
"save_failed": "Lưu không thành công: {{message}}",
"clear_failed": "Xóa không thành công: {{message}}",
"engine_switched": "Đã chuyển {{family}} sang {{engine}}",
"engine_switched": "{{family}} {{engine}}",
"channel_set_failed": "Không đặt được kênh: {{message}}",
"updater_downloading": "Đang tải xuống {{version}}…",
"updater_installed": "Đã cài đặt - khởi chạy lại.",
@@ -492,12 +492,7 @@
"local_sqlite": "SQLite cục bộ",
"translator_online": "Người dịch đang trực tuyến: {{provider}}",
"translator_offline": "Trình dịch ngoại tuyến",
"no_tracking": "Không có - không theo dõi",
"watermark_title": "Hình mờ vô hình",
"watermark_subtitle": "Bật theo mặc định. Đánh dấu âm thanh được tạo là do AI tạo ra để có thể nhận biết về sau. Chỉ ảnh hưởng đến âm thanh được tạo từ giờ trở đi.",
"watermark_on_toast": "Đã bật hình mờ vô hình cho âm thanh mới.",
"watermark_off_toast": "Đã tắt hình mờ vô hình cho âm thanh mới.",
"watermark_failed": "Không thể thay đổi cài đặt hình mờ."
"no_tracking": "Không có - không theo dõi"
},
"consent": {
"title": "Giúp cải thiện OmniVoice?",
@@ -583,12 +578,7 @@
"routingRemote": "Từ xa",
"routingUnknown": "Không xác định",
"routingEffectiveChip": "Chạy trên {{device}} trên máy này",
"routingCaveatTitle": "Đã chọn GPU nhưng: {{reason}}",
"selectCpuFallback": "{{engine}}: đang chạy trên CPU — {{reason}}",
"selectWithCaveat": "Đã chuyển sang {{engine}} — {{reason}}",
"generateCaveat": "{{engine}} có thể chạy chậm trên máy này — {{reason}}",
"cpuLongText": "{{engine}} chạy trên CPU ở đây và văn bản này dài — quá trình tạo có thể vượt quá giới hạn thời gian. Văn bản ngắn hơn hoặc một engine tối ưu cho CPU (OmniVoice GGUF, Supertonic-3) sẽ nhanh hơn nhiều.",
"cpuLongTextTuned": "{{engine}} chạy trên CPU ở đây và văn bản này dài — quá trình tạo có thể vượt quá giới hạn thời gian. Văn bản ngắn hơn, hoặc chia thành vài phần, sẽ nhanh hơn nhiều."
"routingCaveatTitle": "Đã chọn GPU nhưng: {{reason}}"
},
"capture": {
"desc": "Phím nóng chung chỉ hoạt động trong ứng dụng máy tính để bàn. Giao diện người dùng web sử dụng phím tắt <1>Ctrl+Shift+Space</1> trong trang trong khi cửa sổ đang có tiêu điểm.",
@@ -1445,14 +1435,10 @@
"desc": "Đừng lo lắng — phần còn lại của ứng dụng vẫn hoạt động. Bạn có thể chuyển đổi tab hoặc thử lại bên dưới.",
"tryAgain": "Thử lại",
"openDocs": "Mở tài liệu cho lỗi này",
"crash_port_in_use": "Không thể khởi động backend vì cổng 3900 đã được sử dụng — một bản OmniVoice khác (hoặc ứng dụng đã chiếm cổng đó) đang giữ nó. Hãy thoát bản kia rồi khởi chạy lại; nếu không thấy gì đang chạy, một backend còn sót từ phiên trước vẫn đang giữ cổng.",
"crash_oom_kill": "Tiến trình bị buộc dừng (tín hiệu 9), thường có nghĩa là hệ điều hành đã hết bộ nhớ (RAM). Hãy đóng các ứng dụng ngốn bộ nhớ, chọn mô hình ASR nhỏ hơn trong Cài đặt → Mô hình, hoặc giải phóng mô hình TTS trước khi chuyển lời nói thành văn bản.",
"crash_native_fault": "Nó sập bên trong tầng tính toán chứ không phải do hết bộ nhớ — điều này cho thấy trình điều khiển GPU không khớp với CUDA runtime đi kèm, hoặc tệp mô hình tải xuống không trọn vẹn. Hãy cập nhật trình điều khiển GPU, rồi tải lại mô hình trong Cài đặt → Mô hình (nó sửa bản tải dở ngay tại chỗ). Nếu vẫn tiếp diễn, hãy chuyển sang engine cách ly sự cố trong Cài đặt → Engine — \"OmniVoice (subprocess)\" cho tổng hợp giọng nói, \"Faster-Whisper (crash-isolated subprocess)\" cho chuyển lời nói thành văn bản. Chúng chạy mô hình trong tiến trình riêng, nên sự cố như thế chỉ hạ tiến trình đó thay vì toàn bộ backend.",
"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}}",
"backend_shutting_down": "OmniVoice đang tắt. Mở lại ứng dụng rồi thử lại."
"unexpected": "Lỗi không mong muốn: {{message}}"
},
"keyboard": {
"title": "Phím tắt",
@@ -1763,7 +1749,7 @@
"q_try_before": "Tôi có thể thử trước khi cam kết không?",
"a_try_before": "Có. Toàn bộ ứng dụng có thể tải xuống, chạy và tự lưu trữ miễn phí theo AGPL-3.0 — không cần bất kỳ thỏa thuận nào. Khi bạn sẵn sàng trao đổi về giấy phép thương mại (sử dụng độc quyền), hãy gửi email cho chúng tôi và chúng ta sẽ cùng thống nhất chi tiết.",
"q_watermark": "Còn hình mờ thì sao?",
"a_watermark": "Hình mờ vô hình AudioSeal được nhúng theo mặc định cho mọi người. Bạn có thể tắt trong Cài đặt → Quyền riêng tư; nó chỉ ảnh hưởng đến âm thanh được tạo sau khi thay đổi."
"a_watermark": "Hình mờ AudioSeal vô hình được nhúng theo mặc định cho tất cả mọi người. Người được cấp phép thương mại có thể tắt trong Cài đặt → Quyền riêng tư."
},
"gallery_extra": {
"save_prompt": "Nhập tên cho cấu hình giọng nói này:",
@@ -1905,15 +1891,11 @@
"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": "Chờ công việc đang chạy hoàn tất — rồi cài đặt bản 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.",
"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ỏ",
"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"
"dismiss": "Loại bỏ"
},
"archetypes": {
"featured": "Nổi bật",
@@ -2267,10 +2249,7 @@
"email": "Email",
"email_desc": "Cấp phép, hợp tác hoặc bất cứ điều gì riêng tư.",
"website": "Trang web",
"website_desc": "Thông tin thêm về dự án và nhà sản xuất.",
"follow_title": "Theo dõi trên X",
"follow_desc": "Ghi chú phát hành, engine mới, và đôi khi là hé lộ những gì đang được xây dựng. Tiện nếu bạn không muốn ở trong một máy chủ chat.",
"follow_cta": "Theo dõi trên X"
"website_desc": "Thông tin thêm về dự án và nhà sản xuất."
},
"permissions": {
"title": "Quyền truy cập",
@@ -2300,4 +2279,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."
}
}
}
+13 -34
View File
@@ -2,7 +2,7 @@
"backendUnreachable": {
"contact_recent": "它在 {{ago}}前还在响应,随后停止了响应——很可能是在处理请求时崩溃或被强制终止。",
"contact_never": "本次会话中它从未响应过——可能根本没有启动。",
"dev": "无法连接本地 OmniVoice 后端。{{contact}} 在 `bun run dev` 下后端以自动重载方式运行,任何文件改动(包括请求进行中的保存)都会重启它并中断连接。请先重试该操作;如果是重载导致的,重试即可成功。若仍然失败,请查看运行 `bun run dev` 的终端是否有 Python 回溯或退出信息,以及 OmniVoice 数据文件夹中的 omnivoice.log。",
"dev": "无法连接本地 OmniVoice 后端。{{contact}} 请查看运行 `bun run dev` 的终端是否有 Python 报错堆栈或退出提示,并查看 OmniVoice 数据文件夹中的 omnivoice.log 以了解后端最后记录的内容。",
"server": "无法连接 OmniVoice 后端服务器。{{contact}} 请查看服务器日志以找到原因(例如 `docker logs <容器>` 或 `journalctl`)——另外请注意:如果此页面由 Docker 提供,页面本身也可能随后端一起不可用。"
},
"stories": {
@@ -96,8 +96,8 @@
"frontend": "前端",
"tauri": "Tauri",
"cancelOp": "取消操作",
"dismiss": "关闭",
"dismissStatus": "关闭状态",
"dismiss": "解雇",
"dismissStatus": "解除状态",
"search": "搜索...",
"no_matches": "没有匹配项",
"recent_and_popular": "最近和热门",
@@ -325,7 +325,7 @@
"update_check_failed": "更新检查失败:{{message}}",
"save_failed": "保存失败:{{message}}",
"clear_failed": "清除失败:{{message}}",
"engine_switched": "已将 {{family}} 切换到 {{engine}}",
"engine_switched": "{{family}} {{engine}}",
"channel_set_failed": "无法设置频道:{{message}}",
"updater_downloading": "正在下载 {{version}}...",
"updater_installed": "已安装 - 重新启动。",
@@ -449,12 +449,7 @@
"local_sqlite": "本地 SQLite",
"translator_online": "翻译引擎在线:{{provider}}",
"translator_offline": "离线翻译",
"no_tracking": "无 — 不追踪",
"watermark_title": "不可见水印",
"watermark_subtitle": "默认开启。将生成的音频标记为 AI 制作,以便日后识别。仅影响从现在起生成的音频。",
"watermark_on_toast": "已为新音频启用不可见水印。",
"watermark_off_toast": "已为新音频停用不可见水印。",
"watermark_failed": "无法更改水印设置。"
"no_tracking": "无 — 不追踪"
},
"consent": {
"title": "帮助改进 OmniVoice",
@@ -542,12 +537,7 @@
"routingEffectiveChip": "在此机器上的 {{device}} 上运行",
"routingCaveatTitle": "已选择 GPU,但是:{{reason}}",
"curatedModelLabel": "模型",
"curatedModelAria": "{{engine}} 的模型",
"selectCpuFallback": "{{engine}}:正在 CPU 上运行 — {{reason}}",
"selectWithCaveat": "已切换到 {{engine}} — {{reason}}",
"generateCaveat": "{{engine}} 在这台机器上可能较慢 — {{reason}}",
"cpuLongText": "{{engine}} 在此机器上使用 CPU 运行,而这段文本较长 — 生成可能超出时间预算。缩短文本或改用面向 CPU 优化的引擎(OmniVoice GGUF、Supertonic-3)会快得多。",
"cpuLongTextTuned": "{{engine}} 在此机器上使用 CPU 运行,而这段文本较长 — 生成可能超出时间预算。缩短文本或分成几次生成会快得多。"
"curatedModelAria": "{{engine}} 的模型"
},
"capture": {
"desc": "全局热键仅在桌面应用中有效。网页界面使用页面内快捷键 <1>Ctrl+Shift+Space</1>(窗口聚焦时可用)。",
@@ -1249,7 +1239,7 @@
"later": "也许稍后",
"star": "在 GitHub 上加星标",
"opt_out": "不要再问",
"dismiss_aria": "关闭"
"dismiss_aria": "解雇"
}
},
"enterprise": {
@@ -1451,14 +1441,10 @@
"desc": "别担心——应用程序的其余部分仍然有效。您可以切换选项卡,或在下面重试。",
"tryAgain": "再试一次",
"openDocs": "打开此错误的文档",
"crash_port_in_use": "后端无法启动,因为端口 3900 已被占用 — 另一个 OmniVoice 实例(或占用该端口的应用)正持有它。请退出另一个实例后重新启动;如果看不到任何正在运行的程序,则是上一次会话遗留的后端仍占用该端口。",
"crash_oom_kill": "进程被强制终止(信号 9),通常表示操作系统内存(RAM)不足而将其停止。请关闭占用内存较大的应用,在设置 → 模型中选择更小的 ASR 模型,或在转写前卸载 TTS 模型。",
"crash_native_fault": "它是在计算栈内部崩溃的,而不是内存耗尽 — 这通常指向与内置 CUDA 运行时不匹配的 GPU 驱动,或未完整下载的模型文件。请更新 GPU 驱动,然后在设置 → 模型中重新下载该模型(可就地修复未完成的下载)。如果反复出现,请在设置 → 引擎中切换到崩溃隔离引擎 — 合成用「OmniVoice (subprocess)」,转写用「Faster-Whisper (crash-isolated subprocess)」。它们在独立进程中运行模型,因此这类崩溃只会终止该进程,而不是整个后端。",
"report": "报告此错误",
"report_failed": "无法打开错误报告。请重试,或将上面的详细信息复制到新的 issue 中。",
"searchIssues": "搜索类似问题",
"unexpected": "意外错误:{{message}}",
"backend_shutting_down": "OmniVoice 正在关闭。请重新打开应用后再试。"
"unexpected": "意外错误:{{message}}"
},
"keyboard": {
"title": "键盘快捷键",
@@ -1770,7 +1756,7 @@
"q_try_before": "我可以在提交之前尝试一下吗?",
"a_try_before": "可以。完整应用可在 AGPL-3.0 下免费下载、运行和自托管——无需任何协议。当您准备讨论商业(专有用途)许可证时,请发邮件联系我们,我们将一起敲定细节。",
"q_watermark": "水印呢?",
"a_watermark": "不可见的 AudioSeal 水印默认为所有人嵌入。你可以在设置 → 隐私中关闭它;该设置仅影响更改之后生成的音频。"
"a_watermark": "默认情况下为所有人嵌入不可见的 AudioSeal 水印。商业许可持有者可以在设置”→“隐私”中禁用它。"
},
"gallery_extra": {
"save_prompt": "输入此语音配置文件的名称:",
@@ -1912,15 +1898,11 @@
"install_hint": "下载更新并重启到新版本",
"downloading": "更新中… {{pct}}%",
"restart": "重启以更新",
"busy": "请等待正在进行的任务完成,再安装更新。",
"busy": "请先完成配音,再安装更新。",
"whats_new": "更新内容",
"failed": "更新失败",
"retry": "重试",
"dismiss": "关闭",
"toast_available": "OmniVoice Studio {{version}} 可用",
"toast_install": "安装并重启",
"toast_whats_new": "新增内容",
"toast_later": "稍后"
"dismiss": "解雇"
},
"archetypes": {
"featured": "精选",
@@ -2274,10 +2256,7 @@
"email": "电子邮件",
"email_desc": "许可、合作伙伴关系或任何私人的东西。",
"website": "网站",
"website_desc": "有关该项目和制造商的更多信息。",
"follow_title": "在 X 上关注",
"follow_desc": "版本说明、新引擎,以及偶尔透露接下来在做什么。如果你不想常驻聊天服务器,这里更方便。",
"follow_cta": "在 X 上关注"
"website_desc": "有关该项目和制造商的更多信息。"
},
"permissions": {
"title": "权限",
@@ -2307,4 +2286,4 @@
"output_title": "应用报告的内容",
"retry_hint": "请在 设置 → 日志 → 后端 中尝试“重试”。若再次失败,“清理并重试”会从头重建运行环境。"
}
}
}
+13 -34
View File
@@ -2,7 +2,7 @@
"backendUnreachable": {
"contact_recent": "它在 {{ago}}前還有回應,之後停止回應——很可能是在處理請求時當機或被強制終止。",
"contact_never": "本次工作階段中它從未回應——可能根本沒有啟動。",
"dev": "無法連線本機 OmniVoice 後端。{{contact}} 在 `bun run dev` 下後端以自動重新載入方式執行,任何檔案變動(包括請求進行中的儲存)都會重啟它並中斷連線。請先重試該操作;若是重新載入造成的,重試即可成功。若仍然失敗,請查看執行 `bun run dev` 的終端機是否有 Python 追蹤訊息或結束訊息,以及 OmniVoice 資料夾中的 omnivoice.log。",
"dev": "無法連線本機 OmniVoice 後端。{{contact}} 請查看執行 `bun run dev` 的終端機是否有 Python 錯誤堆疊或結束訊息,並查看 OmniVoice 資料夾中的 omnivoice.log 以了解後端最後記錄的內容。",
"server": "無法連線 OmniVoice 後端伺服器。{{contact}} 請查看伺服器日誌以找出原因(例如 `docker logs <容器>` 或 `journalctl`)——另請注意:如果此頁面由 Docker 提供,頁面本身也可能隨後端一起停止運作。"
},
"nav": {
@@ -104,7 +104,7 @@
"update_check_failed": "更新檢查失敗:{{message}}",
"save_failed": "儲存失敗:{{message}}",
"clear_failed": "清除失敗:{{message}}",
"engine_switched": "已將 {{family}} 切換至 {{engine}}",
"engine_switched": "{{family}} {{engine}}",
"channel_set_failed": "無法設定頻道:{{message}}",
"updater_downloading": "正在下載 {{version}}...",
"updater_installed": "已安裝 - 重新啟動。",
@@ -308,8 +308,8 @@
"frontend": "前端",
"tauri": "金牛座",
"cancelOp": "取消操作",
"dismiss": "關閉",
"dismissStatus": "關閉狀態",
"dismiss": "解僱",
"dismissStatus": "解除狀態",
"search": "搜尋...",
"no_matches": "沒有匹配項",
"recent_and_popular": "最近和熱門",
@@ -492,12 +492,7 @@
"local_sqlite": "本地SQLite",
"translator_online": "譯者在線:{{provider}}",
"translator_offline": "離線翻譯器",
"no_tracking": "無 — 不跟蹤",
"watermark_title": "不可見浮水印",
"watermark_subtitle": "預設開啟。將產生的音訊標記為 AI 製作,以便日後識別。僅影響從現在起產生的音訊。",
"watermark_on_toast": "已為新音訊啟用不可見浮水印。",
"watermark_off_toast": "已為新音訊停用不可見浮水印。",
"watermark_failed": "無法變更浮水印設定。"
"no_tracking": "無 — 不跟蹤"
},
"consent": {
"title": "協助改進 OmniVoice",
@@ -583,12 +578,7 @@
"routingRemote": "遠端",
"routingUnknown": "未知",
"routingEffectiveChip": "在此機器上的 {{device}} 上執行",
"routingCaveatTitle": "已選擇 GPU,但是:{{reason}}",
"selectCpuFallback": "{{engine}}:正在 CPU 上執行 — {{reason}}",
"selectWithCaveat": "已切換至 {{engine}} — {{reason}}",
"generateCaveat": "{{engine}} 在這台機器上可能較慢 — {{reason}}",
"cpuLongText": "{{engine}} 在此機器上使用 CPU 執行,而這段文字較長 — 生成可能超出時間預算。縮短文字或改用針對 CPU 最佳化的引擎(OmniVoice GGUF、Supertonic-3)會快得多。",
"cpuLongTextTuned": "{{engine}} 在此機器上使用 CPU 執行,而這段文字較長 — 生成可能超出時間預算。縮短文字或分成幾次生成會快得多。"
"routingCaveatTitle": "已選擇 GPU,但是:{{reason}}"
},
"capture": {
"desc": "全域熱鍵僅適用於桌面應用程式。當視窗具有焦點時,Web UI 使用頁內 <1>Ctrl+Shift+Space</1> 快速鍵。",
@@ -1286,7 +1276,7 @@
"later": "也許稍後",
"star": "在 GitHub 上加星標",
"opt_out": "不要再問",
"dismiss_aria": "關閉"
"dismiss_aria": "解僱"
}
},
"enterprise": {
@@ -1445,14 +1435,10 @@
"desc": "別擔心——應用程式的其餘部分仍然有效。您可以切換選項卡,或在下面重試。",
"tryAgain": "再試一次",
"openDocs": "開啟此錯誤的文檔",
"crash_port_in_use": "後端無法啟動,因為連接埠 3900 已被占用 — 另一個 OmniVoice 執行個體(或占用該連接埠的應用程式)正持有它。請結束另一個執行個體後重新啟動;若看不到任何正在執行的程式,則是上一次工作階段殘留的後端仍占用該連接埠。",
"crash_oom_kill": "程序遭強制終止(信號 9),通常表示作業系統記憶體(RAM)不足而將其停止。請關閉占用記憶體較大的應用程式,在設定 → 模型中選擇較小的 ASR 模型,或在轉錄前卸載 TTS 模型。",
"crash_native_fault": "它是在計算堆疊內部崩潰的,而不是記憶體耗盡 — 這通常指向與內建 CUDA 執行環境不相符的 GPU 驅動程式,或未完整下載的模型檔案。請更新 GPU 驅動程式,然後在設定 → 模型中重新下載該模型(可就地修復未完成的下載)。若反覆發生,請在設定 → 引擎中切換到崩潰隔離引擎 — 合成用「OmniVoice (subprocess)」,轉錄用「Faster-Whisper (crash-isolated subprocess)」。它們在獨立程序中執行模型,因此這類崩潰只會終止該程序,而非整個後端。",
"report": "回報此錯誤",
"report_failed": "無法開啟錯誤回報。請重試,或將上方的詳細資訊複製到新的 issue 中。",
"searchIssues": "搜尋類似問題",
"unexpected": "未預期的錯誤:{{message}}",
"backend_shutting_down": "OmniVoice 正在關閉。請重新開啟應用程式後再試。"
"unexpected": "未預期的錯誤:{{message}}"
},
"keyboard": {
"title": "鍵盤快速鍵",
@@ -1763,7 +1749,7 @@
"q_try_before": "我可以在提交之前嘗試一下嗎?",
"a_try_before": "可以。完整應用程式可在 AGPL-3.0 下免費下載、執行與自行架設——無需任何協議。當您準備討論商業(專有用途)授權時,請寄信給我們,我們會一起確認細節。",
"q_watermark": "水印呢?",
"a_watermark": "不可見的 AudioSeal 浮水印預設會為所有人嵌入。你可以在設定 → 隱私中關閉;此設定僅影響變更之後產生的音訊。"
"a_watermark": "預設為所有人嵌入不可見的 AudioSeal 浮水印。商業授權持有者可以在設定」→「隱私權」中停用它。"
},
"gallery_extra": {
"save_prompt": "輸入此語音設定檔的名稱:",
@@ -1905,15 +1891,11 @@
"install_hint": "下載更新並重新啟動至新版本",
"downloading": "更新中… {{pct}}%",
"restart": "重新啟動以更新",
"busy": "請等待正在進行的工作完成,再安裝更新。",
"busy": "請先完成配音,再安裝更新。",
"whats_new": "有什麼新消息",
"failed": "更新失敗",
"retry": "重試",
"dismiss": "關閉",
"toast_available": "OmniVoice Studio {{version}} 可用",
"toast_install": "安裝並重新啟動",
"toast_whats_new": "新增內容",
"toast_later": "稍後"
"dismiss": "解僱"
},
"archetypes": {
"featured": "精選",
@@ -2267,10 +2249,7 @@
"email": "電子郵件",
"email_desc": "許可、合作夥伴關係或任何私人的東西。",
"website": "網站",
"website_desc": "有關該項目和製造商的更多資訊。",
"follow_title": "在 X 上追蹤",
"follow_desc": "版本說明、新引擎,以及偶爾透露接下來在做什麼。如果你不想常駐聊天伺服器,這裡更方便。",
"follow_cta": "在 X 上追蹤"
"website_desc": "有關該項目和製造商的更多資訊。"
},
"permissions": {
"title": "權限",
@@ -2300,4 +2279,4 @@
"output_title": "應用程式回報的內容",
"retry_hint": "請在 設定 → 記錄 → 後端 中嘗試「重試」。若再次失敗,「清理並重試」會從頭重建執行環境。"
}
}
}
+2 -6
View File
@@ -2533,16 +2533,12 @@ input[type="file"]::file-selector-button:hover {
}
.app-startup__title { font-size: 18px; color: #ebdbb2; }
.app-wizard-wrap {
/* 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. */
/* Fill viewport above the fixed LogsFooter */
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
bottom: var(--logs-footer-height, 28px);
overflow: hidden;
background: var(--color-bg, #1d2021);
display: flex;
-7
View File
@@ -6,13 +6,6 @@ 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 13.3 ships
// WKWebView 16.4; Linux takes whatever WebKitGTK the distro has, which is the
// version we cannot pin). 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`
+1 -6
View File
@@ -334,12 +334,7 @@ export default function AudiobookTab({ profiles = [] }) {
setChapters((prev) =>
prev.map((c, j) =>
j === evt.index
? {
...c,
title: evt.title,
status: 'failed',
error: evt.reason || evt.error || '',
}
? { ...c, title: evt.title, status: 'failed' }
: j === evt.index + 1 && c.status === 'pending'
? { ...c, status: 'rendering' }
: c,

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